From 09f98307f46eb4bd604789658842ef1fcb4b24ad Mon Sep 17 00:00:00 2001 From: Maxim Colls Date: Thu, 16 Nov 2017 15:34:55 +0100 Subject: [PATCH 001/206] Do not upcase State abbreviation --- app/serializers/api/state_serializer.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/serializers/api/state_serializer.rb b/app/serializers/api/state_serializer.rb index a43e5665b3..38ad444701 100644 --- a/app/serializers/api/state_serializer.rb +++ b/app/serializers/api/state_serializer.rb @@ -1,7 +1,3 @@ class Api::StateSerializer < ActiveModel::Serializer attributes :id, :name, :abbr - - def abbr - object.abbr.upcase - end end From 5db1559f28b920bc740bab9863c1bd0809ba187e Mon Sep 17 00:00:00 2001 From: Maxim Colls Date: Tue, 28 Nov 2017 10:15:02 +0000 Subject: [PATCH 002/206] Fixed spec to use unuppercased states --- spec/features/consumer/registration_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/features/consumer/registration_spec.rb b/spec/features/consumer/registration_spec.rb index 70d132b11b..b0818cd79b 100644 --- a/spec/features/consumer/registration_spec.rb +++ b/spec/features/consumer/registration_spec.rb @@ -49,7 +49,7 @@ feature "Registration", js: true do fill_in 'enterprise_city', with: 'Northcote' fill_in 'enterprise_zipcode', with: '3070' expect(page).to have_select('enterprise_country', options: %w(Albania Australia), selected: 'Australia') - select 'VIC', from: 'enterprise_state' + select 'Vic', from: 'enterprise_state' perform_and_ensure(:click_button, "Continue", lambda { page.has_content? 'Who is responsible for managing My Awesome Enterprise?' }) From 5777d04821345e3cecaf62c197d873a68c8d6a03 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Fri, 30 Mar 2018 14:30:40 +0100 Subject: [PATCH 003/206] Use angular translation filter on image upload modal --- .../javascripts/templates/admin/modals/image_upload.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/templates/admin/modals/image_upload.html.haml b/app/assets/javascripts/templates/admin/modals/image_upload.html.haml index 76a96805ef..e5c0f55541 100644 --- a/app/assets/javascripts/templates/admin/modals/image_upload.html.haml +++ b/app/assets/javascripts/templates/admin/modals/image_upload.html.haml @@ -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.bulk_edit.upload_an_image' | t }} %input#image-upload{hidden: true, type: 'file', 'nv-file-select' => true, uploader: "imageUploader"} From 37ba1e6c8325cfb32f68d4c7c192d47e42faf590 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Sun, 15 Apr 2018 18:22:10 +0100 Subject: [PATCH 004/206] Close modal when image updated successfully --- .../product_image_controller.js.coffee | 6 ++-- .../admin/bulk_product_update_spec.rb | 28 ------------------- 2 files changed, 4 insertions(+), 30 deletions(-) diff --git a/app/assets/javascripts/admin/products/controllers/product_image_controller.js.coffee b/app/assets/javascripts/admin/products/controllers/product_image_controller.js.coffee index 1fb2ceea06..fb447208e2 100644 --- a/app/assets/javascripts/admin/products/controllers/product_image_controller.js.coffee +++ b/app/assets/javascripts/admin/products/controllers/product_image_controller.js.coffee @@ -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() diff --git a/spec/features/admin/bulk_product_update_spec.rb b/spec/features/admin/bulk_product_update_spec.rb index 91f28d5498..137612b174 100644 --- a/spec/features/admin/bulk_product_update_spec.rb +++ b/spec/features/admin/bulk_product_update_spec.rb @@ -758,7 +758,6 @@ feature %q{ # Shows default image when no image set expect(page).to have_css "img[src='/assets/noimage/mini.png']" - @old_thumb_src = page.find("a.image-modal img")['src'] # Click image page.find("a.image-modal").trigger('click') @@ -770,7 +769,6 @@ feature %q{ within "div.reveal-modal.product-image-upload" do # Shows preview of current image expect(page).to have_css "img.preview" - old_image_src = page.find("img.preview")['src'] # Upload a new image file attach_file 'image-upload', Rails.root.join("public/500.jpg"), visible: false @@ -778,14 +776,6 @@ feature %q{ # Shows spinner whilst loading expect(page).to have_css "img.spinner", visible: true expect(page).to_not have_css "img.spinner", visible: true - - # Shows new image when finished - expect(page).to have_css "img.preview" - @new_image_src = page.find("img.preview")['src'] - expect(old_image_src) != @new_image_src - - # Close modal - page.find("a.close-reveal-modal").click end expect(page).to_not have_selector "div.reveal-modal.product-image-upload" @@ -799,24 +789,6 @@ feature %q{ end expect(page).to have_selector "div.reveal-modal.product-image-upload" - - within "div.reveal-modal.product-image-upload" do - # Upload another image file - attach_file 'image-upload', Rails.root.join("public/422.jpg"), visible: false - - # Overwrites existing image - expect(page).to have_css "img.preview" - newer_image_src = page.find("img.preview")['src'] - expect(@new_image_src) != newer_image_src - - page.find("a.close-reveal-modal").click - end - - within "table#listing_products tr#p_#{product.id}" do - # Newer thumbnail is shown in image column - newer_thumb_src = page.find("a.image-modal img")['src'] - expect(@new_thumb_src) != newer_thumb_src - end end end end From 50ffd7ca01f60572d367c0a4941089acf4453950 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Fri, 2 Mar 2018 10:48:22 +1100 Subject: [PATCH 005/206] Clear adjustments on subs orders when no items are able to be fulfilled This prevents shipping and payment fees from being displayed in the notification email --- app/jobs/subscription_placement_job.rb | 1 + spec/jobs/subscription_placement_job_spec.rb | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/jobs/subscription_placement_job.rb b/app/jobs/subscription_placement_job.rb index 91d395157f..b3d0f7fd83 100644 --- a/app/jobs/subscription_placement_job.rb +++ b/app/jobs/subscription_placement_job.rb @@ -34,6 +34,7 @@ class SubscriptionPlacementJob changes = cap_quantity_and_store_changes(order) if order.line_items.where('quantity > 0').empty? + order.adjustments.destroy_all return send_empty_email(order, changes) end diff --git a/spec/jobs/subscription_placement_job_spec.rb b/spec/jobs/subscription_placement_job_spec.rb index d1f6e1dfd9..afa0e18f86 100644 --- a/spec/jobs/subscription_placement_job_spec.rb +++ b/spec/jobs/subscription_placement_job_spec.rb @@ -147,8 +147,9 @@ describe SubscriptionPlacementJob do allow(job).to receive(:unavailable_stock_lines_for) { order.line_items } end - it "does not place the order, sends an empty_order email" do + it "does not place the order, clears, all adjustments, and sends an empty_order email" do expect{ job.send(:process, order) }.to_not change{ order.reload.completed_at }.from(nil) + expect(order.reload.adjustments).to be_empty expect(job).to_not have_received(:send_placement_email) expect(job).to have_received(:send_empty_email) end From f5e77cdcec9e76a3fbd1b35fd4c48e6ef38c6bf9 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Wed, 11 Apr 2018 11:08:09 +1000 Subject: [PATCH 006/206] Ensure order total for uplaced subscription orders is zero --- app/jobs/subscription_placement_job.rb | 2 +- spec/jobs/subscription_placement_job_spec.rb | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/jobs/subscription_placement_job.rb b/app/jobs/subscription_placement_job.rb index b3d0f7fd83..c4412b93e1 100644 --- a/app/jobs/subscription_placement_job.rb +++ b/app/jobs/subscription_placement_job.rb @@ -34,7 +34,7 @@ class SubscriptionPlacementJob changes = cap_quantity_and_store_changes(order) if order.line_items.where('quantity > 0').empty? - order.adjustments.destroy_all + order.reload.adjustments.destroy_all return send_empty_email(order, changes) end diff --git a/spec/jobs/subscription_placement_job_spec.rb b/spec/jobs/subscription_placement_job_spec.rb index afa0e18f86..0f38c8fdcd 100644 --- a/spec/jobs/subscription_placement_job_spec.rb +++ b/spec/jobs/subscription_placement_job_spec.rb @@ -149,7 +149,8 @@ describe SubscriptionPlacementJob do it "does not place the order, clears, all adjustments, and sends an empty_order email" do expect{ job.send(:process, order) }.to_not change{ order.reload.completed_at }.from(nil) - expect(order.reload.adjustments).to be_empty + expect(order.adjustments).to be_empty + expect(order.total).to eq 0 expect(job).to_not have_received(:send_placement_email) expect(job).to have_received(:send_empty_email) end From 6a71aafce154ddb12665ffbe4a3dda96e2c15b3e Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Wed, 11 Apr 2018 13:40:42 +1000 Subject: [PATCH 007/206] Update totals for empty order before sending email --- app/jobs/subscription_placement_job.rb | 1 + spec/jobs/subscription_placement_job_spec.rb | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/app/jobs/subscription_placement_job.rb b/app/jobs/subscription_placement_job.rb index c4412b93e1..348420c0f8 100644 --- a/app/jobs/subscription_placement_job.rb +++ b/app/jobs/subscription_placement_job.rb @@ -35,6 +35,7 @@ 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 diff --git a/spec/jobs/subscription_placement_job_spec.rb b/spec/jobs/subscription_placement_job_spec.rb index 0f38c8fdcd..b1013081d6 100644 --- a/spec/jobs/subscription_placement_job_spec.rb +++ b/spec/jobs/subscription_placement_job_spec.rb @@ -120,7 +120,12 @@ describe SubscriptionPlacementJob do describe "processing a subscription order" do let(:subscription) { create(:subscription, with_items: true) } + let(:shop) { subscription.shop } let(:proxy_order) { create(:proxy_order, subscription: subscription) } + let(:oc) { proxy_order.order_cycle } + let(:ex) { oc.exchanges.outgoing.find_by_sender_id_and_receiver_id(shop.id, shop.id) } + let(:fee) { create(:enterprise_fee, enterprise: shop, fee_type: 'sales', amount: 10) } + let!(:exchange_fee) { ExchangeFee.create!(exchange: ex, enterprise_fee: fee) } let!(:order) { proxy_order.initialise_order! } before do @@ -151,6 +156,7 @@ describe SubscriptionPlacementJob do expect{ job.send(:process, order) }.to_not change{ order.reload.completed_at }.from(nil) expect(order.adjustments).to be_empty expect(order.total).to eq 0 + expect(order.adjustment_total).to eq 0 expect(job).to_not have_received(:send_placement_email) expect(job).to have_received(:send_empty_email) end From 9f06d1f809c817e5314a6e6b145d737d9ff5f060 Mon Sep 17 00:00:00 2001 From: Pau Perez Date: Tue, 17 Apr 2018 19:22:03 +0200 Subject: [PATCH 008/206] Disable totally unreliable feature We need to investigate why it fails so many times fix it and then enable it back. As it is, it brings cons than pros preventing even PRs that don't touch code from being merged. --- spec/features/consumer/shopping/embedded_shopfronts_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/features/consumer/shopping/embedded_shopfronts_spec.rb b/spec/features/consumer/shopping/embedded_shopfronts_spec.rb index deb79a5ad2..87b6a7e5ca 100644 --- a/spec/features/consumer/shopping/embedded_shopfronts_spec.rb +++ b/spec/features/consumer/shopping/embedded_shopfronts_spec.rb @@ -47,7 +47,7 @@ feature "Using embedded shopfront functionality", js: true do end end - it "allows shopping and checkout" do + xit "allows shopping and checkout" do within_frame 'test_iframe' do fill_in "variants[#{variant.id}]", with: 1 wait_until_enabled 'input.add_to_cart' From cd9d13cb2acf3d6e3fa45b7168e12ea17bf0b502 Mon Sep 17 00:00:00 2001 From: Pau Perez Date: Tue, 17 Apr 2018 17:53:25 +0200 Subject: [PATCH 009/206] Add images to be used in Spree upgrade wiki page --- doc/img/spree_upgrade_branches.jpg | Bin 0 -> 29389 bytes doc/img/spree_upgrade_epics.jpg | Bin 0 -> 25674 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100755 doc/img/spree_upgrade_branches.jpg create mode 100755 doc/img/spree_upgrade_epics.jpg diff --git a/doc/img/spree_upgrade_branches.jpg b/doc/img/spree_upgrade_branches.jpg new file mode 100755 index 0000000000000000000000000000000000000000..f633de8c491360eeb17baf155bc076b89e9f10a4 GIT binary patch literal 29389 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%0AyuyrUGi?FaIIn;kXLpv@Y7 zm8HPSb_Gt|YR&!XQMZArHU(w_8*+JWx+c}(t?+kideUpsHA^q>0rZ0iGxPhVi}3-} z_Z4&5w-q~Sm?$MJVxk3x@#OF}Y@Vx;Pr?sYNpR8mGjYb>)hi_nX!&qAeADT>7Z{k! zSskw)6IQyDB$~>b!K>6Jeg>DCk6hEeD=8&zCHqWbNbAfq%4tklXbSn&2^_dl)}d=< zuh02Gj`zK~=C$3Jc!#{lgl2}Rfot4{C$9ra03S@vV;;2lbV`lDqojpZ$>QGhDFR~l zD8v}$j288`XmI5VL<`@YnoWaGXYmq*kAVEP=0(bfwx{tMZu>Zj8vVQNK^a~8^+Ubw z8!mej)EvNcAmeN|-r%=P))J+&wqyTHA8@2=OB_5wEK%XX0M9%2mjh1GVEldee*lOm B8WI2i literal 0 HcmV?d00001 diff --git a/doc/img/spree_upgrade_epics.jpg b/doc/img/spree_upgrade_epics.jpg new file mode 100755 index 0000000000000000000000000000000000000000..38096c0bddd847887c6943a8084fba0745a0e978 GIT binary patch literal 25674 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 literal 0 HcmV?d00001 From ef3ef2d4f3c7fc7d770503a5ec822987975391a7 Mon Sep 17 00:00:00 2001 From: Pau Perez Date: Mon, 16 Apr 2018 12:47:57 +0200 Subject: [PATCH 010/206] Upgrade Rails to 3.2.22.5 to get security fixes This will fix reported vulnerabilities CVE-2015-7576, CVE-2016-2098 (reported as high severity), CVE-2016-0751 and CVE-2015-7577. --- Gemfile | 2 +- Gemfile.lock | 58 ++++++++++++++++++++++++++-------------------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/Gemfile b/Gemfile index 0e2d21d89c..392e9df765 100644 --- a/Gemfile +++ b/Gemfile @@ -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' diff --git a/Gemfile.lock b/Gemfile.lock index 44197d99a4..2e7f98adc5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -137,12 +137,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) @@ -157,18 +157,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) @@ -472,7 +472,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) @@ -501,7 +501,7 @@ GEM rack (>= 1.2, < 3) oj (2.1.2) orm_adapter (0.5.0) - paper_trail (3.0.8) + paper_trail (3.0.9) activerecord (>= 3.0, < 5.0) activesupport (>= 3.0, < 5.0) paperclip (3.5.4) @@ -539,7 +539,7 @@ GEM activesupport (>= 2.3.14) multi_json (~> 1.0) rack (1.4.7) - rack-cache (1.7.0) + rack-cache (1.7.1) rack (>= 0.4) rack-livereload (0.3.16) rack @@ -547,20 +547,20 @@ GEM 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) @@ -666,7 +666,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) @@ -770,7 +770,7 @@ DEPENDENCIES rabl rack-livereload rack-ssl - rails (= 3.2.21) + rails (~> 3.2.22) rails-i18n (~> 3.0.0) redcarpet representative_view From c77a01815c960bf3f5aaf7c2debad3b63c1dc49d Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Wed, 21 Mar 2018 12:25:03 +1100 Subject: [PATCH 011/206] Copy datepicker directive to utils module Ensures that datepicker is available for subscriptions --- .../admin/utils/directives/date_picker.js.coffee | 9 +++++++++ spec/features/admin/subscriptions_spec.rb | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 app/assets/javascripts/admin/utils/directives/date_picker.js.coffee diff --git a/app/assets/javascripts/admin/utils/directives/date_picker.js.coffee b/app/assets/javascripts/admin/utils/directives/date_picker.js.coffee new file mode 100644 index 0000000000..c6c8b4fba6 --- /dev/null +++ b/app/assets/javascripts/admin/utils/directives/date_picker.js.coffee @@ -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 diff --git a/spec/features/admin/subscriptions_spec.rb b/spec/features/admin/subscriptions_spec.rb index 9bc5377bb6..ad115cbb5a 100644 --- a/spec/features/admin/subscriptions_spec.rb +++ b/spec/features/admin/subscriptions_spec.rb @@ -159,7 +159,10 @@ feature 'Subscriptions' do click_button('Next') expect(page).to have_content 'can\'t be blank', count: 2 expect(page).to have_content 'Oops! Please fill in all of the required fields...' - fill_in 'begins_at', with: Time.zone.today.strftime('%F') + find_field('begins_at').click + within(".ui-datepicker-calendar") do + find('.ui-datepicker-today').click + end select2_select card2_option, from: 'credit_card_id' click_button('Next') From cd4268d219ece360c289868c2f47aa1fd574bf6e Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Wed, 18 Apr 2018 19:16:49 +0100 Subject: [PATCH 012/206] Add manager dropdown UX --- .../enterprises/controllers/enterprise_controller.js.coffee | 5 ++++- app/views/admin/enterprises/form/_users.html.haml | 1 - spec/features/admin/enterprise_roles_spec.rb | 1 - 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/admin/enterprises/controllers/enterprise_controller.js.coffee b/app/assets/javascripts/admin/enterprises/controllers/enterprise_controller.js.coffee index 2d1dc494f4..f017bae18f 100644 --- a/app/assets/javascripts/admin/enterprises/controllers/enterprise_controller.js.coffee +++ b/app/assets/javascripts/admin/enterprises/controllers/enterprise_controller.js.coffee @@ -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.splice(0, 0, manager) $scope.enterprise_form?.$setDirty() else alert ("#{manager.email}" + " " + t("is_already_manager")) diff --git a/app/views/admin/enterprises/form/_users.html.haml b/app/views/admin/enterprises/form/_users.html.haml index adc6d4ba67..89696d112b 100644 --- a/app/views/admin/enterprises/form/_users.html.haml +++ b/app/views/admin/enterprises/form/_users.html.haml @@ -44,7 +44,6 @@ - # Ignore this input in the submit = hidden_field_tag :ignored, nil, class: "select2 fullwidth", 'user-select' => 'newManager', 'ng-model' => 'newManager' %td.actions - %a{ 'ng-click' => 'addManager(newManager)', :class => "icon-plus no-text" } %tr.animate-repeat{ id: "manager-{{manager.id}}", ng: { repeat: 'manager in Enterprise.users' }} %td = hidden_field_tag "enterprise[user_ids][]", nil, multiple: true, 'ng-value' => 'manager.id' diff --git a/spec/features/admin/enterprise_roles_spec.rb b/spec/features/admin/enterprise_roles_spec.rb index 5029a8b054..751eef36d7 100644 --- a/spec/features/admin/enterprise_roles_spec.rb +++ b/spec/features/admin/enterprise_roles_spec.rb @@ -111,7 +111,6 @@ feature %q{ it "allows adding new managers" do within 'table.managers' do targetted_select2_search user3.email, from: '#s2id_ignored' - find('a.icon-plus.no-text').click # user3 has been added and has an unconfirmed email address expect(page).to have_css "tr#manager-#{user3.id}" From 3fc49d59354f1c4fe6452c6cc445469d198fb7a4 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Mon, 23 Apr 2018 12:55:37 +1000 Subject: [PATCH 013/206] Simplify code --- .../enterprises/controllers/enterprise_controller.js.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/admin/enterprises/controllers/enterprise_controller.js.coffee b/app/assets/javascripts/admin/enterprises/controllers/enterprise_controller.js.coffee index f017bae18f..bb1ae6b31a 100644 --- a/app/assets/javascripts/admin/enterprises/controllers/enterprise_controller.js.coffee +++ b/app/assets/javascripts/admin/enterprises/controllers/enterprise_controller.js.coffee @@ -50,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.splice(0, 0, manager) + $scope.Enterprise.users.unshift(manager) $scope.enterprise_form?.$setDirty() else alert ("#{manager.email}" + " " + t("is_already_manager")) From 11081ab1d61397c938b27b98dfc22f55c4131d82 Mon Sep 17 00:00:00 2001 From: Pau Perez Date: Mon, 23 Apr 2018 12:51:09 +0200 Subject: [PATCH 014/206] Autocorrect Rubocop's Layout/CommentIndentation cop --- .rubocop_todo.yml | 7 ----- Guardfile | 40 +------------------------ lib/open_food_network/packing_report.rb | 6 ---- script/rubocop_autocorrect | 10 +++++++ 4 files changed, 11 insertions(+), 52 deletions(-) diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 72bef1c992..e94f716a1f 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -84,13 +84,6 @@ Layout/AlignParameters: - 'spec/lib/open_food_network/user_balance_calculator_spec.rb' - 'spec/support/request/authentication_workflow.rb' -# Offense count: 8 -# Cop supports --auto-correct. -Layout/CommentIndentation: - Exclude: - - 'Guardfile' - - 'lib/open_food_network/packing_report.rb' - # Offense count: 8 # Cop supports --auto-correct. Layout/ElseAlignment: diff --git a/Guardfile b/Guardfile index 9dc491669e..4ae1efda60 100644 --- a/Guardfile +++ b/Guardfile @@ -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 diff --git a/lib/open_food_network/packing_report.rb b/lib/open_food_network/packing_report.rb index 4c9eb783ec..d24048fcf6 100644 --- a/lib/open_food_network/packing_report.rb +++ b/lib/open_food_network/packing_report.rb @@ -59,9 +59,6 @@ module OpenFoodNetwork def rules if is_by_customer? -# customer_rows orders -# table_items = @line_items - [ { group_by: proc { |line_item| line_item.order.distributor }, sort_by: proc { |distributor| distributor.name } }, @@ -84,9 +81,6 @@ module OpenFoodNetwork sort_by: proc { |full_name| full_name } } ] else -# supplier_rows orders -# table_items = supplier_rows orders -# [ { group_by: proc { |line_item| line_item.order.distributor }, sort_by: proc { |distributor| distributor.name } }, { group_by: proc { |line_item| line_item.product.supplier }, diff --git a/script/rubocop_autocorrect b/script/rubocop_autocorrect index 1ca248ddfa..f9e7cac2fc 100755 --- a/script/rubocop_autocorrect +++ b/script/rubocop_autocorrect @@ -1,5 +1,15 @@ #!/bin/sh +# Usage +# +# 1. Clean any git unstagged or untracked changes. Consider creating a new branch +# 2. Remove a cop's exclusion paragraph from the .rubocop_todo.yml +# 3. Run: +# +# $ ./script/rubocop_autocorrect +# +# This will commit all the changes. + set -e COP="$1" From 25ad9b22aae814d20220c9eecf8983bb3941e1f7 Mon Sep 17 00:00:00 2001 From: Pau Perez Date: Mon, 23 Apr 2018 13:03:05 +0200 Subject: [PATCH 015/206] Update .rubocop_todo.yml --- .rubocop_todo.yml | 182 ++++++++++++++++++++-------------------------- 1 file changed, 79 insertions(+), 103 deletions(-) diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index e94f716a1f..bd93ff1266 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,12 +1,12 @@ # This configuration was generated by # `rubocop --auto-gen-config --exclude-limit 1400` -# on 2018-02-01 09:48:08 +0100 using RuboCop version 0.49.1. +# on 2018-04-23 13:00:19 +0200 using RuboCop version 0.49.1. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 37 +# Offense count: 36 # Cop supports --auto-correct. # Configuration parameters: Include, TreatCommentsAsGroupSeparators. # Include: **/Gemfile, **/gems.rb @@ -28,7 +28,7 @@ Layout/AlignArray: - 'spec/lib/open_food_network/order_and_distributor_report_spec.rb' - 'spec/lib/open_food_network/order_grouper_spec.rb' -# Offense count: 133 +# Offense count: 135 # Cop supports --auto-correct. # Configuration parameters: EnforcedHashRocketStyle, SupportedHashRocketStyles, EnforcedColonStyle, SupportedColonStyles, EnforcedLastArgumentHashStyle, SupportedLastArgumentHashStyles. # SupportedHashRocketStyles: key, separator, table @@ -48,6 +48,7 @@ Layout/AlignHash: - 'spec/features/admin/order_cycles_spec.rb' - 'spec/features/admin/products_spec.rb' - 'spec/features/admin/variant_overrides_spec.rb' + - 'spec/features/consumer/shopping/cart_spec.rb' - 'spec/lib/open_food_network/customers_report_spec.rb' - 'spec/lib/open_food_network/enterprise_fee_calculator_spec.rb' - 'spec/lib/open_food_network/permissions_spec.rb' @@ -59,7 +60,7 @@ Layout/AlignHash: - 'spec/models/spree/shipping_method_spec.rb' - 'spec/models/spree/variant_spec.rb' -# Offense count: 44 +# Offense count: 58 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles, IndentationWidth. # SupportedStyles: with_first_parameter, with_fixed_indentation @@ -79,11 +80,25 @@ Layout/AlignParameters: - 'spec/controllers/enterprises_controller_spec.rb' - 'spec/controllers/shop_controller_spec.rb' - 'spec/features/admin/enterprise_relationships_spec.rb' + - 'spec/features/admin/order_cycles_spec.rb' - 'spec/features/consumer/shopping/checkout_spec.rb' - 'spec/helpers/enterprises_helper_spec.rb' - 'spec/lib/open_food_network/user_balance_calculator_spec.rb' + - 'spec/serializers/variant_serializer_spec.rb' - 'spec/support/request/authentication_workflow.rb' +# Offense count: 1 +# Cop supports --auto-correct. +Layout/BlockEndNewline: + Exclude: + - 'spec/features/consumer/shopping/cart_spec.rb' + +# Offense count: 1 +# Cop supports --auto-correct. +Layout/ClosingParenthesisIndentation: + Exclude: + - 'spec/features/admin/order_cycles_spec.rb' + # Offense count: 8 # Cop supports --auto-correct. Layout/ElseAlignment: @@ -99,7 +114,6 @@ Layout/ElseAlignment: # Cop supports --auto-correct. Layout/EmptyLines: Exclude: - - 'Guardfile' - 'app/controllers/admin/enterprise_fees_controller.rb' - 'app/controllers/admin/order_cycles_controller.rb' - 'app/controllers/admin/producer_properties_controller.rb' @@ -157,12 +171,10 @@ Layout/EmptyLines: - 'lib/open_food_network/enterprise_fee_calculator.rb' - 'lib/open_food_network/enterprise_issue_validator.rb' - 'lib/open_food_network/integrity_checker.rb' - - 'lib/open_food_network/last_used_address.rb' - 'lib/open_food_network/lettuce_share_report.rb' - 'lib/open_food_network/option_value_namer.rb' - 'lib/open_food_network/order_cycle_form_applicator.rb' - 'lib/open_food_network/order_cycle_permissions.rb' - - 'lib/open_food_network/permissions.rb' - 'lib/open_food_network/products_cache.rb' - 'lib/open_food_network/products_cache_integrity_checker.rb' - 'lib/open_food_network/products_cache_refreshment.rb' @@ -182,6 +194,7 @@ Layout/EmptyLines: - 'spec/controllers/admin/column_preferences_controller_spec.rb' - 'spec/controllers/admin/enterprises_controller_spec.rb' - 'spec/controllers/admin/order_cycles_controller_spec.rb' + - 'spec/controllers/admin/subscription_line_items_controller_spec.rb' - 'spec/controllers/enterprises_controller_spec.rb' - 'spec/controllers/shop_controller_spec.rb' - 'spec/controllers/spree/admin/payments_controller_spec.rb' @@ -225,6 +238,7 @@ Layout/EmptyLines: - 'spec/models/model_set_spec.rb' - 'spec/models/product_distribution_spec.rb' - 'spec/models/spree/adjustment_spec.rb' + - 'spec/models/spree/line_item_spec.rb' - 'spec/models/spree/order_populator_spec.rb' - 'spec/models/spree/order_spec.rb' - 'spec/models/spree/product_spec.rb' @@ -238,7 +252,7 @@ Layout/EmptyLines: - 'spec/support/delayed_job_helper.rb' - 'spec/support/matchers/table_matchers.rb' -# Offense count: 66 +# Offense count: 65 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: empty_lines, no_empty_lines @@ -246,7 +260,6 @@ Layout/EmptyLinesAroundBlockBody: Exclude: - 'app/controllers/spree/admin/orders_controller_decorator.rb' - 'app/controllers/spree/admin/reports_controller_decorator.rb' - - 'app/controllers/spree/admin/variants_controller_decorator.rb' - 'app/controllers/spree/api/orders_controller_decorator.rb' - 'app/controllers/spree/api/products_controller_decorator.rb' - 'app/controllers/spree/checkout_controller_decorator.rb' @@ -260,11 +273,11 @@ Layout/EmptyLinesAroundBlockBody: - 'lib/tasks/users.rake' - 'spec/controllers/admin/tag_rules_controller_spec.rb' - 'spec/controllers/cart_controller_spec.rb' - - 'spec/controllers/enterprise_confirmations_controller_spec.rb' - 'spec/controllers/spree/admin/orders_controller_spec.rb' - 'spec/controllers/spree/admin/reports_controller_spec.rb' - 'spec/controllers/spree/api/orders_controller_spec.rb' - 'spec/controllers/spree/orders_controller_spec.rb' + - 'spec/controllers/user_confirmations_controller_spec.rb' - 'spec/controllers/user_registrations_controller_spec.rb' - 'spec/features/admin/caching_spec.rb' - 'spec/features/admin/orders_spec.rb' @@ -329,7 +342,7 @@ Layout/EmptyLinesAroundClassBody: - 'lib/open_food_network/rack_request_blocker.rb' - 'lib/open_food_network/reports/bulk_coop_report.rb' -# Offense count: 55 +# Offense count: 54 # Cop supports --auto-correct. # Configuration parameters: AllowForAlignment, ForceEqualSignAlignment. Layout/ExtraSpacing: @@ -372,7 +385,7 @@ Layout/ExtraSpacing: - 'spec/spec_helper.rb' - 'spec/support/request/web_helper.rb' -# Offense count: 3 +# Offense count: 2 # Cop supports --auto-correct. # Configuration parameters: SupportedStyles, IndentationWidth. # SupportedStyles: special_inside_parentheses, consistent, align_brackets @@ -402,7 +415,7 @@ Layout/IndentHash: - 'spec/lib/open_food_network/order_cycle_form_applicator_spec.rb' - 'spec/support/request/authentication_workflow.rb' -# Offense count: 21 +# Offense count: 20 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: normal, rails @@ -410,14 +423,13 @@ Layout/IndentationConsistency: Exclude: - 'app/controllers/spree/admin/reports_controller_decorator.rb' - 'lib/open_food_network/permissions.rb' - - 'spec/controllers/admin/order_cycles_controller_spec.rb' - 'spec/controllers/admin/tag_rules_controller_spec.rb' - 'spec/features/consumer/shopping/checkout_spec.rb' - 'spec/helpers/admin/business_model_configuration_helper_spec.rb' - 'spec/models/spree/line_item_spec.rb' - 'spec/models/spree/product_spec.rb' -# Offense count: 18 +# Offense count: 21 # Cop supports --auto-correct. # Configuration parameters: Width, IgnoredPatterns. Layout/IndentationWidth: @@ -436,14 +448,15 @@ Layout/IndentationWidth: - 'spec/lib/open_food_network/enterprise_fee_calculator_spec.rb' - 'spec/lib/open_food_network/order_grouper_spec.rb' - 'spec/lib/spree/product_filters_spec.rb' + - 'spec/mailers/enterprise_mailer_spec.rb' - 'spec/models/enterprise_spec.rb' + - 'spec/models/spree/calculator/flexi_rate_spec.rb' -# Offense count: 78 +# Offense count: 52 # Cop supports --auto-correct. Layout/LeadingCommentSpace: Exclude: - 'Gemfile' - - 'Guardfile' - 'app/models/billable_period.rb' - 'app/models/content_configuration.rb' - 'app/models/product_importer.rb' @@ -477,11 +490,11 @@ Layout/MultilineBlockLayout: Exclude: - 'app/models/spree/calculator/default_tax_decorator.rb' - 'app/models/spree/product_decorator.rb' - - 'lib/open_food_network/users_and_enterprises_report.rb' - 'spec/controllers/admin/column_preferences_controller_spec.rb' - 'spec/controllers/shop_controller_spec.rb' - 'spec/controllers/spree/orders_controller_spec.rb' - 'spec/features/admin/variant_overrides_spec.rb' + - 'spec/features/consumer/shopping/cart_spec.rb' - 'spec/helpers/enterprises_helper_spec.rb' - 'spec/jobs/update_billable_periods_spec.rb' - 'spec/lib/open_food_network/enterprise_fee_calculator_spec.rb' @@ -505,7 +518,7 @@ Layout/MultilineHashBraceLayout: - 'spec/controllers/admin/order_cycles_controller_spec.rb' - 'spec/support/request/authentication_workflow.rb' -# Offense count: 6 +# Offense count: 7 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: symmetrical, new_line, same_line @@ -515,20 +528,19 @@ Layout/MultilineMethodCallBraceLayout: - 'app/models/spree/variant_decorator.rb' - 'app/overrides/add_capture_order_shortcut.rb' - 'lib/open_food_network/products_renderer.rb' + - 'spec/features/admin/order_cycles_spec.rb' - 'spec/lib/open_food_network/products_and_inventory_report_spec.rb' -# Offense count: 7 +# Offense count: 4 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles, IndentationWidth. # SupportedStyles: aligned, indented, indented_relative_to_receiver Layout/MultilineMethodCallIndentation: Exclude: - - 'spec/controllers/spree/admin/payments_controller_spec.rb' - 'spec/lib/open_food_network/cached_products_renderer_spec.rb' - - 'spec/requests/checkout/paypal_spec.rb' - 'spec/serializers/variant_serializer_spec.rb' -# Offense count: 33 +# Offense count: 34 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles, IndentationWidth. # SupportedStyles: aligned, indented @@ -560,7 +572,7 @@ Layout/SpaceAfterColon: - 'spec/models/variant_override_spec.rb' - 'spec/spec_helper.rb' -# Offense count: 53 +# Offense count: 85 # Cop supports --auto-correct. Layout/SpaceAfterComma: Exclude: @@ -583,7 +595,9 @@ Layout/SpaceAfterComma: - 'spec/jobs/update_account_invoices_spec.rb' - 'spec/lib/open_food_network/group_buy_report_spec.rb' - 'spec/lib/open_food_network/order_and_distributor_report_spec.rb' + - 'spec/lib/open_food_network/subscription_summary_spec.rb' - 'spec/models/content_configuration_spec.rb' + - 'spec/models/spree/line_item_spec.rb' - 'spec/models/spree/order_spec.rb' - 'spec/models/tag_rule/discount_order_spec.rb' - 'spec/models/tag_rule/filter_order_cycles_spec.rb' @@ -593,12 +607,11 @@ Layout/SpaceAfterComma: - 'spec/spec_helper.rb' - 'spec/support/cancan_helper.rb' -# Offense count: 3 +# Offense count: 2 # Cop supports --auto-correct. Layout/SpaceAfterSemicolon: Exclude: - 'spec/controllers/spree/admin/base_controller_spec.rb' - - 'spec/models/enterprise_spec.rb' # Offense count: 65 # Cop supports --auto-correct. @@ -681,33 +694,25 @@ Layout/SpaceAroundOperators: - 'spec/support/cancan_helper.rb' - 'spec/support/seeds.rb' -# Offense count: 6 +# Offense count: 5 # Cop supports --auto-correct. Layout/SpaceBeforeComma: Exclude: - 'app/helpers/checkout_helper.rb' - - 'app/models/spree/ability_decorator.rb' - 'lib/open_food_network/orders_and_fulfillments_report.rb' - 'spec/features/admin/bulk_order_management_spec.rb' - 'spec/features/admin/bulk_product_update_spec.rb' -# Offense count: 6 +# Offense count: 5 # Cop supports --auto-correct. # Configuration parameters: AllowForAlignment. Layout/SpaceBeforeFirstArg: Exclude: - 'spec/factories.rb' - 'spec/features/admin/bulk_product_update_spec.rb' - - 'spec/features/admin/product_import_spec.rb' - 'spec/features/consumer/multilingual_spec.rb' - 'spec/models/enterprise_fee_spec.rb' -# Offense count: 1 -# Cop supports --auto-correct. -Layout/SpaceBeforeSemicolon: - Exclude: - - 'spec/models/enterprise_spec.rb' - # Offense count: 5 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. @@ -717,7 +722,7 @@ Layout/SpaceInLambdaLiteral: - 'app/models/spree/product_decorator.rb' - 'app/models/spree/variant_decorator.rb' -# Offense count: 187 +# Offense count: 194 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles, EnforcedStyleForEmptyBraces, SupportedStylesForEmptyBraces, SpaceBeforeBlockParameters. # SupportedStyles: space, no_space @@ -762,7 +767,6 @@ Layout/SpaceInsideBlockBraces: - 'spec/lib/open_food_network/tag_rule_applicator_spec.rb' - 'spec/models/column_preference_spec.rb' - 'spec/models/enterprise_relationship_spec.rb' - - 'spec/models/enterprise_spec.rb' - 'spec/models/spree/line_item_spec.rb' - 'spec/models/spree/order_spec.rb' - 'spec/models/spree/payment_spec.rb' @@ -773,13 +777,12 @@ Layout/SpaceInsideBlockBraces: - 'spec/spec_helper.rb' - 'spec/support/cancan_helper.rb' -# Offense count: 140 +# Offense count: 134 # Cop supports --auto-correct. Layout/SpaceInsideBrackets: Exclude: - 'app/controllers/admin/order_cycles_controller.rb' - 'app/helpers/spree/reports_helper.rb' - - 'app/models/enterprise.rb' - 'app/serializers/api/admin/exchange_serializer.rb' - 'lib/open_food_network/bulk_coop_report.rb' - 'lib/open_food_network/order_and_distributor_report.rb' @@ -796,7 +799,7 @@ Layout/SpaceInsideBrackets: - 'spec/lib/open_food_network/users_and_enterprises_report_spec.rb' - 'spec/performance/orders_controller_spec.rb' -# Offense count: 778 +# Offense count: 784 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles, EnforcedStyleForEmptyBraces, SupportedStylesForEmptyBraces. # SupportedStyles: space, no_space, compact @@ -807,7 +810,6 @@ Layout/SpaceInsideHashLiteralBraces: - 'app/controllers/admin/contents_controller.rb' - 'app/controllers/admin/enterprise_relationships_controller.rb' - 'app/controllers/admin/enterprise_roles_controller.rb' - - 'app/controllers/admin/order_cycles_controller.rb' - 'app/controllers/api/statuses_controller.rb' - 'app/controllers/checkout_controller.rb' - 'app/controllers/spree/admin/line_items_controller_decorator.rb' @@ -851,7 +853,9 @@ Layout/SpaceInsideHashLiteralBraces: - 'spec/controllers/admin/accounts_and_billing_settings_controller_spec.rb' - 'spec/controllers/admin/business_model_configuration_controller_spec.rb' - 'spec/controllers/admin/enterprises_controller_spec.rb' + - 'spec/controllers/admin/manager_invitations_controller_spec.rb' - 'spec/controllers/admin/order_cycles_controller_spec.rb' + - 'spec/controllers/admin/subscriptions_controller_spec.rb' - 'spec/controllers/api/statuses_controller_spec.rb' - 'spec/controllers/cart_controller_spec.rb' - 'spec/controllers/checkout_controller_spec.rb' @@ -907,6 +911,8 @@ Layout/SpaceInsideHashLiteralBraces: - 'spec/requests/checkout/failed_checkout_spec.rb' - 'spec/requests/checkout/stripe_connect_spec.rb' - 'spec/serializers/enterprise_serializer_spec.rb' + - 'spec/services/order_syncer_spec.rb' + - 'spec/services/subscription_form_spec.rb' - 'spec/spec_helper.rb' - 'spec/support/cancan_helper.rb' - 'spec/support/request/authentication_workflow.rb' @@ -923,7 +929,7 @@ Layout/SpaceInsideStringInterpolation: - 'lib/open_food_network/users_and_enterprises_report.rb' - 'spec/support/request/web_helper.rb' -# Offense count: 6 +# Offense count: 5 # Cop supports --auto-correct. Layout/Tab: Exclude: @@ -932,7 +938,7 @@ Layout/Tab: - 'spec/lib/spree/product_filters_spec.rb' - 'spec/models/spree/line_item_spec.rb' -# Offense count: 67 +# Offense count: 62 # Cop supports --auto-correct. Layout/TrailingWhitespace: Exclude: @@ -946,7 +952,6 @@ Layout/TrailingWhitespace: - 'app/views/json/_enterprises.rabl' - 'app/views/json/_producer.rabl' - 'app/views/json/partials/_producer.rabl' - - 'lib/tasks/dev.rake' - 'spec/controllers/admin/column_preferences_controller_spec.rb' - 'spec/controllers/shop_controller_spec.rb' - 'spec/features/admin/enterprise_user_spec.rb' @@ -957,8 +962,6 @@ Layout/TrailingWhitespace: - 'spec/lib/open_food_network/order_grouper_spec.rb' - 'spec/lib/open_food_network/permissions_spec.rb' - 'spec/lib/open_food_network/user_balance_calculator_spec.rb' - - 'spec/models/order_cycle_spec.rb' - - 'spec/models/spree/product_spec.rb' - 'spec/models/spree/variant_spec.rb' - 'spec/serializers/admin/enterprise_serializer_spec.rb' - 'spec/serializers/enterprise_serializer_spec.rb' @@ -976,14 +979,6 @@ Lint/BlockAlignment: - 'spec/models/spree/line_item_spec.rb' - 'spec/models/spree/product_spec.rb' -# Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyleAlignWith, SupportedStylesAlignWith, AutoCorrect. -# SupportedStylesAlignWith: start_of_line, def -Lint/DefEndAlignment: - Exclude: - - 'app/models/spree/line_item_decorator.rb' - # Offense count: 1 # Cop supports --auto-correct. Lint/DeprecatedClassMethods: @@ -1002,7 +997,7 @@ Lint/EndAlignment: - 'app/serializers/api/admin/for_order_cycle/supplied_product_serializer.rb' - 'app/serializers/api/admin/order_cycle_serializer.rb' -# Offense count: 17 +# Offense count: 18 Lint/IneffectiveAccessModifier: Exclude: - 'app/models/column_preference.rb' @@ -1060,7 +1055,7 @@ Lint/UnderscorePrefixedVariableName: Exclude: - 'spec/support/cancan_helper.rb' -# Offense count: 126 +# Offense count: 128 # Cop supports --auto-correct. # Configuration parameters: IgnoreEmptyBlocks, AllowUnusedKeywordArguments. Lint/UnusedBlockArgument: @@ -1095,12 +1090,11 @@ Lint/UnusedBlockArgument: - 'spec/support/matchers/table_matchers.rb' - 'spec/support/performance_helper.rb' -# Offense count: 16 +# Offense count: 15 # Cop supports --auto-correct. # Configuration parameters: AllowUnusedKeywordArguments, IgnoreEmptyMethods. Lint/UnusedMethodArgument: Exclude: - - 'app/helpers/admin/injection_helper.rb' - 'app/helpers/angular_form_builder.rb' - 'app/helpers/angular_form_helper.rb' - 'app/helpers/order_cycles_helper.rb' @@ -1126,12 +1120,11 @@ Lint/UselessAccessModifier: - 'lib/open_food_network/reports/bulk_coop_report.rb' - 'spec/lib/open_food_network/reports/report_spec.rb' -# Offense count: 325 +# Offense count: 313 Lint/Void: Exclude: - 'app/serializers/api/enterprise_serializer.rb' - 'spec/archive/features/consumer/checkout_spec.rb' - - 'spec/controllers/admin/bulk_line_items_controller_spec.rb' - 'spec/controllers/api/order_cycles_controller_spec.rb' - 'spec/controllers/cart_controller_spec.rb' - 'spec/controllers/checkout_controller_spec.rb' @@ -1145,13 +1138,12 @@ Lint/Void: - 'spec/controllers/spree/orders_controller_spec.rb' - 'spec/controllers/user_passwords_controller_spec.rb' - 'spec/controllers/user_registrations_controller_spec.rb' + - 'spec/features/admin/bulk_product_update_spec.rb' - 'spec/features/admin/enterprise_fees_spec.rb' - 'spec/features/admin/enterprise_groups_spec.rb' - - 'spec/features/admin/enterprise_user_spec.rb' - 'spec/features/admin/enterprises/index_spec.rb' - 'spec/features/admin/enterprises_spec.rb' - 'spec/features/admin/order_cycles_spec.rb' - - 'spec/features/admin/orders_spec.rb' - 'spec/features/admin/payment_method_spec.rb' - 'spec/features/admin/product_import_spec.rb' - 'spec/features/admin/products_spec.rb' @@ -1173,7 +1165,6 @@ Lint/Void: - 'spec/lib/open_food_network/packing_report_spec.rb' - 'spec/lib/open_food_network/reports/report_spec.rb' - 'spec/lib/open_food_network/reports/rule_spec.rb' - - 'spec/mailers/enterprise_mailer_spec.rb' - 'spec/mailers/order_mailer_spec.rb' - 'spec/models/cart_spec.rb' - 'spec/models/enterprise_relationship_spec.rb' @@ -1192,12 +1183,10 @@ Lint/Void: - 'spec/serializers/enterprise_serializer_spec.rb' - 'spec/support/request/web_helper.rb' -# Offense count: 794 +# Offense count: 950 # Configuration parameters: CountComments, ExcludedMethods. Metrics/BlockLength: - Max: 672 - Exclude: - - 'spec/**/*' + Max: 773 # Offense count: 1 Performance/Caller: @@ -1248,7 +1237,7 @@ Performance/StringReplacement: - 'app/helpers/spree/admin/navigation_helper_decorator.rb' - 'app/models/spree/preferences/file_configuration.rb' -# Offense count: 10 +# Offense count: 11 # Cop supports --auto-correct. # Configuration parameters: NilOrEmpty, NotPresent, UnlessPresent. Rails/Blank: @@ -1279,10 +1268,11 @@ Rails/Delegate: - 'app/serializers/api/admin/tag_rule_serializer.rb' - 'app/serializers/api/variant_serializer.rb' -# Offense count: 7 +# Offense count: 8 Rails/FilePath: Exclude: - 'lib/tasks/karma.rake' + - 'spec/features/admin/bulk_product_update_spec.rb' - 'spec/features/admin/content_spec.rb' - 'spec/models/content_configuration_spec.rb' - 'spec/models/spree/image_spec.rb' @@ -1298,7 +1288,7 @@ Rails/FindEach: - 'app/models/enterprise.rb' - 'app/models/spree/user_decorator.rb' -# Offense count: 5 +# Offense count: 7 # Configuration parameters: Include. # Include: app/models/**/*.rb Rails/HasAndBelongsToMany: @@ -1347,7 +1337,7 @@ Rails/ReadWriteAttribute: Exclude: - 'app/models/enterprise.rb' -# Offense count: 46 +# Offense count: 45 # Configuration parameters: Include. # Include: app/models/**/*.rb Rails/ScopeArgs: @@ -1384,14 +1374,6 @@ Rails/TimeZone: - 'spec/models/enterprise_relationship_spec.rb' - 'spec/models/variant_override_spec.rb' -# Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles, AutoCorrect. -# SupportedStyles: conservative, aggressive -Rails/UniqBeforePluck: - Exclude: - - 'lib/open_food_network/sales_tax_report.rb' - # Offense count: 21 # Cop supports --auto-correct. # Configuration parameters: Include. @@ -1448,7 +1430,7 @@ Style/BarePercentLiterals: - 'spec/features/admin/variants_spec.rb' - 'spec/support/request/web_helper.rb' -# Offense count: 207 +# Offense count: 209 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: braces, no_braces, context_dependent @@ -1482,10 +1464,10 @@ Style/BracesAroundHashParameters: - 'spec/controllers/admin/accounts_and_billing_settings_controller_spec.rb' - 'spec/controllers/admin/business_model_configuration_controller_spec.rb' - 'spec/controllers/admin/enterprises_controller_spec.rb' + - 'spec/controllers/admin/manager_invitations_controller_spec.rb' - 'spec/controllers/admin/order_cycles_controller_spec.rb' - 'spec/controllers/api/order_cycles_controller_spec.rb' - 'spec/controllers/cart_controller_spec.rb' - - 'spec/controllers/enterprise_confirmations_controller_spec.rb' - 'spec/controllers/enterprises_controller_spec.rb' - 'spec/controllers/line_items_controller_spec.rb' - 'spec/controllers/spree/admin/adjustments_controller_spec.rb' @@ -1497,6 +1479,7 @@ Style/BracesAroundHashParameters: - 'spec/controllers/spree/api/products_controller_spec.rb' - 'spec/controllers/spree/api/variants_controller_spec.rb' - 'spec/controllers/spree/orders_controller_spec.rb' + - 'spec/controllers/user_confirmations_controller_spec.rb' - 'spec/factories.rb' - 'spec/features/admin/accounts_and_billing_settings_spec.rb' - 'spec/features/admin/business_model_configuration_spec.rb' @@ -1509,6 +1492,7 @@ Style/BracesAroundHashParameters: - 'spec/jobs/update_account_invoices_spec.rb' - 'spec/lib/open_food_network/feature_toggle_spec.rb' - 'spec/lib/open_food_network/order_cycle_form_applicator_spec.rb' + - 'spec/lib/open_food_network/subscription_summarizer_spec.rb' - 'spec/lib/open_food_network/xero_invoices_report_spec.rb' - 'spec/models/billable_period_spec.rb' - 'spec/models/product_distribution_spec.rb' @@ -1650,8 +1634,8 @@ Style/ConditionalAssignment: - 'app/controllers/spree/admin/reports_controller_decorator.rb' - 'app/controllers/spree/admin/search_controller_decorator.rb' - 'app/helpers/spree/admin/orders_helper_decorator.rb' - - 'app/models/spree/calculator/flexi_rate_decorator.rb' - 'app/models/spree/calculator/per_item_decorator.rb' + - 'app/models/spree/line_item_decorator.rb' - 'app/models/spree/payment_decorator.rb' - 'spec/lib/open_food_network/order_grouper_spec.rb' @@ -1664,14 +1648,13 @@ Style/EachWithObject: - 'lib/open_food_network/enterprise_fee_calculator.rb' - 'lib/open_food_network/products_renderer.rb' -# Offense count: 2 +# Offense count: 1 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: empty, nil, both Style/EmptyElse: Exclude: - 'app/models/spreadsheet_entry.rb' - - 'app/serializers/api/admin/basic_order_cycle_serializer.rb' # Offense count: 2 # Cop supports --auto-correct. @@ -1707,7 +1690,7 @@ Style/FileName: Style/FormatStringToken: EnforcedStyle: template -# Offense count: 87 +# Offense count: 88 # Configuration parameters: MinBodyLength. Style/GuardClause: Exclude: @@ -1727,7 +1710,6 @@ Style/GuardClause: - 'app/controllers/spree/admin/products_controller_decorator.rb' - 'app/controllers/spree/admin/resource_controller_decorator.rb' - 'app/controllers/spree/admin/shipping_methods_controller_decorator.rb' - - 'app/controllers/spree/admin/variants_controller_decorator.rb' - 'app/controllers/spree/orders_controller_decorator.rb' - 'app/controllers/spree/paypal_controller_decorator.rb' - 'app/jobs/products_cache_integrity_checker_job.rb' @@ -1759,7 +1741,7 @@ Style/GuardClause: - 'spec/support/request/distribution_helper.rb' - 'spec/support/request/shop_workflow.rb' -# Offense count: 1219 +# Offense count: 1109 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles, UseHashRocketsWithSymbolValues, PreferHashRocketsForNonAlnumEndingSymbols. # SupportedStyles: ruby19, hash_rockets, no_mixed_keys, ruby19_no_mixed_keys @@ -1774,7 +1756,6 @@ Style/HashSyntax: - 'app/controllers/admin/tag_rules_controller.rb' - 'app/controllers/api/enterprises_controller.rb' - 'app/controllers/checkout_controller.rb' - - 'app/controllers/enterprise_confirmations_controller.rb' - 'app/controllers/open_food_network/cart_controller.rb' - 'app/controllers/spree/admin/line_items_controller_decorator.rb' - 'app/controllers/spree/admin/orders_controller_decorator.rb' @@ -1782,7 +1763,6 @@ Style/HashSyntax: - 'app/controllers/spree/admin/reports_controller_decorator.rb' - 'app/controllers/spree/admin/search_controller_decorator.rb' - 'app/controllers/spree/admin/shipping_methods_controller_decorator.rb' - - 'app/controllers/spree/admin/variants_controller_decorator.rb' - 'app/controllers/spree/api/products_controller_decorator.rb' - 'app/controllers/spree/orders_controller_decorator.rb' - 'app/controllers/spree/paypal_controller_decorator.rb' @@ -1796,7 +1776,6 @@ Style/HashSyntax: - 'app/helpers/spree/admin/navigation_helper_decorator.rb' - 'app/helpers/spree/admin/orders_helper_decorator.rb' - 'app/mailers/enterprise_mailer.rb' - - 'app/mailers/producer_mailer.rb' - 'app/mailers/spree/order_mailer_decorator.rb' - 'app/mailers/spree/user_mailer_decorator.rb' - 'app/models/billable_period.rb' @@ -1860,7 +1839,6 @@ Style/HashSyntax: - 'lib/spree/product_filters.rb' - 'lib/tasks/cache.rake' - 'lib/tasks/data.rake' - - 'lib/tasks/dev.rake' - 'lib/tasks/enterprises.rake' - 'lib/tasks/karma.rake' - 'spec/archive/features/consumer/checkout_spec.rb' @@ -1869,7 +1847,6 @@ Style/HashSyntax: - 'spec/controllers/api/order_cycles_controller_spec.rb' - 'spec/controllers/base_controller_spec.rb' - 'spec/controllers/cart_controller_spec.rb' - - 'spec/controllers/enterprise_confirmations_controller_spec.rb' - 'spec/controllers/spree/admin/orders_controller_spec.rb' - 'spec/controllers/spree/admin/payment_methods_controller_spec.rb' - 'spec/controllers/spree/admin/payments_controller_spec.rb' @@ -1893,10 +1870,12 @@ Style/HashSyntax: - 'spec/features/admin/products_spec.rb' - 'spec/features/admin/reports_spec.rb' - 'spec/features/admin/shipping_methods_spec.rb' + - 'spec/features/admin/subscriptions_spec.rb' - 'spec/features/admin/variant_overrides_spec.rb' - 'spec/features/consumer/account/cards_spec.rb' - 'spec/features/consumer/shopping/products_spec.rb' - 'spec/features/consumer/shopping/shopping_spec.rb' + - 'spec/jobs/subscription_placement_job_spec.rb' - 'spec/lib/open_food_network/group_buy_report_spec.rb' - 'spec/lib/open_food_network/lettuce_share_report_spec.rb' - 'spec/lib/open_food_network/order_and_distributor_report_spec.rb' @@ -1925,12 +1904,11 @@ Style/HashSyntax: - 'spec/support/request/web_helper.rb' - 'spec/support/seeds.rb' -# Offense count: 6 +# Offense count: 4 Style/IfInsideElse: Exclude: - 'app/controllers/admin/column_preferences_controller.rb' - 'app/controllers/admin/variant_overrides_controller.rb' - - 'app/controllers/enterprise_confirmations_controller.rb' - 'app/controllers/spree/admin/overview_controller_decorator.rb' - 'app/controllers/spree/admin/products_controller_decorator.rb' @@ -1990,23 +1968,22 @@ Style/MethodMissing: Exclude: - 'app/helpers/application_helper.rb' -# Offense count: 5 +# Offense count: 4 # Cop supports --auto-correct. Style/MultilineIfModifier: Exclude: - 'lib/open_food_network/enterprise_issue_validator.rb' - 'lib/spree/core/controller_helpers/respond_with_decorator.rb' -# Offense count: 7 +# Offense count: 6 # Cop supports --auto-correct. Style/MutableConstant: Exclude: - 'app/models/enterprise.rb' - 'app/models/enterprise_fee.rb' - - 'app/models/spree/payment_method_decorator.rb' - 'lib/discourse/single_sign_on.rb' -# Offense count: 8 +# Offense count: 7 # Cop supports --auto-correct. Style/NestedParenthesizedCalls: Exclude: @@ -2064,7 +2041,7 @@ Style/NumericLiteralPrefix: Style/NumericLiterals: MinDigits: 11 -# Offense count: 15 +# Offense count: 14 # Cop supports --auto-correct. # Configuration parameters: AutoCorrect, EnforcedStyle, SupportedStyles. # SupportedStyles: predicate, comparison @@ -2158,11 +2135,10 @@ Style/RaiseArgs: - 'lib/open_food_network/products_renderer.rb' - 'spec/models/spree/tax_rate_spec.rb' -# Offense count: 3 +# Offense count: 2 # Cop supports --auto-correct. Style/RedundantBegin: Exclude: - - 'app/controllers/admin/order_cycles_controller.rb' - 'app/controllers/shop_controller.rb' - 'app/models/spree/product_decorator.rb' @@ -2196,7 +2172,7 @@ Style/RedundantReturn: - 'app/models/spree/order_populator_decorator.rb' - 'app/serializers/api/admin/enterprise_serializer.rb' -# Offense count: 110 +# Offense count: 114 # Cop supports --auto-correct. Style/RedundantSelf: Exclude: From c12ac913717f6d8a25a0f5dcbd63579a743f798d Mon Sep 17 00:00:00 2001 From: Pau Perez Date: Tue, 24 Apr 2018 16:08:34 +0200 Subject: [PATCH 016/206] Update rubyzip to fix security issue Github reported us about CVE-2017-5946 which is a high severity issue. This gem is used by Roo which already supports the Rubyzip version that contains the fix (version 1.2.1). Check https://github.com/roo-rb/roo/commit/872bb3a0b67fbecf7dd4bc23ff03b7c2764462b0 for further details. Rubyzip's changelog for the version 1.2.1 can be found in https://github.com/rubyzip/rubyzip/blob/master/Changelog.md#121. --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index d6adb99a3d..b5284666c6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -628,7 +628,7 @@ GEM ruby-progressbar (~> 1.7) unicode-display_width (~> 1.0, >= 1.0.1) ruby-progressbar (1.8.1) - rubyzip (1.2.0) + rubyzip (1.2.1) safe_yaml (1.0.4) sass (3.3.14) sass-rails (3.2.6) From b431a7417a935ddf85ff46c7b9f3e73a368ff794 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Sat, 14 Apr 2018 19:33:33 +0100 Subject: [PATCH 017/206] Add cancan permissions for Admin::ManagerInvitationsController --- app/controllers/admin/manager_invitations_controller.rb | 2 ++ app/models/spree/ability_decorator.rb | 2 ++ 2 files changed, 4 insertions(+) diff --git a/app/controllers/admin/manager_invitations_controller.rb b/app/controllers/admin/manager_invitations_controller.rb index ed70322f34..b0da06b281 100644 --- a/app/controllers/admin/manager_invitations_controller.rb +++ b/app/controllers/admin/manager_invitations_controller.rb @@ -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]) diff --git a/app/models/spree/ability_decorator.rb b/app/models/spree/ability_decorator.rb index dc00ec1871..0c9492b4cf 100644 --- a/app/models/spree/ability_decorator.rb +++ b/app/models/spree/ability_decorator.rb @@ -127,6 +127,8 @@ 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) From 1782a8c7002cbeedaed7b951234e2a826c70ce2e Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Thu, 19 Apr 2018 17:16:37 +0100 Subject: [PATCH 018/206] manager invite permissions spec --- .../manager_invitations_controller_spec.rb | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/spec/controllers/admin/manager_invitations_controller_spec.rb b/spec/controllers/admin/manager_invitations_controller_spec.rb index 01b7c984d1..c5e6bf35f8 100644 --- a/spec/controllers/admin/manager_invitations_controller_spec.rb +++ b/spec/controllers/admin/manager_invitations_controller_spec.rb @@ -2,8 +2,11 @@ require 'spec_helper' module Admin describe ManagerInvitationsController, type: :controller do + let!(:enterprise_owner) { create(:user) } + let!(:other_enterprise_user) { create(:user) } let!(:existing_user) { create(:user) } - let!(:enterprise) { create(:enterprise) } + let!(:enterprise) { create(:enterprise, owner: enterprise_owner ) } + let!(:enterprise2) { create(:enterprise, owner: other_enterprise_user ) } let(:admin) { create(:admin_user) } describe "#create" do @@ -38,5 +41,38 @@ module Admin end end end + + describe "with enterprise permissions" do + context "as user with proper enterprise permissions" do + before do + controller.stub spree_current_user: enterprise_owner + end + + it "returns success code" do + spree_post :create, {email: 'an@email.com', enterprise_id: enterprise.id} + + new_user = Spree::User.find_by_email('an@email.com') + + expect(new_user.reset_password_token).to_not be_nil + expect(json_response['user']).to eq new_user.id + expect(response.status).to eq 200 + end + end + + context "as another enterprise user without permissions for this enterprise" do + before do + controller.stub spree_current_user: other_enterprise_user + end + + it "returns unauthorized response" do + spree_post :create, {email: 'another@email.com', enterprise_id: enterprise.id} + + new_user = Spree::User.find_by_email('another@email.com') + + expect(new_user).to be_nil + expect(response.status).to eq 302 + end + end + end end end From 1f23402912baa16cbc807935c3fc9c671e470ea4 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Wed, 18 Apr 2018 15:14:21 +0100 Subject: [PATCH 019/206] Disable password reset for unconfirmed users --- .../forgot_controller.js.coffee | 11 ++++- .../javascripts/templates/forgot.html.haml | 42 ++++++++++--------- app/controllers/user_passwords_controller.rb | 13 +++++- config/locales/en.yml | 1 + .../user_passwords_controller_spec.rb | 14 +++++-- 5 files changed, 55 insertions(+), 26 deletions(-) diff --git a/app/assets/javascripts/darkswarm/controllers/authentication/forgot_controller.js.coffee b/app/assets/javascripts/darkswarm/controllers/authentication/forgot_controller.js.coffee index 85920de958..b2984fd092 100644 --- a/app/assets/javascripts/darkswarm/controllers/authentication/forgot_controller.js.coffee +++ b/app/assets/javascripts/darkswarm/controllers/authentication/forgot_controller.js.coffee @@ -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}).success (data)-> + $scope.messages = t('devise.confirmations.send_instructions') + .error (data) -> + $scope.errors = t('devise.confirmations.failed_to_send') diff --git a/app/assets/javascripts/templates/forgot.html.haml b/app/assets/javascripts/templates/forgot.html.haml index 958f1daa39..0157b6fc36 100644 --- a/app/assets/javascripts/templates/forgot.html.haml +++ b/app/assets/javascripts/templates/forgot.html.haml @@ -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}}"} diff --git a/app/controllers/user_passwords_controller.rb b/app/controllers/user_passwords_controller.rb index 1870cc8859..240837655e 100644 --- a/app/controllers/user_passwords_controller.rb +++ b/app/controllers/user_passwords_controller.rb @@ -4,6 +4,8 @@ class UserPasswordsController < Spree::UserPasswordsController before_filter :set_admin_redirect, only: :edit def create + 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 user_unconfirmed? + user = Spree::User.find_by_email(params[:spree_user][:email]) + if user && !user.confirmed? + render json: { error: t('email_unconfirmed') }, status: :unauthorized + end + + user && !user.confirmed? + end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 315ee8960c..4a2ee53afa 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1628,6 +1628,7 @@ See the %{link} to find out more about %{sitename}'s features and to start using november: "November" december: "December" email_not_found: "Email address not found" + email_unconfirmed: "You must confirm your email address before you can reset your password." email_required: "You must provide an email address" logging_in: "Hold on a moment, we're logging you in" signup_email: "Your email" diff --git a/spec/controllers/user_passwords_controller_spec.rb b/spec/controllers/user_passwords_controller_spec.rb index 59a2f16280..42e505e172 100644 --- a/spec/controllers/user_passwords_controller_spec.rb +++ b/spec/controllers/user_passwords_controller_spec.rb @@ -3,6 +3,7 @@ require 'spree/api/testing_support/helpers' describe UserPasswordsController, type: :controller do let(:user) { create(:user) } + let(:unconfirmed_user) { create(:user, confirmed_at: nil) } before do @request.env["devise.mapping"] = Devise.mappings[:spree_user] @@ -44,11 +45,16 @@ describe UserPasswordsController, type: :controller do end describe "via ajax" do - it "returns errors" do + it "returns error when email not found" do xhr :post, :create, spree_user: {}, use_route: :spree - json = JSON.parse(response.body) - response.status.should == 401 - json.should == {"email"=>["can't be blank"]} + expect(response.status).to eq 404 + expect(json_response).to eq 'error' => I18n.t('email_not_found') + end + + it "returns error when user is unconfirmed" do + xhr :post, :create, spree_user: {email: unconfirmed_user.email}, use_route: :spree + expect(response.status).to eq 401 + expect(json_response).to eq 'error' => I18n.t('email_unconfirmed') end end end From 1c57f0f241c1c8ca449b2f14baa0234f7d2e8548 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Wed, 18 Apr 2018 16:26:39 +0100 Subject: [PATCH 020/206] Update :return_url value when re-sending email confirmations --- .../controllers/authentication/forgot_controller.js.coffee | 2 +- .../controllers/authentication/login_controller.js.coffee | 2 +- app/controllers/user_confirmations_controller.rb | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/darkswarm/controllers/authentication/forgot_controller.js.coffee b/app/assets/javascripts/darkswarm/controllers/authentication/forgot_controller.js.coffee index b2984fd092..7c3b67d823 100644 --- a/app/assets/javascripts/darkswarm/controllers/authentication/forgot_controller.js.coffee +++ b/app/assets/javascripts/darkswarm/controllers/authentication/forgot_controller.js.coffee @@ -13,7 +13,7 @@ Darkswarm.controller "ForgotCtrl", ($scope, $http, $location, AuthenticationServ $scope.errors = t 'email_required' $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') diff --git a/app/assets/javascripts/darkswarm/controllers/authentication/login_controller.js.coffee b/app/assets/javascripts/darkswarm/controllers/authentication/login_controller.js.coffee index 016d60d9d2..f1bb13bcca 100644 --- a/app/assets/javascripts/darkswarm/controllers/authentication/login_controller.js.coffee +++ b/app/assets/javascripts/darkswarm/controllers/authentication/login_controller.js.coffee @@ -14,7 +14,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') diff --git a/app/controllers/user_confirmations_controller.rb b/app/controllers/user_confirmations_controller.rb index 8e3e504eaa..e564da0b64 100644 --- a/app/controllers/user_confirmations_controller.rb +++ b/app/controllers/user_confirmations_controller.rb @@ -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? From a08b0955476794f4e575cc65138489834d964a80 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Sat, 21 Apr 2018 13:37:48 +0100 Subject: [PATCH 021/206] Extract json render from :user_confirmed? method --- app/controllers/user_passwords_controller.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/controllers/user_passwords_controller.rb b/app/controllers/user_passwords_controller.rb index 240837655e..e6b7610a13 100644 --- a/app/controllers/user_passwords_controller.rb +++ b/app/controllers/user_passwords_controller.rb @@ -4,7 +4,7 @@ class UserPasswordsController < Spree::UserPasswordsController before_filter :set_admin_redirect, only: :edit def create - return if user_unconfirmed? + render_unconfirmed_response && return if user_unconfirmed? self.resource = resource_class.send_reset_password_instructions(params[resource_name]) @@ -29,12 +29,12 @@ class UserPasswordsController < Spree::UserPasswordsController 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]) - if user && !user.confirmed? - render json: { error: t('email_unconfirmed') }, status: :unauthorized - end - user && !user.confirmed? end end From de5124f90c0597459796e2c200acbd74f1ffa0a6 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Tue, 1 May 2018 10:20:21 +1000 Subject: [PATCH 022/206] Remove Heroku references since nobody is using it --- .gitignore | 1 - config/database.yml | 1 - config/environments/development.rb | 5 ----- 3 files changed, 7 deletions(-) diff --git a/.gitignore b/.gitignore index 65877ba8fb..f295094890 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/config/database.yml b/config/database.yml index 7eef2396f9..7f55b6c499 100644 --- a/config/database.yml +++ b/config/database.yml @@ -16,7 +16,6 @@ test: username: ofn password: f00d -#not used with heroku production: adapter: postgresql encoding: unicode diff --git a/config/environments/development.rb b/config/environments/development.rb index f8a177076f..22bf2a1945 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -35,8 +35,3 @@ Openfoodnetwork::Application.configure do config.action_mailer.delivery_method = :letter_opener config.action_mailer.default_url_options = { host: "0.0.0.0:3000" } end - - -# Load heroku vars from local file -heroku_env = File.join(Rails.root, 'config', 'heroku_env.rb') -load(heroku_env) if File.exists?(heroku_env) From 7b06abd4c2eadb440e275f8eac31575c938eae13 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Tue, 1 May 2018 14:18:56 +1000 Subject: [PATCH 023/206] Fix initial password setting --- .../admin/manager_invitations_controller.rb | 2 ++ .../consumer/confirm_invitation_spec.rb | 35 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 spec/features/consumer/confirm_invitation_spec.rb diff --git a/app/controllers/admin/manager_invitations_controller.rb b/app/controllers/admin/manager_invitations_controller.rb index b0da06b281..5849aa2880 100644 --- a/app/controllers/admin/manager_invitations_controller.rb +++ b/app/controllers/admin/manager_invitations_controller.rb @@ -30,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 diff --git a/spec/features/consumer/confirm_invitation_spec.rb b/spec/features/consumer/confirm_invitation_spec.rb new file mode 100644 index 0000000000..fb9a898cc7 --- /dev/null +++ b/spec/features/consumer/confirm_invitation_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +feature "Confirm invitation" do + include UIComponentHelper # for be_logged_in_as + + describe "confirm email" do + let(:email) { "test@example.org" } + let(:user) { Spree::User.create(email: email, unconfirmed_email: email, password: "secret") } + + before do + user.reset_password_token = Devise.friendly_token + user.reset_password_sent_at = Time.now.utc + user.save! + end + + it "confirms the email address" do + visit spree.spree_user_confirmation_url(confirmation_token: user.confirmation_token) + expect(user.reload.confirmed?).to be true + end + + it "redirects to set a password" do + visit spree.spree_user_confirmation_url(confirmation_token: user.confirmation_token) + expect(page).to have_text "Change my password" + end + + it "allows you to set a password" do + visit spree.spree_user_confirmation_url(confirmation_token: user.confirmation_token) + fill_in "Password", with: "my secret" + fill_in "Password Confirmation", with: "my secret" + click_button + expect(page).to have_no_text "Reset password token has expired" + expect(page).to be_logged_in_as user + end + end +end From c597b3c377d7e6e7db391cb3338ec7f992664aab Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Tue, 1 May 2018 14:40:05 +1000 Subject: [PATCH 024/206] Speed-up spec --- .../consumer/confirm_invitation_spec.rb | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/spec/features/consumer/confirm_invitation_spec.rb b/spec/features/consumer/confirm_invitation_spec.rb index fb9a898cc7..9fea517638 100644 --- a/spec/features/consumer/confirm_invitation_spec.rb +++ b/spec/features/consumer/confirm_invitation_spec.rb @@ -1,9 +1,9 @@ -require 'spec_helper' +require "spec_helper" -feature "Confirm invitation" do +feature "Confirm invitation as manager" do include UIComponentHelper # for be_logged_in_as - describe "confirm email" do + describe "confirm email and set password" do let(:email) { "test@example.org" } let(:user) { Spree::User.create(email: email, unconfirmed_email: email, password: "secret") } @@ -13,21 +13,16 @@ feature "Confirm invitation" do user.save! end - it "confirms the email address" do - visit spree.spree_user_confirmation_url(confirmation_token: user.confirmation_token) - expect(user.reload.confirmed?).to be true - end - - it "redirects to set a password" do - visit spree.spree_user_confirmation_url(confirmation_token: user.confirmation_token) - expect(page).to have_text "Change my password" - end - it "allows you to set a password" do visit spree.spree_user_confirmation_url(confirmation_token: user.confirmation_token) + + expect(user.reload.confirmed?).to be true + expect(page).to have_text "Change my password" + fill_in "Password", with: "my secret" fill_in "Password Confirmation", with: "my secret" click_button + expect(page).to have_no_text "Reset password token has expired" expect(page).to be_logged_in_as user end From 5da9b55cdde2d6149e22dd536bb7593849c93179 Mon Sep 17 00:00:00 2001 From: Pau Perez Date: Wed, 2 May 2018 12:26:42 +0200 Subject: [PATCH 025/206] Improve order_and_distributor report's readability --- .../order_and_distributor_report.rb | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/lib/open_food_network/order_and_distributor_report.rb b/lib/open_food_network/order_and_distributor_report.rb index dec58d7aeb..34784b40ac 100644 --- a/lib/open_food_network/order_and_distributor_report.rb +++ b/lib/open_food_network/order_and_distributor_report.rb @@ -32,15 +32,43 @@ module OpenFoodNetwork @orders.each do |order| order.line_items.each do |line_item| - order_and_distributor_details << [order.created_at, order.id, - order.bill_address.full_name, order.email, order.bill_address.phone, order.bill_address.city, - line_item.product.sku, line_item.product.name, line_item.options_text, line_item.quantity, line_item.max_quantity, line_item.price * line_item.quantity, line_item.distribution_fee, - order.payments.first.andand.payment_method.andand.name, - order.distributor.andand.name, order.distributor.address.address1, order.distributor.address.city, order.distributor.address.zipcode, order.special_instructions ] + order_and_distributor_details << row_for(line_item, order) end end order_and_distributor_details end + + private + + # Returns a row with the data to display for the specified line_item and + # its order + # + # @param line_item [Spree::LineItem] + # @param order [Spree::Order] + # @return [Array] + def row_for(line_item, order) + [ + order.created_at, + order.id, + order.bill_address.full_name, + order.email, + order.bill_address.phone, + order.bill_address.city, + line_item.product.sku, + line_item.product.name, + line_item.options_text, + line_item.quantity, + line_item.max_quantity, + line_item.price * line_item.quantity, + line_item.distribution_fee, + order.payments.first.andand.payment_method.andand.name, + order.distributor.andand.name, + order.distributor.address.address1, + order.distributor.address.city, + order.distributor.address.zipcode, + order.special_instructions + ] + end end end From dd14915209ee5d19fdb2d45845bf4d4f8498064e Mon Sep 17 00:00:00 2001 From: Pau Perez Date: Thu, 3 May 2018 13:56:19 +0200 Subject: [PATCH 026/206] Exclude shared JS libs from CodeClimate CodeClimate is raising issues from code that we don't own and won't touch thus, causing false negatives. --- .codeclimate.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.codeclimate.yml b/.codeclimate.yml index c126a85843..4e8512dd15 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -34,3 +34,4 @@ checks: exclude_patterns: - "spec/**/*" - "vendor/**/*" +- "app/assets/javascripts/shared/*" From 9d9a974295457d4490ab86448e7646e795e86b1c Mon Sep 17 00:00:00 2001 From: Daniel Dominguez Date: Fri, 4 May 2018 11:52:08 -0300 Subject: [PATCH 027/206] Switched gem FactoryGirl to FactoryBot as FactoryGirl is deprecated. - Change FactoryGirl to FactoryBot everywhere on code. --- Gemfile | 2 +- Gemfile.lock | 8 +- lib/tasks/dev.rake | 86 +++++++------- .../features/consumer/checkout_spec.rb | 4 +- .../admin/bulk_line_items_controller_spec.rb | 34 +++--- .../api/order_cycles_controller_spec.rb | 4 +- spec/controllers/cart_controller_spec.rb | 14 +-- .../spree/admin/line_items_controller_spec.rb | 4 +- .../spree/admin/orders_controller_spec.rb | 26 ++--- .../spree/api/line_items_controller_spec.rb | 4 +- .../spree/api/products_controller_spec.rb | 6 +- .../spree/api/variants_controller_spec.rb | 8 +- spec/factories.rb | 70 +++++------ .../admin/bulk_product_update_spec.rb | 110 +++++++++--------- .../order_cycle_form_applicator_spec.rb | 20 ++-- spec/models/cart_spec.rb | 14 +-- spec/models/enterprise_spec.rb | 4 +- spec/models/spree/addresses_spec.rb | 4 +- spec/models/spree/order_spec.rb | 34 +++--- spec/spec_helper.rb | 6 +- spec/support/spree/init.rb | 2 +- 21 files changed, 232 insertions(+), 232 deletions(-) diff --git a/Gemfile b/Gemfile index 392e9df765..6304ba0480 100644 --- a/Gemfile +++ b/Gemfile @@ -106,7 +106,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' diff --git a/Gemfile.lock b/Gemfile.lock index 3e51181c4a..d12583b8f4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -274,10 +274,10 @@ GEM eventmachine (1.2.3) excon (0.45.4) execjs (2.6.0) - factory_girl (4.9.0) + factory_bot (4.8.2) activesupport (>= 3.0.0) - factory_girl_rails (4.9.0) - factory_girl (~> 4.9.0) + factory_bot_rails (4.8.2) + factory_bot (~> 4.8.2) railties (>= 3.0.0) faraday (0.9.2) multipart-post (>= 1.2, < 3) @@ -731,7 +731,7 @@ DEPENDENCIES delayed_job_active_record diffy eventmachine (>= 1.2.3) - factory_girl_rails + factory_bot_rails figaro foreigner foundation-icons-sass-rails diff --git a/lib/tasks/dev.rake b/lib/tasks/dev.rake index 4bf1c58c3c..84d51466d3 100644 --- a/lib/tasks/dev.rake +++ b/lib/tasks/dev.rake @@ -17,9 +17,9 @@ namespace :openfoodnetwork do unless Spree::Zone.find_by_name 'Australia' puts "[#{task_name}] Seeding shipping / payment information" - zone = FactoryGirl.create(:zone, name: 'Australia', zone_members: []) + zone = FactoryBot.create(:zone, name: 'Australia', zone_members: []) Spree::ZoneMember.create(zone: zone, zoneable: country) - address = FactoryGirl.create( + address = FactoryBot.create( :address, address1: "15/1 Ballantyne Street", zipcode: "3153", @@ -27,19 +27,19 @@ namespace :openfoodnetwork do country: country, state: state ) - enterprise = FactoryGirl.create(:enterprise, address: address) + enterprise = FactoryBot.create(:enterprise, address: address) - FactoryGirl.create(:shipping_method, zone: zone, distributors: [enterprise]) + FactoryBot.create(:shipping_method, zone: zone, distributors: [enterprise]) end # -- Taxonomies unless Spree::Taxonomy.find_by_name 'Products' puts "[#{task_name}] Seeding taxonomies" - taxonomy = Spree::Taxonomy.find_by_name('Products') || FactoryGirl.create(:taxonomy, name: 'Products') + taxonomy = Spree::Taxonomy.find_by_name('Products') || FactoryBot.create(:taxonomy, name: 'Products') taxonomy_root = taxonomy.root ['Vegetables', 'Fruit', 'Oils', 'Preserves and Sauces', 'Dairy', 'Meat and Fish'].each do |taxon_name| - FactoryGirl.create(:taxon, name: taxon_name, parent_id: taxonomy_root.id) + FactoryBot.create(:taxon, name: taxon_name, parent_id: taxonomy_root.id) end end @@ -47,39 +47,39 @@ namespace :openfoodnetwork do unless Spree::Address.find_by_zipcode "3160" puts "[#{task_name}] Seeding addresses" - FactoryGirl.create(:address, address1: "25 Myrtle Street", zipcode: "3153", city: "Bayswater", country: country, state: state) - FactoryGirl.create(:address, address1: "6 Rollings Road", zipcode: "3156", city: "Upper Ferntree Gully", country: country, state: state) - FactoryGirl.create(:address, address1: "72 Lake Road", zipcode: "3130", city: "Blackburn", country: country, state: state) - FactoryGirl.create(:address, address1: "7 Verbena Street", zipcode: "3195", city: "Mordialloc", country: country, state: state) - FactoryGirl.create(:address, address1: "20 Galvin Street", zipcode: "3018", city: "Altona", country: country, state: state) - FactoryGirl.create(:address, address1: "59 Websters Road", zipcode: "3106", city: "Templestowe", country: country, state: state) - FactoryGirl.create(:address, address1: "17 Torresdale Drive", zipcode: "3155", city: "Boronia", country: country, state: state) - FactoryGirl.create(:address, address1: "21 Robina CRT", zipcode: "3764", city: "Kilmore", country: country, state: state) - FactoryGirl.create(:address, address1: "25 Kendall Street", zipcode: "3134", city: "Ringwood", country: country, state: state) - FactoryGirl.create(:address, address1: "2 Mines Road", zipcode: "3135", city: "Ringwood East", country: country, state: state) - FactoryGirl.create(:address, address1: "183 Millers Road", zipcode: "3025", city: "Altona North", country: country, state: state) - FactoryGirl.create(:address, address1: "310 Pascoe Vale Road", zipcode: "3040", city: "Essendon", country: country, state: state) - FactoryGirl.create(:address, address1: "6 Martin Street", zipcode: "3160", city: "Belgrave", country: country, state: state) + FactoryBot.create(:address, address1: "25 Myrtle Street", zipcode: "3153", city: "Bayswater", country: country, state: state) + FactoryBot.create(:address, address1: "6 Rollings Road", zipcode: "3156", city: "Upper Ferntree Gully", country: country, state: state) + FactoryBot.create(:address, address1: "72 Lake Road", zipcode: "3130", city: "Blackburn", country: country, state: state) + FactoryBot.create(:address, address1: "7 Verbena Street", zipcode: "3195", city: "Mordialloc", country: country, state: state) + FactoryBot.create(:address, address1: "20 Galvin Street", zipcode: "3018", city: "Altona", country: country, state: state) + FactoryBot.create(:address, address1: "59 Websters Road", zipcode: "3106", city: "Templestowe", country: country, state: state) + FactoryBot.create(:address, address1: "17 Torresdale Drive", zipcode: "3155", city: "Boronia", country: country, state: state) + FactoryBot.create(:address, address1: "21 Robina CRT", zipcode: "3764", city: "Kilmore", country: country, state: state) + FactoryBot.create(:address, address1: "25 Kendall Street", zipcode: "3134", city: "Ringwood", country: country, state: state) + FactoryBot.create(:address, address1: "2 Mines Road", zipcode: "3135", city: "Ringwood East", country: country, state: state) + FactoryBot.create(:address, address1: "183 Millers Road", zipcode: "3025", city: "Altona North", country: country, state: state) + FactoryBot.create(:address, address1: "310 Pascoe Vale Road", zipcode: "3040", city: "Essendon", country: country, state: state) + FactoryBot.create(:address, address1: "6 Martin Street", zipcode: "3160", city: "Belgrave", country: country, state: state) end # -- Enterprises unless Enterprise.count > 1 puts "[#{task_name}] Seeding enterprises" - 3.times { FactoryGirl.create(:supplier_enterprise, address: Spree::Address.find_by_zipcode("3160")) } + 3.times { FactoryBot.create(:supplier_enterprise, address: Spree::Address.find_by_zipcode("3160")) } - FactoryGirl.create(:distributor_enterprise, name: "Green Grass", address: Spree::Address.find_by_zipcode("3153")) - FactoryGirl.create(:distributor_enterprise, name: "AusFarmers United", address: Spree::Address.find_by_zipcode("3156")) - FactoryGirl.create(:distributor_enterprise, name: "Blackburn FreeGrossers", address: Spree::Address.find_by_zipcode("3130")) - FactoryGirl.create(:distributor_enterprise, name: "MegaFoods", address: Spree::Address.find_by_zipcode("3195")) - FactoryGirl.create(:distributor_enterprise, name: "Eco Butchers", address: Spree::Address.find_by_zipcode("3018")) - FactoryGirl.create(:distributor_enterprise, name: "Western Wines", address: Spree::Address.find_by_zipcode("3106")) - FactoryGirl.create(:distributor_enterprise, name: "QuickFresh", address: Spree::Address.find_by_zipcode("3155")) - FactoryGirl.create(:distributor_enterprise, name: "Fooderers", address: Spree::Address.find_by_zipcode("3764")) - FactoryGirl.create(:distributor_enterprise, name: "Food Local", address: Spree::Address.find_by_zipcode("3134")) - FactoryGirl.create(:distributor_enterprise, name: "Green Food Trading Corporation", address: Spree::Address.find_by_zipcode("3135")) - FactoryGirl.create(:distributor_enterprise, name: "Better Food", address: Spree::Address.find_by_zipcode("3025")) - FactoryGirl.create(:distributor_enterprise, name: "Gippsland Poultry", address: Spree::Address.find_by_zipcode("3040")) + FactoryBot.create(:distributor_enterprise, name: "Green Grass", address: Spree::Address.find_by_zipcode("3153")) + FactoryBot.create(:distributor_enterprise, name: "AusFarmers United", address: Spree::Address.find_by_zipcode("3156")) + FactoryBot.create(:distributor_enterprise, name: "Blackburn FreeGrossers", address: Spree::Address.find_by_zipcode("3130")) + FactoryBot.create(:distributor_enterprise, name: "MegaFoods", address: Spree::Address.find_by_zipcode("3195")) + FactoryBot.create(:distributor_enterprise, name: "Eco Butchers", address: Spree::Address.find_by_zipcode("3018")) + FactoryBot.create(:distributor_enterprise, name: "Western Wines", address: Spree::Address.find_by_zipcode("3106")) + FactoryBot.create(:distributor_enterprise, name: "QuickFresh", address: Spree::Address.find_by_zipcode("3155")) + FactoryBot.create(:distributor_enterprise, name: "Fooderers", address: Spree::Address.find_by_zipcode("3764")) + FactoryBot.create(:distributor_enterprise, name: "Food Local", address: Spree::Address.find_by_zipcode("3134")) + FactoryBot.create(:distributor_enterprise, name: "Green Food Trading Corporation", address: Spree::Address.find_by_zipcode("3135")) + FactoryBot.create(:distributor_enterprise, name: "Better Food", address: Spree::Address.find_by_zipcode("3025")) + FactoryBot.create(:distributor_enterprise, name: "Gippsland Poultry", address: Spree::Address.find_by_zipcode("3040")) end # -- Enterprise users @@ -88,12 +88,12 @@ namespace :openfoodnetwork do pw = "spree123" - u = FactoryGirl.create(:user, email: "sup@example.com", password: pw, password_confirmation: pw) + u = FactoryBot.create(:user, email: "sup@example.com", password: pw, password_confirmation: pw) u.enterprises << Enterprise.is_primary_producer.first u.enterprises << Enterprise.is_primary_producer.second puts " Supplier User created: #{u.email}/#{pw} (" + u.enterprise_roles.map{ |er| er.enterprise.name}.join(", ") + ")" - u = FactoryGirl.create(:user, email: "dist@example.com", password: pw, password_confirmation: pw) + u = FactoryBot.create(:user, email: "dist@example.com", password: pw, password_confirmation: pw) u.enterprises << Enterprise.is_distributor.first u.enterprises << Enterprise.is_distributor.second puts " Distributor User created: #{u.email}/#{pw} (" + u.enterprise_roles.map{ |er| er.enterprise.name}.join(", ") + ")" @@ -102,14 +102,14 @@ namespace :openfoodnetwork do # -- Enterprise fees unless EnterpriseFee.count > 1 Enterprise.is_distributor.each do |distributor| - FactoryGirl.create(:enterprise_fee, enterprise: distributor) + FactoryBot.create(:enterprise_fee, enterprise: distributor) end end # -- Enterprise Payment Methods unless Spree::PaymentMethod.count > 1 Enterprise.is_distributor.each do |distributor| - FactoryGirl.create(:payment_method, distributors: [distributor], name: "Cheque (#{distributor.name})", environment: 'development') + FactoryBot.create(:payment_method, distributors: [distributor], name: "Cheque (#{distributor.name})", environment: 'development') end end @@ -117,7 +117,7 @@ namespace :openfoodnetwork do unless Spree::Product.count > 0 puts "[#{task_name}] Seeding products" - prod1 = FactoryGirl.create(:product, + prod1 = FactoryBot.create(:product, name: 'Garlic', price: 20.00, supplier: Enterprise.is_primary_producer[0], taxons: [Spree::Taxon.find_by_name('Vegetables')]) @@ -127,7 +127,7 @@ namespace :openfoodnetwork do enterprise_fee: Enterprise.is_distributor[0].enterprise_fees.first) - prod2 = FactoryGirl.create(:product, + prod2 = FactoryBot.create(:product, name: 'Fuji Apple', price: 5.00, supplier: Enterprise.is_primary_producer[1], taxons: [Spree::Taxon.find_by_name('Fruit')]) @@ -136,7 +136,7 @@ namespace :openfoodnetwork do distributor: Enterprise.is_distributor[1], enterprise_fee: Enterprise.is_distributor[1].enterprise_fees.first) - prod3 = FactoryGirl.create(:product, + prod3 = FactoryBot.create(:product, name: 'Beef - 5kg Trays', price: 50.00, supplier: Enterprise.is_primary_producer[2], taxons: [Spree::Taxon.find_by_name('Meat and Fish')]) @@ -145,7 +145,7 @@ namespace :openfoodnetwork do distributor: Enterprise.is_distributor[2], enterprise_fee: Enterprise.is_distributor[2].enterprise_fees.first) - prod4 = FactoryGirl.create(:product, + prod4 = FactoryBot.create(:product, name: 'Carrots', price: 3.00, supplier: Enterprise.is_primary_producer[2], taxons: [Spree::Taxon.find_by_name('Meat and Fish')]) @@ -154,7 +154,7 @@ namespace :openfoodnetwork do distributor: Enterprise.is_distributor[2], enterprise_fee: Enterprise.is_distributor[2].enterprise_fees.first) - prod5 = FactoryGirl.create(:product, + prod5 = FactoryBot.create(:product, name: 'Potatoes', price: 2.00, supplier: Enterprise.is_primary_producer[2], taxons: [Spree::Taxon.find_by_name('Meat and Fish')]) @@ -163,7 +163,7 @@ namespace :openfoodnetwork do distributor: Enterprise.is_distributor[2], enterprise_fee: Enterprise.is_distributor[2].enterprise_fees.first) - prod6 = FactoryGirl.create(:product, + prod6 = FactoryBot.create(:product, name: 'Tomatoes', price: 2.00, supplier: Enterprise.is_primary_producer[2], taxons: [Spree::Taxon.find_by_name('Meat and Fish')]) @@ -172,7 +172,7 @@ namespace :openfoodnetwork do distributor: Enterprise.is_distributor[2], enterprise_fee: Enterprise.is_distributor[2].enterprise_fees.first) - prod7 = FactoryGirl.create(:product, + prod7 = FactoryBot.create(:product, name: 'Potatoes', price: 2.00, supplier: Enterprise.is_primary_producer[2], taxons: [Spree::Taxon.find_by_name('Meat and Fish')]) diff --git a/spec/archive/features/consumer/checkout_spec.rb b/spec/archive/features/consumer/checkout_spec.rb index da147e7b2b..918391abef 100644 --- a/spec/archive/features/consumer/checkout_spec.rb +++ b/spec/archive/features/consumer/checkout_spec.rb @@ -487,9 +487,9 @@ feature %q{ ExchangeFee.create!(exchange: ex2, enterprise_fee: supplier_fee4) # Distributors - distributor1 = FactoryGirl.create(:distributor_enterprise, name: "FruitAndVeg") + distributor1 = FactoryBot.create(:distributor_enterprise, name: "FruitAndVeg") @distributor1 = distributor1 - distributor2 = FactoryGirl.create(:distributor_enterprise, name: "MoreFreshStuff") + distributor2 = FactoryBot.create(:distributor_enterprise, name: "MoreFreshStuff") create_enterprise_group_for distributor1 distributor_fee1 = create(:enterprise_fee, enterprise: distributor1, fee_type: 'packing', amount: 7) distributor_fee2 = create(:enterprise_fee, enterprise: distributor1, fee_type: 'transport', amount: 8) diff --git a/spec/controllers/admin/bulk_line_items_controller_spec.rb b/spec/controllers/admin/bulk_line_items_controller_spec.rb index ecc6c37f5c..dd5c1c6326 100644 --- a/spec/controllers/admin/bulk_line_items_controller_spec.rb +++ b/spec/controllers/admin/bulk_line_items_controller_spec.rb @@ -7,14 +7,14 @@ describe Admin::BulkLineItemsController, type: :controller do render_views let(:line_item_attributes) { %i[id quantity max_quantity price supplier final_weight_volume units_product units_variant order] } - let!(:dist1) { FactoryGirl.create(:distributor_enterprise) } - let!(:order1) { FactoryGirl.create(:order, state: 'complete', completed_at: 1.day.ago, distributor: dist1, billing_address: FactoryGirl.create(:address) ) } - let!(:order2) { FactoryGirl.create(:order, state: 'complete', completed_at: Time.zone.now, distributor: dist1, billing_address: FactoryGirl.create(:address) ) } - let!(:order3) { FactoryGirl.create(:order, state: 'complete', completed_at: Time.zone.now, distributor: dist1, billing_address: FactoryGirl.create(:address) ) } - let!(:line_item1) { FactoryGirl.create(:line_item, order: order1) } - let!(:line_item2) { FactoryGirl.create(:line_item, order: order2) } - let!(:line_item3) { FactoryGirl.create(:line_item, order: order2) } - let!(:line_item4) { FactoryGirl.create(:line_item, order: order3) } + let!(:dist1) { FactoryBot.create(:distributor_enterprise) } + let!(:order1) { FactoryBot.create(:order, state: 'complete', completed_at: 1.day.ago, distributor: dist1, billing_address: FactoryBot.create(:address) ) } + let!(:order2) { FactoryBot.create(:order, state: 'complete', completed_at: Time.zone.now, distributor: dist1, billing_address: FactoryBot.create(:address) ) } + let!(:order3) { FactoryBot.create(:order, state: 'complete', completed_at: Time.zone.now, distributor: dist1, billing_address: FactoryBot.create(:address) ) } + let!(:line_item1) { FactoryBot.create(:line_item, order: order1) } + let!(:line_item2) { FactoryBot.create(:line_item, order: order2) } + let!(:line_item3) { FactoryBot.create(:line_item, order: order2) } + let!(:line_item4) { FactoryBot.create(:line_item, order: order3) } context "as a normal user" do before { controller.stub spree_current_user: create_enterprise_user } @@ -82,11 +82,11 @@ describe Admin::BulkLineItemsController, type: :controller do let(:distributor2) { create(:distributor_enterprise) } let(:coordinator) { create(:distributor_enterprise) } let(:order_cycle) { create(:simple_order_cycle, coordinator: coordinator) } - let!(:order1) { FactoryGirl.create(:order, order_cycle: order_cycle, state: 'complete', completed_at: Time.zone.now, distributor: distributor1, billing_address: FactoryGirl.create(:address) ) } - let!(:line_item1) { FactoryGirl.create(:line_item, order: order1, product: FactoryGirl.create(:product, supplier: supplier)) } - let!(:line_item2) { FactoryGirl.create(:line_item, order: order1, product: FactoryGirl.create(:product, supplier: supplier)) } - let!(:order2) { FactoryGirl.create(:order, order_cycle: order_cycle, state: 'complete', completed_at: Time.zone.now, distributor: distributor2, billing_address: FactoryGirl.create(:address) ) } - let!(:line_item3) { FactoryGirl.create(:line_item, order: order2, product: FactoryGirl.create(:product, supplier: supplier)) } + let!(:order1) { FactoryBot.create(:order, order_cycle: order_cycle, state: 'complete', completed_at: Time.zone.now, distributor: distributor1, billing_address: FactoryBot.create(:address) ) } + let!(:line_item1) { FactoryBot.create(:line_item, order: order1, product: FactoryBot.create(:product, supplier: supplier)) } + let!(:line_item2) { FactoryBot.create(:line_item, order: order1, product: FactoryBot.create(:product, supplier: supplier)) } + let!(:order2) { FactoryBot.create(:order, order_cycle: order_cycle, state: 'complete', completed_at: Time.zone.now, distributor: distributor2, billing_address: FactoryBot.create(:address) ) } + let!(:line_item3) { FactoryBot.create(:line_item, order: order2, product: FactoryBot.create(:product, supplier: supplier)) } context "producer enterprise" do before do @@ -130,8 +130,8 @@ describe Admin::BulkLineItemsController, type: :controller do let(:distributor1) { create(:distributor_enterprise) } let(:coordinator) { create(:distributor_enterprise) } let(:order_cycle) { create(:simple_order_cycle, coordinator: coordinator) } - let!(:order1) { FactoryGirl.create(:order, order_cycle: order_cycle, state: 'complete', completed_at: Time.zone.now, distributor: distributor1, billing_address: FactoryGirl.create(:address) ) } - let!(:line_item1) { FactoryGirl.create(:line_item, order: order1, product: FactoryGirl.create(:product, supplier: supplier)) } + let!(:order1) { FactoryBot.create(:order, order_cycle: order_cycle, state: 'complete', completed_at: Time.zone.now, distributor: distributor1, billing_address: FactoryBot.create(:address) ) } + let!(:line_item1) { FactoryBot.create(:line_item, order: order1, product: FactoryBot.create(:product, supplier: supplier)) } let(:line_item_params) { { quantity: 3, final_weight_volume: 3000, price: 3.00 } } let(:params) { { id: line_item1.id, order_id: order1.number, line_item: line_item_params } } @@ -226,8 +226,8 @@ describe Admin::BulkLineItemsController, type: :controller do let(:distributor1) { create(:distributor_enterprise) } let(:coordinator) { create(:distributor_enterprise) } let(:order_cycle) { create(:simple_order_cycle, coordinator: coordinator) } - let!(:order1) { FactoryGirl.create(:order, order_cycle: order_cycle, state: 'complete', completed_at: Time.zone.now, distributor: distributor1, billing_address: FactoryGirl.create(:address) ) } - let!(:line_item1) { FactoryGirl.create(:line_item, order: order1, product: FactoryGirl.create(:product, supplier: supplier)) } + let!(:order1) { FactoryBot.create(:order, order_cycle: order_cycle, state: 'complete', completed_at: Time.zone.now, distributor: distributor1, billing_address: FactoryBot.create(:address) ) } + let!(:line_item1) { FactoryBot.create(:line_item, order: order1, product: FactoryBot.create(:product, supplier: supplier)) } let(:params) { { id: line_item1.id, order_id: order1.number } } before do diff --git a/spec/controllers/api/order_cycles_controller_spec.rb b/spec/controllers/api/order_cycles_controller_spec.rb index 0802bc1d58..48b47fcb9c 100644 --- a/spec/controllers/api/order_cycles_controller_spec.rb +++ b/spec/controllers/api/order_cycles_controller_spec.rb @@ -8,8 +8,8 @@ module Api render_views describe "managed" do - let!(:oc1) { FactoryGirl.create(:simple_order_cycle) } - let!(:oc2) { FactoryGirl.create(:simple_order_cycle) } + let!(:oc1) { FactoryBot.create(:simple_order_cycle) } + let!(:oc2) { FactoryBot.create(:simple_order_cycle) } let(:coordinator) { oc1.coordinator } let(:attributes) { [:id, :name, :suppliers, :distributors] } diff --git a/spec/controllers/cart_controller_spec.rb b/spec/controllers/cart_controller_spec.rb index b014232c7f..f7f48c3a06 100644 --- a/spec/controllers/cart_controller_spec.rb +++ b/spec/controllers/cart_controller_spec.rb @@ -5,14 +5,14 @@ module OpenFoodNetwork describe CartController, type: :controller do render_views - let(:user) { FactoryGirl.create(:user) } + let(:user) { FactoryBot.create(:user) } let(:product1) do - p1 = FactoryGirl.create(:product) + p1 = FactoryBot.create(:product) p1.update_column(:count_on_hand, 10) p1 end let(:cart) { Cart.create(user: user) } - let(:distributor) { FactoryGirl.create(:distributor_enterprise) } + let(:distributor) { FactoryBot.create(:distributor_enterprise) } before do end @@ -30,7 +30,7 @@ module OpenFoodNetwork end context 'with an empty order' do - let(:order) { FactoryGirl.create(:order, distributor: distributor) } + let(:order) { FactoryBot.create(:order, distributor: distributor) } before(:each) do cart.orders << order @@ -48,9 +48,9 @@ module OpenFoodNetwork end context 'an order with line items' do - let(:product) { FactoryGirl.create(:product, distributors: [ distributor ]) } - let(:order) { FactoryGirl.create(:order, { distributor: distributor } ) } - let(:line_item) { FactoryGirl.create(:line_item, { variant: product.master }) } + let(:product) { FactoryBot.create(:product, distributors: [ distributor ]) } + let(:order) { FactoryBot.create(:order, { distributor: distributor } ) } + let(:line_item) { FactoryBot.create(:line_item, { variant: product.master }) } before(:each) do order.line_items << line_item diff --git a/spec/controllers/spree/admin/line_items_controller_spec.rb b/spec/controllers/spree/admin/line_items_controller_spec.rb index 1420d4910d..5b7575e7e5 100644 --- a/spec/controllers/spree/admin/line_items_controller_spec.rb +++ b/spec/controllers/spree/admin/line_items_controller_spec.rb @@ -25,8 +25,8 @@ describe Spree::Admin::LineItemsController, type: :controller do let(:distributor1) { create(:distributor_enterprise) } let(:coordinator) { create(:distributor_enterprise) } let(:order_cycle) { create(:simple_order_cycle, coordinator: coordinator) } - let!(:order1) { FactoryGirl.create(:order, order_cycle: order_cycle, state: 'complete', completed_at: Time.zone.now, distributor: distributor1, billing_address: FactoryGirl.create(:address) ) } - let!(:line_item1) { FactoryGirl.create(:line_item, order: order1, product: FactoryGirl.create(:product, supplier: supplier)) } + let!(:order1) { FactoryBot.create(:order, order_cycle: order_cycle, state: 'complete', completed_at: Time.zone.now, distributor: distributor1, billing_address: FactoryBot.create(:address) ) } + let!(:line_item1) { FactoryBot.create(:line_item, order: order1, product: FactoryBot.create(:product, supplier: supplier)) } let(:line_item_params) { { quantity: 3, final_weight_volume: 3000, price: 3.00 } } let(:params) { { id: line_item1.id, order_id: order1.number, line_item: line_item_params } } diff --git a/spec/controllers/spree/admin/orders_controller_spec.rb b/spec/controllers/spree/admin/orders_controller_spec.rb index 752c7f1dc8..25c2683d4d 100644 --- a/spec/controllers/spree/admin/orders_controller_spec.rb +++ b/spec/controllers/spree/admin/orders_controller_spec.rb @@ -35,14 +35,14 @@ describe Spree::Admin::OrdersController, type: :controller do let(:order_attributes) { [:id, :full_name, :email, :phone, :completed_at, :distributor, :order_cycle, :number] } def self.make_simple_data! - let!(:dist1) { FactoryGirl.create(:distributor_enterprise) } - let!(:order1) { FactoryGirl.create(:order, state: 'complete', completed_at: Time.zone.now, distributor: dist1, billing_address: FactoryGirl.create(:address) ) } - let!(:order2) { FactoryGirl.create(:order, state: 'complete', completed_at: Time.zone.now, distributor: dist1, billing_address: FactoryGirl.create(:address) ) } - let!(:order3) { FactoryGirl.create(:order, state: 'complete', completed_at: Time.zone.now, distributor: dist1, billing_address: FactoryGirl.create(:address) ) } - let!(:line_item1) { FactoryGirl.create(:line_item, order: order1) } - let!(:line_item2) { FactoryGirl.create(:line_item, order: order2) } - let!(:line_item3) { FactoryGirl.create(:line_item, order: order2) } - let!(:line_item4) { FactoryGirl.create(:line_item, order: order3) } + let!(:dist1) { FactoryBot.create(:distributor_enterprise) } + let!(:order1) { FactoryBot.create(:order, state: 'complete', completed_at: Time.zone.now, distributor: dist1, billing_address: FactoryBot.create(:address) ) } + let!(:order2) { FactoryBot.create(:order, state: 'complete', completed_at: Time.zone.now, distributor: dist1, billing_address: FactoryBot.create(:address) ) } + let!(:order3) { FactoryBot.create(:order, state: 'complete', completed_at: Time.zone.now, distributor: dist1, billing_address: FactoryBot.create(:address) ) } + let!(:line_item1) { FactoryBot.create(:line_item, order: order1) } + let!(:line_item2) { FactoryBot.create(:line_item, order: order2) } + let!(:line_item3) { FactoryBot.create(:line_item, order: order2) } + let!(:line_item4) { FactoryBot.create(:line_item, order: order3) } let(:line_item_attributes) { [:id, :quantity, :max_quantity, :supplier, :units_product, :units_variant] } end @@ -95,11 +95,11 @@ describe Spree::Admin::OrdersController, type: :controller do let(:distributor2) { create(:distributor_enterprise) } let(:coordinator) { create(:distributor_enterprise) } let(:order_cycle) { create(:simple_order_cycle, coordinator: coordinator) } - let!(:order1) { FactoryGirl.create(:order, order_cycle: order_cycle, state: 'complete', completed_at: Time.zone.now, distributor: distributor1, billing_address: FactoryGirl.create(:address) ) } - let!(:line_item1) { FactoryGirl.create(:line_item, order: order1, product: FactoryGirl.create(:product, supplier: supplier)) } - let!(:line_item2) { FactoryGirl.create(:line_item, order: order1, product: FactoryGirl.create(:product, supplier: supplier)) } - let!(:order2) { FactoryGirl.create(:order, order_cycle: order_cycle, state: 'complete', completed_at: Time.zone.now, distributor: distributor2, billing_address: FactoryGirl.create(:address) ) } - let!(:line_item3) { FactoryGirl.create(:line_item, order: order2, product: FactoryGirl.create(:product, supplier: supplier)) } + let!(:order1) { FactoryBot.create(:order, order_cycle: order_cycle, state: 'complete', completed_at: Time.zone.now, distributor: distributor1, billing_address: FactoryBot.create(:address) ) } + let!(:line_item1) { FactoryBot.create(:line_item, order: order1, product: FactoryBot.create(:product, supplier: supplier)) } + let!(:line_item2) { FactoryBot.create(:line_item, order: order1, product: FactoryBot.create(:product, supplier: supplier)) } + let!(:order2) { FactoryBot.create(:order, order_cycle: order_cycle, state: 'complete', completed_at: Time.zone.now, distributor: distributor2, billing_address: FactoryBot.create(:address) ) } + let!(:line_item3) { FactoryBot.create(:line_item, order: order2, product: FactoryBot.create(:product, supplier: supplier)) } context "producer enterprise" do diff --git a/spec/controllers/spree/api/line_items_controller_spec.rb b/spec/controllers/spree/api/line_items_controller_spec.rb index cf864aabc5..47c09f36b6 100644 --- a/spec/controllers/spree/api/line_items_controller_spec.rb +++ b/spec/controllers/spree/api/line_items_controller_spec.rb @@ -12,8 +12,8 @@ module Spree context "as an admin user" do sign_in_as_admin! - let(:order) { FactoryGirl.create(:order, state: 'complete', completed_at: Time.zone.now) } - let(:line_item) { FactoryGirl.create(:line_item, order: order, final_weight_volume: 500) } + let(:order) { FactoryBot.create(:order, state: 'complete', completed_at: Time.zone.now) } + let(:line_item) { FactoryBot.create(:line_item, order: order, final_weight_volume: 500) } context "as a line item is updated" do before { allow(controller).to receive(:order) { order } } diff --git a/spec/controllers/spree/api/products_controller_spec.rb b/spec/controllers/spree/api/products_controller_spec.rb index d952c42537..b3d518a9da 100644 --- a/spec/controllers/spree/api/products_controller_spec.rb +++ b/spec/controllers/spree/api/products_controller_spec.rb @@ -75,8 +75,8 @@ module Spree end it "sorts products in ascending id order" do - FactoryGirl.create(:product, supplier: supplier) - FactoryGirl.create(:product, supplier: supplier) + FactoryBot.create(:product, supplier: supplier) + FactoryBot.create(:product, supplier: supplier) spree_get :index, { :template => 'bulk_index', :format => :json } @@ -99,7 +99,7 @@ module Spree spree_get :index, { :template => 'bulk_index', :format => :json } json_response.size.should == 1 - product5 = FactoryGirl.create(:product) + product5 = FactoryBot.create(:product) product5.available_on = nil product5.save! diff --git a/spec/controllers/spree/api/variants_controller_spec.rb b/spec/controllers/spree/api/variants_controller_spec.rb index 06f6185675..698a3e7926 100644 --- a/spec/controllers/spree/api/variants_controller_spec.rb +++ b/spec/controllers/spree/api/variants_controller_spec.rb @@ -4,10 +4,10 @@ module Spree describe Spree::Api::VariantsController, type: :controller do render_views - let(:supplier) { FactoryGirl.create(:supplier_enterprise) } - let!(:variant1) { FactoryGirl.create(:variant) } - let!(:variant2) { FactoryGirl.create(:variant) } - let!(:variant3) { FactoryGirl.create(:variant) } + let(:supplier) { FactoryBot.create(:supplier_enterprise) } + let!(:variant1) { FactoryBot.create(:variant) } + let!(:variant2) { FactoryBot.create(:variant) } + let!(:variant3) { FactoryBot.create(:variant) } let(:attributes) { [:id, :options_text, :price, :on_hand, :unit_value, :unit_description, :on_demand, :display_as, :display_name] } before do diff --git a/spec/factories.rb b/spec/factories.rb index ebe8d05dd8..fd4e4e1bf5 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -1,7 +1,7 @@ require 'ffaker' require 'spree/testing_support/factories' -# http://www.rubydoc.info/gems/factory_girl/file/GETTING_STARTED.md +# http://www.rubydoc.info/gems/factory_bot/file/GETTING_STARTED.md # # The spree_core gem defines factories in several files. For example: # @@ -15,7 +15,7 @@ require 'spree/testing_support/factories' # * order_with_inventory_unit_shipped # * completed_order_with_totals # -FactoryGirl.define do +FactoryBot.define do factory :classification, class: Spree::Classification do end @@ -85,7 +85,7 @@ FactoryGirl.define do orders_open_at { 1.day.ago } orders_close_at { 1.week.from_now } - coordinator { Enterprise.is_distributor.first || FactoryGirl.create(:distributor_enterprise) } + coordinator { Enterprise.is_distributor.first || FactoryBot.create(:distributor_enterprise) } transient do suppliers [] @@ -128,14 +128,14 @@ FactoryGirl.define do factory :exchange, :class => Exchange do incoming false - order_cycle { OrderCycle.first || FactoryGirl.create(:simple_order_cycle) } - sender { incoming ? FactoryGirl.create(:enterprise) : order_cycle.coordinator } - receiver { incoming ? order_cycle.coordinator : FactoryGirl.create(:enterprise) } + order_cycle { OrderCycle.first || FactoryBot.create(:simple_order_cycle) } + sender { incoming ? FactoryBot.create(:enterprise) : order_cycle.coordinator } + receiver { incoming ? order_cycle.coordinator : FactoryBot.create(:enterprise) } end factory :schedule, class: Schedule do sequence(:name) { |n| "Schedule #{n}" } - order_cycles { [OrderCycle.first || FactoryGirl.create(:simple_order_cycle)] } + order_cycles { [OrderCycle.first || FactoryBot.create(:simple_order_cycle)] } end factory :subscription, :class => Subscription do @@ -201,12 +201,12 @@ FactoryGirl.define do end factory :enterprise, :class => Enterprise do - owner { FactoryGirl.create :user } + owner { FactoryBot.create :user } sequence(:name) { |n| "Enterprise #{n}" } sells 'any' description 'enterprise' long_description '

Hello, world!

This is a paragraph.

' - address { FactoryGirl.create(:address) } + address { FactoryBot.create(:address) } end factory :supplier_enterprise, :parent => :enterprise do @@ -241,7 +241,7 @@ FactoryGirl.define do sequence(:permalink) { |n| "group#{n}" } description 'this is a group' on_front_page false - address { FactoryGirl.build(:address) } + address { FactoryBot.build(:address) } end sequence(:calculator_amount) @@ -255,21 +255,21 @@ FactoryGirl.define do sequence(:name) { |n| "Enterprise fee #{n}" } sequence(:fee_type) { |n| EnterpriseFee::FEE_TYPES[n % EnterpriseFee::FEE_TYPES.count] } - enterprise { Enterprise.first || FactoryGirl.create(:supplier_enterprise) } + enterprise { Enterprise.first || FactoryBot.create(:supplier_enterprise) } calculator { build(:calculator_per_item, preferred_amount: amount) } after(:create) { |ef| ef.calculator.save! } end factory :product_distribution, :class => ProductDistribution do - product { |pd| Spree::Product.first || FactoryGirl.create(:product) } - distributor { |pd| Enterprise.is_distributor.first || FactoryGirl.create(:distributor_enterprise) } - enterprise_fee { |pd| FactoryGirl.create(:enterprise_fee, enterprise: pd.distributor) } + product { |pd| Spree::Product.first || FactoryBot.create(:product) } + distributor { |pd| Enterprise.is_distributor.first || FactoryBot.create(:distributor_enterprise) } + enterprise_fee { |pd| FactoryBot.create(:enterprise_fee, enterprise: pd.distributor) } end factory :adjustment_metadata, :class => AdjustmentMetadata do - adjustment { FactoryGirl.create(:adjustment) } - enterprise { FactoryGirl.create(:distributor_enterprise) } + adjustment { FactoryBot.create(:adjustment) } + enterprise { FactoryBot.create(:distributor_enterprise) } fee_name 'fee' fee_type 'packing' enterprise_role 'distributor' @@ -286,7 +286,7 @@ FactoryGirl.define do after(:create) do |order| p = create(:simple_product, :distributors => [order.distributor]) - FactoryGirl.create(:line_item, :order => order, :product => p) + FactoryBot.create(:line_item, :order => order, :product => p) order.reload end end @@ -309,8 +309,8 @@ FactoryGirl.define do order.distributor.update_attribute(:charges_sales_tax, true) Spree::Zone.global.update_attribute(:default_tax, true) - p = FactoryGirl.create(:taxed_product, zone: Spree::Zone.global, price: proxy.product_price, tax_rate_amount: proxy.tax_rate_amount, tax_rate_name: proxy.tax_rate_name, distributors: [order.distributor]) - FactoryGirl.create(:line_item, order: order, product: p, price: p.price) + p = FactoryBot.create(:taxed_product, zone: Spree::Zone.global, price: proxy.product_price, tax_rate_amount: proxy.tax_rate_amount, tax_rate_name: proxy.tax_rate_name, distributors: [order.distributor]) + FactoryBot.create(:line_item, order: order, product: p, price: p.price) order.reload end end @@ -403,34 +403,34 @@ FactoryGirl.define do turnover { rand(100000).to_f/100 } account_invoice do AccountInvoice.where(user_id: owner_id, year: begins_at.year, month: begins_at.month).first || - FactoryGirl.create(:account_invoice, user: owner, year: begins_at.year, month: begins_at.month) + FactoryBot.create(:account_invoice, user: owner, year: begins_at.year, month: begins_at.month) end end factory :account_invoice do - user { FactoryGirl.create :user } + user { FactoryBot.create :user } year { 2000 + rand(100) } month { 1 + rand(12) } end factory :filter_order_cycles_tag_rule, class: TagRule::FilterOrderCycles do - enterprise { FactoryGirl.create :distributor_enterprise } + enterprise { FactoryBot.create :distributor_enterprise } end factory :filter_shipping_methods_tag_rule, class: TagRule::FilterShippingMethods do - enterprise { FactoryGirl.create :distributor_enterprise } + enterprise { FactoryBot.create :distributor_enterprise } end factory :filter_products_tag_rule, class: TagRule::FilterProducts do - enterprise { FactoryGirl.create :distributor_enterprise } + enterprise { FactoryBot.create :distributor_enterprise } end factory :filter_payment_methods_tag_rule, class: TagRule::FilterPaymentMethods do - enterprise { FactoryGirl.create :distributor_enterprise } + enterprise { FactoryBot.create :distributor_enterprise } end factory :tag_rule, class: TagRule::DiscountOrder do - enterprise { FactoryGirl.create :distributor_enterprise } + enterprise { FactoryBot.create :distributor_enterprise } before(:create) do |tr| tr.calculator = Spree::Calculator::FlatPercentItemTotal.new(calculable: tr) end @@ -442,7 +442,7 @@ FactoryGirl.define do end factory :stripe_account do - enterprise { FactoryGirl.create :distributor_enterprise } + enterprise { FactoryBot.create :distributor_enterprise } stripe_user_id "abc123" stripe_publishable_key "xyz456" end @@ -456,9 +456,9 @@ FactoryGirl.define do end -FactoryGirl.modify do +FactoryBot.modify do factory :product do - primary_taxon { Spree::Taxon.first || FactoryGirl.create(:taxon) } + primary_taxon { Spree::Taxon.first || FactoryBot.create(:taxon) } end factory :simple_product do # Fix product factory name sequence with Kernel.rand so it is not interpreted as a Spree::Product method @@ -466,8 +466,8 @@ FactoryGirl.modify do # When this fix has been merged into a version of Spree that we're using, this line can be removed. sequence(:name) { |n| "Product ##{n} - #{Kernel.rand(9999)}" } - supplier { Enterprise.is_primary_producer.first || FactoryGirl.create(:supplier_enterprise) } - primary_taxon { Spree::Taxon.first || FactoryGirl.create(:taxon) } + supplier { Enterprise.is_primary_producer.first || FactoryBot.create(:supplier_enterprise) } + primary_taxon { Spree::Taxon.first || FactoryBot.create(:taxon) } on_hand 3 unit_value 1 @@ -484,7 +484,7 @@ FactoryGirl.modify do end factory :shipping_method do - distributors { [Enterprise.is_distributor.first || FactoryGirl.create(:distributor_enterprise)] } + distributors { [Enterprise.is_distributor.first || FactoryBot.create(:distributor_enterprise)] } display_on '' end @@ -495,13 +495,13 @@ FactoryGirl.modify do factory :payment do transient do - distributor { order.distributor || Enterprise.is_distributor.first || FactoryGirl.create(:distributor_enterprise) } + distributor { order.distributor || Enterprise.is_distributor.first || FactoryBot.create(:distributor_enterprise) } end - payment_method { FactoryGirl.create(:payment_method, distributors: [distributor]) } + payment_method { FactoryBot.create(:payment_method, distributors: [distributor]) } end factory :payment_method do - distributors { [Enterprise.is_distributor.first || FactoryGirl.create(:distributor_enterprise)] } + distributors { [Enterprise.is_distributor.first || FactoryBot.create(:distributor_enterprise)] } end factory :option_type do diff --git a/spec/features/admin/bulk_product_update_spec.rb b/spec/features/admin/bulk_product_update_spec.rb index 137612b174..61d563cb0c 100644 --- a/spec/features/admin/bulk_product_update_spec.rb +++ b/spec/features/admin/bulk_product_update_spec.rb @@ -13,8 +13,8 @@ feature %q{ end it "displays a list of products" do - p1 = FactoryGirl.create(:product) - p2 = FactoryGirl.create(:product) + p1 = FactoryBot.create(:product) + p2 = FactoryBot.create(:product) visit '/admin/products/bulk_edit' @@ -29,11 +29,11 @@ feature %q{ end it "displays a select box for suppliers, with the appropriate supplier selected" do - s1 = FactoryGirl.create(:supplier_enterprise) - s2 = FactoryGirl.create(:supplier_enterprise) - s3 = FactoryGirl.create(:supplier_enterprise) - p1 = FactoryGirl.create(:product, supplier: s2) - p2 = FactoryGirl.create(:product, supplier: s3) + s1 = FactoryBot.create(:supplier_enterprise) + s2 = FactoryBot.create(:supplier_enterprise) + s3 = FactoryBot.create(:supplier_enterprise) + p1 = FactoryBot.create(:product, supplier: s2) + p2 = FactoryBot.create(:product, supplier: s3) visit '/admin/products/bulk_edit' @@ -42,8 +42,8 @@ feature %q{ end it "displays a date input for available_on for each product, formatted to yyyy-mm-dd hh:mm:ss" do - p1 = FactoryGirl.create(:product, available_on: Date.current) - p2 = FactoryGirl.create(:product, available_on: Date.current-1) + p1 = FactoryBot.create(:product, available_on: Date.current) + p2 = FactoryBot.create(:product, available_on: Date.current-1) visit '/admin/products/bulk_edit' find("div#columns-dropdown", :text => "COLUMNS").click @@ -55,7 +55,7 @@ feature %q{ end it "displays an on hand count in a span for each product" do - p1 = FactoryGirl.create(:product, on_hand: 15) + p1 = FactoryBot.create(:product, on_hand: 15) v1 = p1.variants.first v1.on_hand = 4 v1.save! @@ -69,9 +69,9 @@ feature %q{ end it "displays 'on demand' for any variant that is available on demand" do - p1 = FactoryGirl.create(:product) - v1 = FactoryGirl.create(:variant, product: p1, is_master: false, on_hand: 4) - v2 = FactoryGirl.create(:variant, product: p1, is_master: false, on_hand: 0, on_demand: true) + p1 = FactoryBot.create(:product) + v1 = FactoryBot.create(:variant, product: p1, is_master: false, on_hand: 4) + v2 = FactoryBot.create(:variant, product: p1, is_master: false, on_hand: 0, on_demand: true) visit '/admin/products/bulk_edit' expect(page).to have_selector "a.view-variants", count: 1 @@ -84,7 +84,7 @@ feature %q{ end it "displays a select box for the unit of measure for the product's variants" do - p = FactoryGirl.create(:product, variant_unit: 'weight', variant_unit_scale: 1, variant_unit_name: '') + p = FactoryBot.create(:product, variant_unit: 'weight', variant_unit_scale: 1, variant_unit_name: '') visit '/admin/products/bulk_edit' @@ -92,7 +92,7 @@ feature %q{ end it "displays a text field for the item name when unit is set to 'Items'" do - p = FactoryGirl.create(:product, variant_unit: 'items', variant_unit_scale: nil, variant_unit_name: 'packet') + p = FactoryBot.create(:product, variant_unit: 'items', variant_unit_scale: nil, variant_unit_name: 'packet') visit '/admin/products/bulk_edit' @@ -107,8 +107,8 @@ feature %q{ end it "displays a list of variants for each product" do - v1 = FactoryGirl.create(:variant, display_name: "something1" ) - v2 = FactoryGirl.create(:variant, display_name: "something2" ) + v1 = FactoryBot.create(:variant, display_name: "something1" ) + v2 = FactoryBot.create(:variant, display_name: "something2" ) visit '/admin/products/bulk_edit' expect(page).to have_selector "a.view-variants", count: 2 @@ -121,9 +121,9 @@ feature %q{ end it "displays an on_hand input (for each variant) for each product" do - p1 = FactoryGirl.create(:product) - v1 = FactoryGirl.create(:variant, product: p1, is_master: false, on_hand: 15) - v2 = FactoryGirl.create(:variant, product: p1, is_master: false, on_hand: 6) + p1 = FactoryBot.create(:product) + v1 = FactoryBot.create(:variant, product: p1, is_master: false, on_hand: 15) + v2 = FactoryBot.create(:variant, product: p1, is_master: false, on_hand: 6) visit '/admin/products/bulk_edit' expect(page).to have_selector "a.view-variants", count: 1 @@ -136,9 +136,9 @@ feature %q{ it "displays a price input (for each variant) for each product" do - p1 = FactoryGirl.create(:product, price: 2.0) - v1 = FactoryGirl.create(:variant, product: p1, is_master: false, price: 12.75) - v2 = FactoryGirl.create(:variant, product: p1, is_master: false, price: 2.50) + p1 = FactoryBot.create(:product, price: 2.0) + v1 = FactoryBot.create(:variant, product: p1, is_master: false, price: 12.75) + v2 = FactoryBot.create(:variant, product: p1, is_master: false, price: 2.50) visit '/admin/products/bulk_edit' expect(page).to have_selector "a.view-variants", count: 1 @@ -150,9 +150,9 @@ feature %q{ end it "displays a unit value field (for each variant) for each product" do - p1 = FactoryGirl.create(:product, price: 2.0, variant_unit: "weight", variant_unit_scale: "1000") - v1 = FactoryGirl.create(:variant, product: p1, is_master: false, price: 12.75, unit_value: 1200, unit_description: "(small bag)", display_as: "bag") - v2 = FactoryGirl.create(:variant, product: p1, is_master: false, price: 2.50, unit_value: 4800, unit_description: "(large bag)", display_as: "bin") + p1 = FactoryBot.create(:product, price: 2.0, variant_unit: "weight", variant_unit_scale: "1000") + v1 = FactoryBot.create(:variant, product: p1, is_master: false, price: 12.75, unit_value: 1200, unit_description: "(small bag)", display_as: "bag") + v2 = FactoryBot.create(:variant, product: p1, is_master: false, price: 2.50, unit_value: 4800, unit_description: "(large bag)", display_as: "bin") visit '/admin/products/bulk_edit' expect(page).to have_selector "a.view-variants", count: 1 @@ -167,8 +167,8 @@ feature %q{ scenario "creating a new product" do - s = FactoryGirl.create(:supplier_enterprise) - d = FactoryGirl.create(:distributor_enterprise) + s = FactoryBot.create(:supplier_enterprise) + d = FactoryBot.create(:distributor_enterprise) taxon = create(:taxon) login_to_admin_section @@ -194,7 +194,7 @@ feature %q{ scenario "creating new variants" do # Given a product without variants or a unit - p = FactoryGirl.create(:product, variant_unit: 'weight', variant_unit_scale: 1000) + p = FactoryBot.create(:product, variant_unit: 'weight', variant_unit_scale: 1000) login_to_admin_section visit '/admin/products/bulk_edit' @@ -238,11 +238,11 @@ feature %q{ end scenario "updating product attributes" do - s1 = FactoryGirl.create(:supplier_enterprise) - s2 = FactoryGirl.create(:supplier_enterprise) - t1 = FactoryGirl.create(:taxon) - t2 = FactoryGirl.create(:taxon) - p = FactoryGirl.create(:product, supplier: s1, available_on: Date.current, variant_unit: 'volume', variant_unit_scale: 1, primary_taxon: t2, sku: "OLD SKU") + s1 = FactoryBot.create(:supplier_enterprise) + s2 = FactoryBot.create(:supplier_enterprise) + t1 = FactoryBot.create(:taxon) + t2 = FactoryBot.create(:taxon) + p = FactoryBot.create(:product, supplier: s1, available_on: Date.current, variant_unit: 'volume', variant_unit_scale: 1, primary_taxon: t2, sku: "OLD SKU") login_to_admin_section @@ -288,7 +288,7 @@ feature %q{ end scenario "updating a product with a variant unit of 'items'" do - p = FactoryGirl.create(:product, variant_unit: 'weight', variant_unit_scale: 1000) + p = FactoryBot.create(:product, variant_unit: 'weight', variant_unit_scale: 1000) login_to_admin_section @@ -309,9 +309,9 @@ feature %q{ end scenario "updating a product with variants" do - s1 = FactoryGirl.create(:supplier_enterprise) - s2 = FactoryGirl.create(:supplier_enterprise) - p = FactoryGirl.create(:product, supplier: s1, available_on: Date.current, variant_unit: 'volume', variant_unit_scale: 0.001, + s1 = FactoryBot.create(:supplier_enterprise) + s2 = FactoryBot.create(:supplier_enterprise) + p = FactoryBot.create(:product, supplier: s1, available_on: Date.current, variant_unit: 'volume', variant_unit_scale: 0.001, price: 3.0, on_hand: 9, unit_value: 0.25, unit_description: '(bottle)' ) v = p.variants.first v.update_column(:sku, "VARIANTSKU") @@ -352,8 +352,8 @@ feature %q{ end scenario "updating delegated attributes of variants in isolation" do - p = FactoryGirl.create(:product) - v = FactoryGirl.create(:variant, product: p, price: 3.0) + p = FactoryBot.create(:product) + v = FactoryBot.create(:variant, product: p, price: 3.0) login_to_admin_section @@ -378,7 +378,7 @@ feature %q{ end scenario "updating a product mutiple times without refresh" do - p = FactoryGirl.create(:product, name: 'original name') + p = FactoryBot.create(:product, name: 'original name') login_to_admin_section visit '/admin/products/bulk_edit' @@ -411,7 +411,7 @@ feature %q{ end scenario "updating a product after cloning a product" do - p = FactoryGirl.create(:product, :name => "product 1") + p = FactoryBot.create(:product, :name => "product 1") login_to_admin_section visit '/admin/products/bulk_edit' @@ -433,8 +433,8 @@ feature %q{ scenario "updating when a filter has been applied" do s1 = create(:supplier_enterprise) s2 = create(:supplier_enterprise) - p1 = FactoryGirl.create(:simple_product, :name => "product1", supplier: s1) - p2 = FactoryGirl.create(:simple_product, :name => "product2", supplier: s2) + p1 = FactoryBot.create(:simple_product, :name => "product1", supplier: s1) + p2 = FactoryBot.create(:simple_product, :name => "product2", supplier: s2) login_to_admin_section visit '/admin/products/bulk_edit' @@ -455,11 +455,11 @@ feature %q{ describe "using action buttons" do describe "using delete buttons" do - let!(:p1) { FactoryGirl.create(:product) } - let!(:p2) { FactoryGirl.create(:product) } + let!(:p1) { FactoryBot.create(:product) } + let!(:p2) { FactoryBot.create(:product) } let!(:v1) { p1.variants.first } let!(:v2) { p2.variants.first } - let!(:v3) { FactoryGirl.create(:variant, product: p2 ) } + let!(:v3) { FactoryBot.create(:variant, product: p2 ) } before do @@ -502,8 +502,8 @@ feature %q{ end describe "using edit buttons" do - let!(:p1) { FactoryGirl.create(:product) } - let!(:p2) { FactoryGirl.create(:product) } + let!(:p1) { FactoryBot.create(:product) } + let!(:p2) { FactoryBot.create(:product) } let!(:v1) { p1.variants.first } let!(:v2) { p2.variants.first } @@ -538,9 +538,9 @@ feature %q{ describe "using clone buttons" do it "shows a clone button for products, which duplicates the product and adds it to the page when clicked" do - p1 = FactoryGirl.create(:product, :name => "P1") - p2 = FactoryGirl.create(:product, :name => "P2") - p3 = FactoryGirl.create(:product, :name => "P3") + p1 = FactoryBot.create(:product, :name => "P1") + p2 = FactoryBot.create(:product, :name => "P2") + p3 = FactoryBot.create(:product, :name => "P3") login_to_admin_section visit '/admin/products/bulk_edit' @@ -566,7 +566,7 @@ feature %q{ describe "using the page" do describe "using column display dropdown" do it "shows a column display dropdown, which shows a list of columns when clicked" do - FactoryGirl.create(:simple_product) + FactoryBot.create(:simple_product) login_to_admin_section visit '/admin/products/bulk_edit' @@ -597,8 +597,8 @@ feature %q{ it "displays basic filtering controls which filter the product list" do s1 = create(:supplier_enterprise) s2 = create(:supplier_enterprise) - p1 = FactoryGirl.create(:simple_product, :name => "product1", supplier: s1) - p2 = FactoryGirl.create(:simple_product, :name => "product2", supplier: s2) + p1 = FactoryBot.create(:simple_product, :name => "product1", supplier: s1) + p2 = FactoryBot.create(:simple_product, :name => "product2", supplier: s2) login_to_admin_section visit '/admin/products/bulk_edit' diff --git a/spec/lib/open_food_network/order_cycle_form_applicator_spec.rb b/spec/lib/open_food_network/order_cycle_form_applicator_spec.rb index 9cd5461a37..db02f1656d 100644 --- a/spec/lib/open_food_network/order_cycle_form_applicator_spec.rb +++ b/spec/lib/open_food_network/order_cycle_form_applicator_spec.rb @@ -294,8 +294,8 @@ module OpenFoodNetwork end it "checks whether exchanges exist" do - oc = FactoryGirl.create(:simple_order_cycle) - exchange = FactoryGirl.create(:exchange, order_cycle: oc) + oc = FactoryBot.create(:simple_order_cycle) + exchange = FactoryBot.create(:exchange, order_cycle: oc) applicator = OrderCycleFormApplicator.new(oc, user) applicator.send(:exchange_exists?, exchange.sender_id, exchange.receiver_id, exchange.incoming).should be true @@ -425,9 +425,9 @@ module OpenFoodNetwork end it "does not add exchanges it is not permitted to touch" do - sender = FactoryGirl.create(:enterprise) - receiver = FactoryGirl.create(:enterprise) - oc = FactoryGirl.create(:simple_order_cycle) + sender = FactoryBot.create(:enterprise) + receiver = FactoryBot.create(:enterprise) + oc = FactoryBot.create(:simple_order_cycle) applicator = OrderCycleFormApplicator.new(oc, user) incoming = true @@ -438,13 +438,13 @@ module OpenFoodNetwork end it "does not update exchanges it is not permitted to touch" do - sender = FactoryGirl.create(:enterprise) - receiver = FactoryGirl.create(:enterprise) - oc = FactoryGirl.create(:simple_order_cycle) + sender = FactoryBot.create(:enterprise) + receiver = FactoryBot.create(:enterprise) + oc = FactoryBot.create(:simple_order_cycle) applicator = OrderCycleFormApplicator.new(oc, user) incoming = true - exchange = FactoryGirl.create(:exchange, order_cycle: oc, sender: sender, receiver: receiver, incoming: incoming) - variant1 = FactoryGirl.create(:variant) + exchange = FactoryBot.create(:exchange, order_cycle: oc, sender: sender, receiver: receiver, incoming: incoming) + variant1 = FactoryBot.create(:variant) applicator.send(:touched_exchanges=, []) applicator.send(:update_exchange, sender.id, receiver.id, incoming, {:variant_ids => [variant1.id]}) diff --git a/spec/models/cart_spec.rb b/spec/models/cart_spec.rb index 9ef473db3e..08bc4ff3a8 100644 --- a/spec/models/cart_spec.rb +++ b/spec/models/cart_spec.rb @@ -10,11 +10,11 @@ describe Cart do describe 'when adding a product' do let(:cart) { Cart.create(user: user) } - let(:distributor) { FactoryGirl.create(:distributor_enterprise) } - let(:other_distributor) { FactoryGirl.create(:distributor_enterprise) } + let(:distributor) { FactoryBot.create(:distributor_enterprise) } + let(:other_distributor) { FactoryBot.create(:distributor_enterprise) } let(:currency) { "AUD" } - let(:product) { FactoryGirl.create(:product, :distributors => [distributor]) } + let(:product) { FactoryBot.create(:product, :distributors => [distributor]) } let(:product_with_order_cycle) { create(:product) } let(:order_cycle) { create(:simple_order_cycle, distributors: [distributor, other_distributor], variants: [product_with_order_cycle.master]) } @@ -44,11 +44,11 @@ describe Cart do end describe 'to a cart with an order for a distributor' do - let(:product_from_other_distributor) { FactoryGirl.create(:product, :distributors => [other_distributor]) } - let(:order) { FactoryGirl.create(:order, :distributor => distributor) } + let(:product_from_other_distributor) { FactoryBot.create(:product, :distributors => [other_distributor]) } + let(:order) { FactoryBot.create(:order, :distributor => distributor) } before do - FactoryGirl.create(:line_item, :order => order, :product => product) + FactoryBot.create(:line_item, :order => order, :product => product) order.reload subject.orders << order subject.save! @@ -86,7 +86,7 @@ describe Cart do end describe 'existing order for distributor and order cycle' do - let(:order) { FactoryGirl.create(:order, :distributor => distributor, :order_cycle => order_cycle) } + let(:order) { FactoryBot.create(:order, :distributor => distributor, :order_cycle => order_cycle) } before do subject.orders << order diff --git a/spec/models/enterprise_spec.rb b/spec/models/enterprise_spec.rb index 781a60475d..3f598c2ea0 100644 --- a/spec/models/enterprise_spec.rb +++ b/spec/models/enterprise_spec.rb @@ -112,7 +112,7 @@ describe Enterprise do end describe "validations" do - subject { FactoryGirl.create(:distributor_enterprise) } + subject { FactoryBot.create(:distributor_enterprise) } it { should validate_presence_of(:name) } it { should validate_uniqueness_of(:permalink) } it { should ensure_length_of(:description).is_at_most(255) } @@ -188,7 +188,7 @@ describe Enterprise do end describe "delegations" do - #subject { FactoryGirl.create(:distributor_enterprise, :address => FactoryGirl.create(:address)) } + #subject { FactoryBot.create(:distributor_enterprise, :address => FactoryBot.create(:address)) } it { should delegate(:latitude).to(:address) } it { should delegate(:longitude).to(:address) } diff --git a/spec/models/spree/addresses_spec.rb b/spec/models/spree/addresses_spec.rb index 5fc9d442b6..f453218610 100644 --- a/spec/models/spree/addresses_spec.rb +++ b/spec/models/spree/addresses_spec.rb @@ -10,7 +10,7 @@ describe Spree::Address do end describe "geocode address" do - let(:address) { FactoryGirl.build(:address) } + let(:address) { FactoryBot.build(:address) } it "should include address1, address2, zipcode, city, state and country" do address.geocode_address.should include(address.address1) @@ -30,7 +30,7 @@ describe Spree::Address do end describe "full address" do - let(:address) { FactoryGirl.build(:address) } + let(:address) { FactoryBot.build(:address) } it "should include address1, address2, zipcode, city and state" do address.full_address.should include(address.address1) diff --git a/spec/models/spree/order_spec.rb b/spec/models/spree/order_spec.rb index 7680023463..d872e90ddb 100644 --- a/spec/models/spree/order_spec.rb +++ b/spec/models/spree/order_spec.rb @@ -459,32 +459,32 @@ describe Spree::Order do context "validating distributor changes" do it "checks that a distributor is available when changing" do set_feature_toggle :order_cycles, false - order_enterprise = FactoryGirl.create(:enterprise, id: 1, :name => "Order Enterprise") + order_enterprise = FactoryBot.create(:enterprise, id: 1, :name => "Order Enterprise") subject.distributor = order_enterprise - product1 = FactoryGirl.create(:product) - product2 = FactoryGirl.create(:product) - product3 = FactoryGirl.create(:product) - variant11 = FactoryGirl.create(:variant, product: product1) - variant12 = FactoryGirl.create(:variant, product: product1) - variant21 = FactoryGirl.create(:variant, product: product2) - variant31 = FactoryGirl.create(:variant, product: product3) - variant32 = FactoryGirl.create(:variant, product: product3) + product1 = FactoryBot.create(:product) + product2 = FactoryBot.create(:product) + product3 = FactoryBot.create(:product) + variant11 = FactoryBot.create(:variant, product: product1) + variant12 = FactoryBot.create(:variant, product: product1) + variant21 = FactoryBot.create(:variant, product: product2) + variant31 = FactoryBot.create(:variant, product: product3) + variant32 = FactoryBot.create(:variant, product: product3) # Product Distributions # Order Enterprise sells product 1 and product 3 - FactoryGirl.create(:product_distribution, product: product1, distributor: order_enterprise) - FactoryGirl.create(:product_distribution, product: product3, distributor: order_enterprise) + FactoryBot.create(:product_distribution, product: product1, distributor: order_enterprise) + FactoryBot.create(:product_distribution, product: product3, distributor: order_enterprise) # Build the current order - line_item1 = FactoryGirl.create(:line_item, order: subject, variant: variant11) - line_item2 = FactoryGirl.create(:line_item, order: subject, variant: variant12) - line_item3 = FactoryGirl.create(:line_item, order: subject, variant: variant31) + line_item1 = FactoryBot.create(:line_item, order: subject, variant: variant11) + line_item2 = FactoryBot.create(:line_item, order: subject, variant: variant12) + line_item3 = FactoryBot.create(:line_item, order: subject, variant: variant31) subject.reload subject.line_items = [line_item1,line_item2,line_item3] - test_enterprise = FactoryGirl.create(:enterprise, id: 2, :name => "Test Enterprise") + test_enterprise = FactoryBot.create(:enterprise, id: 2, :name => "Test Enterprise") # Test Enterprise sells only product 1 - FactoryGirl.create(:product_distribution, product: product1, distributor: test_enterprise) + FactoryBot.create(:product_distribution, product: product1, distributor: test_enterprise) subject.distributor = test_enterprise subject.should_not be_valid @@ -502,7 +502,7 @@ describe Spree::Order do end it "finds only orders not in specified state" do - o = FactoryGirl.create(:completed_order_with_totals) + o = FactoryBot.create(:completed_order_with_totals) o.cancel! Spree::Order.not_state(:canceled).should_not include o end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 0679613855..08ad5094bc 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -141,9 +141,9 @@ RSpec.configure do |config| config.include OpenFoodNetwork::DelayedJobHelper config.include OpenFoodNetwork::PerformanceHelper - # FactoryGirl - require 'factory_girl_rails' - config.include FactoryGirl::Syntax::Methods + # FactoryBot + require 'factory_bot_rails' + config.include FactoryBot::Syntax::Methods config.include Paperclip::Shoulda::Matchers diff --git a/spec/support/spree/init.rb b/spec/support/spree/init.rb index 0007680de7..dacc2d44dc 100644 --- a/spec/support/spree/init.rb +++ b/spec/support/spree/init.rb @@ -5,6 +5,6 @@ ProductDistribution.class_eval do before_validation :init_enterprise_fee def init_enterprise_fee - self.enterprise_fee ||= EnterpriseFee.where(enterprise_id: distributor).first || FactoryGirl.create(:enterprise_fee, enterprise_id: distributor) + self.enterprise_fee ||= EnterpriseFee.where(enterprise_id: distributor).first || FactoryBot.create(:enterprise_fee, enterprise_id: distributor) end end From fd3e0d885ba3a9a7b5d2a5ac71e0993786fad561 Mon Sep 17 00:00:00 2001 From: Steven Lawson Date: Mon, 7 May 2018 17:15:55 -0600 Subject: [PATCH 028/206] Closes issue 1926, Changed package screen text from choose your starting point to choose your package --- config/locales/en.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index 4a2ee53afa..86f66e997a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -741,7 +741,7 @@ en: welcome_title: Welcome to the Open Food Network! welcome_text: You have successfully created a next_step: Next step - choose_starting_point: 'Choose your starting point:' + choose_starting_point: 'Choose your package:' invite_manager: user_already_exists: "User already exists" error: "Something went wrong" From 10d3abeaac369f63f649aa262a93c06fce4da958 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 26 Apr 2018 17:54:06 +1000 Subject: [PATCH 029/206] Upgrade Rubocop to the latest version --- .rubocop.yml | 6 +- .rubocop_todo.yml | 587 +++++++++++++++++++++++++++++++--------------- Gemfile.lock | 17 +- 3 files changed, 415 insertions(+), 195 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 1bd857d1d0..3edb24aad7 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -143,7 +143,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 diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index bd93ff1266..cf4dd57a72 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by # `rubocop --auto-gen-config --exclude-limit 1400` -# on 2018-04-23 13:00:19 +0200 using RuboCop version 0.49.1. +# on 2018-05-08 14:46:01 +1000 using RuboCop version 0.55.0. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new @@ -9,12 +9,12 @@ # Offense count: 36 # Cop supports --auto-correct. # Configuration parameters: Include, TreatCommentsAsGroupSeparators. -# Include: **/Gemfile, **/gems.rb +# Include: **/*.gemfile, **/Gemfile, **/gems.rb Bundler/OrderedGems: Exclude: - 'Gemfile' -# Offense count: 128 +# Offense count: 124 # Cop supports --auto-correct. Layout/AlignArray: Exclude: @@ -28,9 +28,9 @@ Layout/AlignArray: - 'spec/lib/open_food_network/order_and_distributor_report_spec.rb' - 'spec/lib/open_food_network/order_grouper_spec.rb' -# Offense count: 135 +# Offense count: 127 # Cop supports --auto-correct. -# Configuration parameters: EnforcedHashRocketStyle, SupportedHashRocketStyles, EnforcedColonStyle, SupportedColonStyles, EnforcedLastArgumentHashStyle, SupportedLastArgumentHashStyles. +# Configuration parameters: EnforcedHashRocketStyle, EnforcedColonStyle, EnforcedLastArgumentHashStyle. # SupportedHashRocketStyles: key, separator, table # SupportedColonStyles: key, separator, table # SupportedLastArgumentHashStyles: always_inspect, always_ignore, ignore_implicit, ignore_explicit @@ -43,7 +43,6 @@ Layout/AlignHash: - 'lib/open_food_network/packing_report.rb' - 'lib/open_food_network/payments_report.rb' - 'spec/archive/features/consumer/checkout_spec.rb' - - 'spec/factories.rb' - 'spec/features/admin/bulk_product_update_spec.rb' - 'spec/features/admin/order_cycles_spec.rb' - 'spec/features/admin/products_spec.rb' @@ -60,9 +59,9 @@ Layout/AlignHash: - 'spec/models/spree/shipping_method_spec.rb' - 'spec/models/spree/variant_spec.rb' -# Offense count: 58 +# Offense count: 62 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles, IndentationWidth. +# Configuration parameters: EnforcedStyle, IndentationWidth. # SupportedStyles: with_first_parameter, with_fixed_indentation Layout/AlignParameters: Exclude: @@ -87,6 +86,17 @@ Layout/AlignParameters: - 'spec/serializers/variant_serializer_spec.rb' - 'spec/support/request/authentication_workflow.rb' +# Offense count: 5 +# Cop supports --auto-correct. +# Configuration parameters: EnforcedStyleAlignWith. +# SupportedStylesAlignWith: either, start_of_block, start_of_line +Layout/BlockAlignment: + Exclude: + - 'spec/lib/open_food_network/enterprise_fee_calculator_spec.rb' + - 'spec/models/enterprise_spec.rb' + - 'spec/models/spree/line_item_spec.rb' + - 'spec/models/spree/product_spec.rb' + # Offense count: 1 # Cop supports --auto-correct. Layout/BlockEndNewline: @@ -110,7 +120,7 @@ Layout/ElseAlignment: - 'app/serializers/api/admin/order_cycle_serializer.rb' - 'lib/open_food_network/sales_tax_report.rb' -# Offense count: 210 +# Offense count: 209 # Cop supports --auto-correct. Layout/EmptyLines: Exclude: @@ -201,7 +211,6 @@ Layout/EmptyLines: - 'spec/controllers/spree/api/products_controller_spec.rb' - 'spec/controllers/spree/checkout_controller_spec.rb' - 'spec/controllers/spree/orders_controller_spec.rb' - - 'spec/factories.rb' - 'spec/features/admin/bulk_product_update_spec.rb' - 'spec/features/admin/enterprise_relationships_spec.rb' - 'spec/features/admin/enterprise_roles_spec.rb' @@ -252,9 +261,15 @@ Layout/EmptyLines: - 'spec/support/delayed_job_helper.rb' - 'spec/support/matchers/table_matchers.rb' +# Offense count: 1 +# Cop supports --auto-correct. +Layout/EmptyLinesAroundArguments: + Exclude: + - 'spec/archive/features/consumer/checkout_spec.rb' + # Offense count: 65 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. +# Configuration parameters: EnforcedStyle. # SupportedStyles: empty_lines, no_empty_lines Layout/EmptyLinesAroundBlockBody: Exclude: @@ -313,8 +328,8 @@ Layout/EmptyLinesAroundBlockBody: # Offense count: 27 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. -# SupportedStyles: empty_lines, empty_lines_except_namespace, empty_lines_special, no_empty_lines +# Configuration parameters: EnforcedStyle. +# SupportedStyles: empty_lines, empty_lines_except_namespace, empty_lines_special, no_empty_lines, beginning_only, ending_only Layout/EmptyLinesAroundClassBody: Exclude: - 'app/controllers/admin/account_controller.rb' @@ -342,7 +357,19 @@ Layout/EmptyLinesAroundClassBody: - 'lib/open_food_network/rack_request_blocker.rb' - 'lib/open_food_network/reports/bulk_coop_report.rb' -# Offense count: 54 +# Offense count: 5 +# Cop supports --auto-correct. +# Configuration parameters: EnforcedStyleAlignWith, AutoCorrect, Severity. +# SupportedStylesAlignWith: keyword, variable, start_of_line +Layout/EndAlignment: + Exclude: + - 'app/controllers/admin/order_cycles_controller.rb' + - 'app/controllers/api/order_cycles_controller.rb' + - 'app/serializers/api/admin/for_order_cycle/enterprise_serializer.rb' + - 'app/serializers/api/admin/for_order_cycle/supplied_product_serializer.rb' + - 'app/serializers/api/admin/order_cycle_serializer.rb' + +# Offense count: 53 # Cop supports --auto-correct. # Configuration parameters: AllowForAlignment, ForceEqualSignAlignment. Layout/ExtraSpacing: @@ -362,7 +389,6 @@ Layout/ExtraSpacing: - 'lib/spree/product_filters.rb' - 'lib/tasks/karma.rake' - 'spec/controllers/admin/accounts_and_billing_settings_controller_spec.rb' - - 'spec/factories.rb' - 'spec/features/admin/bulk_order_management_spec.rb' - 'spec/features/admin/bulk_product_update_spec.rb' - 'spec/features/admin/orders_spec.rb' @@ -387,14 +413,14 @@ Layout/ExtraSpacing: # Offense count: 2 # Cop supports --auto-correct. -# Configuration parameters: SupportedStyles, IndentationWidth. +# Configuration parameters: IndentationWidth. # SupportedStyles: special_inside_parentheses, consistent, align_brackets Layout/IndentArray: EnforcedStyle: consistent # Offense count: 52 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles, IndentationWidth. +# Configuration parameters: EnforcedStyle, IndentationWidth. # SupportedStyles: special_inside_parentheses, consistent, align_braces Layout/IndentHash: Exclude: @@ -417,7 +443,7 @@ Layout/IndentHash: # Offense count: 20 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. +# Configuration parameters: EnforcedStyle. # SupportedStyles: normal, rails Layout/IndentationConsistency: Exclude: @@ -452,7 +478,7 @@ Layout/IndentationWidth: - 'spec/models/enterprise_spec.rb' - 'spec/models/spree/calculator/flexi_rate_spec.rb' -# Offense count: 52 +# Offense count: 51 # Cop supports --auto-correct. Layout/LeadingCommentSpace: Exclude: @@ -470,7 +496,6 @@ Layout/LeadingCommentSpace: - 'lib/tasks/users.rake' - 'spec/archive/features/consumer/checkout_spec.rb' - 'spec/controllers/spree/api/line_items_controller_spec.rb' - - 'spec/factories.rb' - 'spec/features/admin/products_spec.rb' - 'spec/features/admin/reports_spec.rb' - 'spec/jobs/finalize_account_invoices_spec.rb' @@ -508,7 +533,7 @@ Layout/MultilineBlockLayout: # Offense count: 6 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. +# Configuration parameters: EnforcedStyle. # SupportedStyles: symmetrical, new_line, same_line Layout/MultilineHashBraceLayout: Exclude: @@ -520,7 +545,7 @@ Layout/MultilineHashBraceLayout: # Offense count: 7 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. +# Configuration parameters: EnforcedStyle. # SupportedStyles: symmetrical, new_line, same_line Layout/MultilineMethodCallBraceLayout: Exclude: @@ -533,16 +558,16 @@ Layout/MultilineMethodCallBraceLayout: # Offense count: 4 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles, IndentationWidth. +# Configuration parameters: EnforcedStyle, IndentationWidth. # SupportedStyles: aligned, indented, indented_relative_to_receiver Layout/MultilineMethodCallIndentation: Exclude: - 'spec/lib/open_food_network/cached_products_renderer_spec.rb' - 'spec/serializers/variant_serializer_spec.rb' -# Offense count: 34 +# Offense count: 32 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles, IndentationWidth. +# Configuration parameters: EnforcedStyle, IndentationWidth. # SupportedStyles: aligned, indented Layout/MultilineOperationIndentation: Exclude: @@ -560,7 +585,6 @@ Layout/MultilineOperationIndentation: - 'lib/open_food_network/products_cache_refreshment.rb' - 'lib/open_food_network/sales_tax_report.rb' - 'lib/open_food_network/users_and_enterprises_report.rb' - - 'spec/factories.rb' # Offense count: 7 # Cop supports --auto-correct. @@ -615,7 +639,7 @@ Layout/SpaceAfterSemicolon: # Offense count: 65 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. +# Configuration parameters: EnforcedStyle. # SupportedStyles: space, no_space Layout/SpaceAroundEqualsInParameterDefault: Exclude: @@ -659,7 +683,7 @@ Layout/SpaceAroundEqualsInParameterDefault: - 'spec/support/request/distribution_helper.rb' - 'spec/support/request/web_helper.rb' -# Offense count: 61 +# Offense count: 59 # Cop supports --auto-correct. # Configuration parameters: AllowForAlignment. Layout/SpaceAroundOperators: @@ -679,8 +703,6 @@ Layout/SpaceAroundOperators: - 'spec/controllers/admin/enterprises_controller_spec.rb' - 'spec/controllers/cart_controller_spec.rb' - 'spec/controllers/spree/orders_controller_spec.rb' - - 'spec/controllers/user_passwords_controller_spec.rb' - - 'spec/factories.rb' - 'spec/features/admin/bulk_order_management_spec.rb' - 'spec/features/admin/bulk_product_update_spec.rb' - 'spec/features/consumer/shopping/checkout_spec.rb' @@ -703,28 +725,49 @@ Layout/SpaceBeforeComma: - 'spec/features/admin/bulk_order_management_spec.rb' - 'spec/features/admin/bulk_product_update_spec.rb' -# Offense count: 5 +# Offense count: 4 # Cop supports --auto-correct. # Configuration parameters: AllowForAlignment. Layout/SpaceBeforeFirstArg: Exclude: - - 'spec/factories.rb' - 'spec/features/admin/bulk_product_update_spec.rb' - 'spec/features/consumer/multilingual_spec.rb' - 'spec/models/enterprise_fee_spec.rb' # Offense count: 5 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. +# Configuration parameters: EnforcedStyle. # SupportedStyles: require_no_space, require_space Layout/SpaceInLambdaLiteral: Exclude: - 'app/models/spree/product_decorator.rb' - 'app/models/spree/variant_decorator.rb' -# Offense count: 194 +# Offense count: 129 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles, EnforcedStyleForEmptyBraces, SupportedStylesForEmptyBraces, SpaceBeforeBlockParameters. +# Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBrackets. +# SupportedStyles: space, no_space, compact +# SupportedStylesForEmptyBrackets: space, no_space +Layout/SpaceInsideArrayLiteralBrackets: + Exclude: + - 'app/controllers/admin/order_cycles_controller.rb' + - 'app/helpers/spree/reports_helper.rb' + - 'lib/open_food_network/bulk_coop_report.rb' + - 'lib/open_food_network/orders_and_fulfillments_report.rb' + - 'lib/open_food_network/packing_report.rb' + - 'lib/open_food_network/payments_report.rb' + - 'lib/open_food_network/users_and_enterprises_report.rb' + - 'spec/controllers/admin/variant_overrides_controller_spec.rb' + - 'spec/controllers/cart_controller_spec.rb' + - 'spec/features/admin/reports_spec.rb' + - 'spec/jobs/update_billable_periods_spec.rb' + - 'spec/lib/open_food_network/order_and_distributor_report_spec.rb' + - 'spec/lib/open_food_network/order_grouper_spec.rb' + - 'spec/lib/open_food_network/users_and_enterprises_report_spec.rb' + +# Offense count: 192 +# Cop supports --auto-correct. +# Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBraces, SpaceBeforeBlockParameters. # SupportedStyles: space, no_space # SupportedStylesForEmptyBraces: space, no_space Layout/SpaceInsideBlockBraces: @@ -747,7 +790,6 @@ Layout/SpaceInsideBlockBraces: - 'spec/controllers/spree/admin/base_controller_spec.rb' - 'spec/controllers/spree/admin/orders_controller_spec.rb' - 'spec/controllers/spree/admin/search_controller_spec.rb' - - 'spec/factories.rb' - 'spec/features/admin/bulk_order_management_spec.rb' - 'spec/features/admin/customers_spec.rb' - 'spec/features/admin/tag_rules_spec.rb' @@ -777,31 +819,9 @@ Layout/SpaceInsideBlockBraces: - 'spec/spec_helper.rb' - 'spec/support/cancan_helper.rb' -# Offense count: 134 +# Offense count: 786 # Cop supports --auto-correct. -Layout/SpaceInsideBrackets: - Exclude: - - 'app/controllers/admin/order_cycles_controller.rb' - - 'app/helpers/spree/reports_helper.rb' - - 'app/serializers/api/admin/exchange_serializer.rb' - - 'lib/open_food_network/bulk_coop_report.rb' - - 'lib/open_food_network/order_and_distributor_report.rb' - - 'lib/open_food_network/orders_and_fulfillments_report.rb' - - 'lib/open_food_network/packing_report.rb' - - 'lib/open_food_network/payments_report.rb' - - 'lib/open_food_network/users_and_enterprises_report.rb' - - 'spec/controllers/admin/variant_overrides_controller_spec.rb' - - 'spec/controllers/cart_controller_spec.rb' - - 'spec/features/admin/reports_spec.rb' - - 'spec/jobs/update_billable_periods_spec.rb' - - 'spec/lib/open_food_network/order_and_distributor_report_spec.rb' - - 'spec/lib/open_food_network/order_grouper_spec.rb' - - 'spec/lib/open_food_network/users_and_enterprises_report_spec.rb' - - 'spec/performance/orders_controller_spec.rb' - -# Offense count: 784 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles, EnforcedStyleForEmptyBraces, SupportedStylesForEmptyBraces. +# Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBraces. # SupportedStyles: space, no_space, compact # SupportedStylesForEmptyBraces: space, no_space Layout/SpaceInsideHashLiteralBraces: @@ -871,7 +891,6 @@ Layout/SpaceInsideHashLiteralBraces: - 'spec/controllers/spree/user_sessions_controller_spec.rb' - 'spec/controllers/user_passwords_controller_spec.rb' - 'spec/controllers/user_registrations_controller_spec.rb' - - 'spec/factories.rb' - 'spec/features/admin/accounts_and_billing_settings_spec.rb' - 'spec/features/admin/image_settings_spec.rb' - 'spec/features/admin/products_spec.rb' @@ -919,9 +938,19 @@ Layout/SpaceInsideHashLiteralBraces: - 'spec/support/request/shop_workflow.rb' - 'spec/support/seeds.rb' +# Offense count: 4 +# Cop supports --auto-correct. +# Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBrackets. +# SupportedStyles: space, no_space +# SupportedStylesForEmptyBrackets: space, no_space +Layout/SpaceInsideReferenceBrackets: + Exclude: + - 'app/serializers/api/admin/exchange_serializer.rb' + - 'spec/performance/orders_controller_spec.rb' + # Offense count: 10 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. +# Configuration parameters: EnforcedStyle. # SupportedStyles: space, no_space Layout/SpaceInsideStringInterpolation: Exclude: @@ -931,6 +960,7 @@ Layout/SpaceInsideStringInterpolation: # Offense count: 5 # Cop supports --auto-correct. +# Configuration parameters: IndentationWidth. Layout/Tab: Exclude: - 'app/controllers/admin/invoice_settings_controller.rb' @@ -940,6 +970,7 @@ Layout/Tab: # Offense count: 62 # Cop supports --auto-correct. +# Configuration parameters: AllowInHeredoc. Layout/TrailingWhitespace: Exclude: - 'app/models/distributor_shipping_method.rb' @@ -968,34 +999,17 @@ Layout/TrailingWhitespace: - 'spec/support/request/menu_helper.rb' - 'spec/views/json/producers.json.rabl_spec.rb' -# Offense count: 5 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyleAlignWith, SupportedStylesAlignWith. -# SupportedStylesAlignWith: either, start_of_block, start_of_line -Lint/BlockAlignment: - Exclude: - - 'spec/lib/open_food_network/enterprise_fee_calculator_spec.rb' - - 'spec/models/enterprise_spec.rb' - - 'spec/models/spree/line_item_spec.rb' - - 'spec/models/spree/product_spec.rb' - # Offense count: 1 # Cop supports --auto-correct. Lint/DeprecatedClassMethods: Exclude: - 'app/controllers/admin/product_import_controller.rb' -# Offense count: 5 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyleAlignWith, SupportedStylesAlignWith, AutoCorrect. -# SupportedStylesAlignWith: keyword, variable, start_of_line -Lint/EndAlignment: +# Offense count: 4 +Lint/DuplicateMethods: Exclude: - - 'app/controllers/admin/order_cycles_controller.rb' - - 'app/controllers/api/order_cycles_controller.rb' - - 'app/serializers/api/admin/for_order_cycle/enterprise_serializer.rb' - - 'app/serializers/api/admin/for_order_cycle/supplied_product_serializer.rb' - - 'app/serializers/api/admin/order_cycle_serializer.rb' + - 'lib/discourse/single_sign_on.rb' + - 'lib/open_food_network/subscription_summary.rb' # Offense count: 18 Lint/IneffectiveAccessModifier: @@ -1010,7 +1024,7 @@ Lint/IneffectiveAccessModifier: # Offense count: 2 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. +# Configuration parameters: EnforcedStyle. # SupportedStyles: runtime_error, standard_error Lint/InheritException: Exclude: @@ -1018,7 +1032,7 @@ Lint/InheritException: - 'lib/open_food_network/products_renderer.rb' # Offense count: 1 -Lint/LiteralInCondition: +Lint/LiteralAsCondition: Exclude: - 'lib/open_food_network/rack_request_blocker.rb' @@ -1028,6 +1042,7 @@ Lint/NonLocalExitFromIterator: - 'app/models/product_importer.rb' # Offense count: 1 +# Cop supports --auto-correct. Lint/ScriptPermission: Exclude: - 'Rakefile' @@ -1055,7 +1070,7 @@ Lint/UnderscorePrefixedVariableName: Exclude: - 'spec/support/cancan_helper.rb' -# Offense count: 128 +# Offense count: 125 # Cop supports --auto-correct. # Configuration parameters: IgnoreEmptyBlocks, AllowUnusedKeywordArguments. Lint/UnusedBlockArgument: @@ -1081,7 +1096,6 @@ Lint/UnusedBlockArgument: - 'lib/open_food_network/reports/bulk_coop_supplier_report.rb' - 'lib/open_food_network/sales_tax_report.rb' - 'lib/open_food_network/xero_invoices_report.rb' - - 'spec/factories.rb' - 'spec/lib/open_food_network/order_grouper_spec.rb' - 'spec/support/cancan_helper.rb' - 'spec/support/delayed_job_helper.rb' @@ -1120,7 +1134,8 @@ Lint/UselessAccessModifier: - 'lib/open_food_network/reports/bulk_coop_report.rb' - 'spec/lib/open_food_network/reports/report_spec.rb' -# Offense count: 313 +# Offense count: 315 +# Configuration parameters: CheckForMethodsWithNoSideEffects. Lint/Void: Exclude: - 'app/serializers/api/enterprise_serializer.rb' @@ -1136,7 +1151,6 @@ Lint/Void: - 'spec/controllers/spree/api/products_controller_spec.rb' - 'spec/controllers/spree/api/variants_controller_spec.rb' - 'spec/controllers/spree/orders_controller_spec.rb' - - 'spec/controllers/user_passwords_controller_spec.rb' - 'spec/controllers/user_registrations_controller_spec.rb' - 'spec/features/admin/bulk_product_update_spec.rb' - 'spec/features/admin/enterprise_fees_spec.rb' @@ -1183,11 +1197,109 @@ Lint/Void: - 'spec/serializers/enterprise_serializer_spec.rb' - 'spec/support/request/web_helper.rb' -# Offense count: 950 +# Offense count: 945 # Configuration parameters: CountComments, ExcludedMethods. Metrics/BlockLength: Max: 773 +# Offense count: 8 +Naming/AccessorMethodName: + Exclude: + - 'app/models/product_importer.rb' + - 'app/models/spree/adjustment_decorator.rb' + - 'app/models/spree/order_decorator.rb' + - 'spec/support/request/shop_workflow.rb' + - 'spec/support/request/web_helper.rb' + +# Offense count: 1 +Naming/BinaryOperatorParameterName: + Exclude: + - 'app/models/exchange.rb' + +# Offense count: 2 +# Configuration parameters: ExpectMatchingDefinition, Regex, IgnoreExecutableScripts, AllowedAcronyms. +# AllowedAcronyms: CLI, DSL, ACL, API, ASCII, CPU, CSS, DNS, EOF, GUID, HTML, HTTP, HTTPS, ID, IP, JSON, LHS, QPS, RAM, RHS, RPC, SLA, SMTP, SQL, SSH, TCP, TLS, TTL, UDP, UI, UID, UUID, URI, URL, UTF8, VM, XML, XMPP, XSRF, XSS +Naming/FileName: + Exclude: + - 'Gemfile' + - 'Guardfile' + +# Offense count: 1 +# Configuration parameters: Blacklist. +# Blacklist: (?-mix:(^|\s)(EO[A-Z]{1}|END)(\s|$)) +Naming/HeredocDelimiterNaming: + Exclude: + - 'app/models/content_configuration.rb' + +# Offense count: 4 +Naming/MemoizedInstanceVariableName: + Exclude: + - 'app/controllers/spree/admin/payments_controller_decorator.rb' + - 'lib/open_food_network/address_finder.rb' + +# Offense count: 25 +# Configuration parameters: NamePrefix, NamePrefixBlacklist, NameWhitelist, MethodDefinitionMacros. +# NamePrefix: is_, has_, have_ +# NamePrefixBlacklist: is_, has_, have_ +# NameWhitelist: is_a? +# MethodDefinitionMacros: define_method, define_singleton_method +Naming/PredicateName: + Exclude: + - 'spec/**/*' + - 'app/mailers/producer_mailer.rb' + - 'app/models/enterprise.rb' + - 'app/models/enterprise_relationship.rb' + - 'app/models/order_cycle.rb' + - 'app/models/product_importer.rb' + - 'app/models/spreadsheet_entry.rb' + - 'app/models/spree/ability_decorator.rb' + - 'app/models/spree/adjustment_decorator.rb' + - 'app/models/spree/line_item_decorator.rb' + - 'app/models/spree/order_decorator.rb' + - 'app/models/spree/payment_method_decorator.rb' + - 'app/models/spree/preferences/file_configuration.rb' + - 'app/models/spree/product_decorator.rb' + - 'app/models/spree/shipping_method_decorator.rb' + - 'lib/open_food_network/customers_report.rb' + - 'lib/open_food_network/order_cycle_management_report.rb' + - 'lib/open_food_network/order_grouper.rb' + - 'lib/open_food_network/packing_report.rb' + - 'lib/tasks/data.rake' + +# Offense count: 14 +# Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames. +# AllowedNames: io, id, to, by, on, in, at +Naming/UncommunicativeMethodParamName: + Exclude: + - 'app/controllers/spree/orders_controller_decorator.rb' + - 'app/helpers/admin/injection_helper.rb' + - 'app/helpers/spree/admin/base_helper_decorator.rb' + - 'app/helpers/spree/base_helper_decorator.rb' + - 'app/models/exchange.rb' + - 'app/services/subscription_validator.rb' + - 'lib/open_food_network/property_merge.rb' + - 'lib/open_food_network/reports/bulk_coop_report.rb' + - 'lib/open_food_network/xero_invoices_report.rb' + - 'spec/lib/open_food_network/reports/report_spec.rb' + - 'spec/mailers/producer_mailer_spec.rb' + +# Offense count: 4 +# Configuration parameters: EnforcedStyle. +# SupportedStyles: snake_case, camelCase +Naming/VariableName: + Exclude: + - 'app/controllers/spree/admin/reports_controller_decorator.rb' + - 'app/helpers/admin/injection_helper.rb' + +# Offense count: 16 +# Configuration parameters: EnforcedStyle. +# SupportedStyles: snake_case, normalcase, non_integer +Naming/VariableNumber: + Exclude: + - 'spec/archive/features/consumer/checkout_spec.rb' + - 'spec/lib/open_food_network/products_and_inventory_report_spec.rb' + - 'spec/models/calculator/weight_spec.rb' + # Offense count: 1 Performance/Caller: Exclude: @@ -1237,6 +1349,80 @@ Performance/StringReplacement: - 'app/helpers/spree/admin/navigation_helper_decorator.rb' - 'app/models/spree/preferences/file_configuration.rb' +# Offense count: 4 +# Cop supports --auto-correct. +Performance/UnneededSort: + Exclude: + - 'spec/features/admin/order_cycles_spec.rb' + +# Offense count: 203 +# Cop supports --auto-correct. +Rails/ActiveRecordAliases: + Exclude: + - 'app/controllers/admin/bulk_line_items_controller.rb' + - 'app/controllers/admin/enterprises_controller.rb' + - 'app/controllers/admin/order_cycles_controller.rb' + - 'app/controllers/admin/subscriptions_controller.rb' + - 'app/controllers/api/enterprises_controller.rb' + - 'app/controllers/api/product_images_controller.rb' + - 'app/controllers/checkout_controller.rb' + - 'app/controllers/spree/admin/line_items_controller_decorator.rb' + - 'app/controllers/spree/orders_controller_decorator.rb' + - 'app/helpers/i18n_helper.rb' + - 'app/jobs/subscription_placement_job.rb' + - 'app/jobs/update_account_invoices.rb' + - 'app/jobs/update_billable_periods.rb' + - 'app/models/billable_period.rb' + - 'app/models/spree/adjustment_decorator.rb' + - 'app/models/spree/line_item_decorator.rb' + - 'app/models/spree/order_decorator.rb' + - 'app/models/spree/product_set.rb' + - 'app/services/create_mail_method.rb' + - 'app/services/line_item_syncer.rb' + - 'app/services/order_factory.rb' + - 'app/services/order_syncer.rb' + - 'lib/open_food_network/order_cycle_form_applicator.rb' + - 'lib/open_food_network/subscription_payment_updater.rb' + - 'lib/stripe/profile_storer.rb' + - 'spec/controllers/admin/customers_controller_spec.rb' + - 'spec/controllers/admin/proxy_orders_controller_spec.rb' + - 'spec/controllers/admin/subscriptions_controller_spec.rb' + - 'spec/controllers/line_items_controller_spec.rb' + - 'spec/controllers/spree/admin/payment_methods_controller_spec.rb' + - 'spec/controllers/spree/orders_controller_spec.rb' + - 'spec/features/admin/bulk_order_management_spec.rb' + - 'spec/features/admin/order_cycles_spec.rb' + - 'spec/features/admin/orders_spec.rb' + - 'spec/features/admin/subscriptions_spec.rb' + - 'spec/features/admin/variants_spec.rb' + - 'spec/features/consumer/account_spec.rb' + - 'spec/features/consumer/registration_spec.rb' + - 'spec/features/consumer/shopping/cart_spec.rb' + - 'spec/features/consumer/shopping/orders_spec.rb' + - 'spec/features/consumer/shopping/shopping_spec.rb' + - 'spec/jobs/subscription_confirm_job_spec.rb' + - 'spec/jobs/subscription_placement_job_spec.rb' + - 'spec/jobs/update_account_invoices_spec.rb' + - 'spec/jobs/update_billable_periods_spec.rb' + - 'spec/lib/open_food_network/products_cache_refreshment_spec.rb' + - 'spec/lib/open_food_network/products_cache_spec.rb' + - 'spec/models/customer_spec.rb' + - 'spec/models/enterprise_caching_spec.rb' + - 'spec/models/exchange_spec.rb' + - 'spec/models/order_cycle_spec.rb' + - 'spec/models/producer_property_spec.rb' + - 'spec/models/proxy_order_spec.rb' + - 'spec/models/spree/adjustment_spec.rb' + - 'spec/models/spree/line_item_spec.rb' + - 'spec/models/spree/order_spec.rb' + - 'spec/models/spree/product_spec.rb' + - 'spec/models/spree/user_spec.rb' + - 'spec/models/spree/variant_spec.rb' + - 'spec/models/variant_override_spec.rb' + - 'spec/requests/checkout/stripe_connect_spec.rb' + - 'spec/services/order_syncer_spec.rb' + - 'spec/services/subscription_estimator_spec.rb' + # Offense count: 11 # Cop supports --auto-correct. # Configuration parameters: NilOrEmpty, NotPresent, UnlessPresent. @@ -1251,7 +1437,7 @@ Rails/Blank: - 'lib/tasks/data.rake' # Offense count: 3 -# Configuration parameters: EnforcedStyle, SupportedStyles. +# Configuration parameters: EnforcedStyle. # SupportedStyles: strict, flexible Rails/Date: Exclude: @@ -1260,6 +1446,7 @@ Rails/Date: # Offense count: 6 # Cop supports --auto-correct. +# Configuration parameters: EnforceForPrefixed. Rails/Delegate: Exclude: - 'app/models/spree/line_item_decorator.rb' @@ -1300,6 +1487,56 @@ Rails/HasAndBelongsToMany: - 'app/models/spree/line_item_decorator.rb' - 'app/models/spree/payment_method_decorator.rb' +# Offense count: 31 +# Configuration parameters: Include. +# Include: app/models/**/*.rb +Rails/HasManyOrHasOneDependent: + Exclude: + - 'app/models/account_invoice.rb' + - 'app/models/billable_period.rb' + - 'app/models/cart.rb' + - 'app/models/customer.rb' + - 'app/models/enterprise.rb' + - 'app/models/order_cycle.rb' + - 'app/models/spree/address_decorator.rb' + - 'app/models/spree/adjustment_decorator.rb' + - 'app/models/spree/order_decorator.rb' + - 'app/models/spree/payment_method_decorator.rb' + - 'app/models/spree/property_decorator.rb' + - 'app/models/spree/shipping_method_decorator.rb' + - 'app/models/spree/user_decorator.rb' + - 'app/models/spree/variant_decorator.rb' + - 'app/models/subscription.rb' + +# Offense count: 43 +# Cop supports --auto-correct. +# Configuration parameters: EnforcedStyle. +# SupportedStyles: numeric, symbolic +Rails/HttpStatus: + Exclude: + - 'app/controllers/admin/bulk_line_items_controller.rb' + - 'app/controllers/admin/column_preferences_controller.rb' + - 'app/controllers/admin/customers_controller.rb' + - 'app/controllers/admin/enterprise_fees_controller.rb' + - 'app/controllers/admin/enterprise_relationships_controller.rb' + - 'app/controllers/admin/enterprise_roles_controller.rb' + - 'app/controllers/admin/manager_invitations_controller.rb' + - 'app/controllers/admin/tag_rules_controller.rb' + - 'app/controllers/admin/variant_overrides_controller.rb' + - 'app/controllers/api/enterprises_controller.rb' + - 'app/controllers/application_controller.rb' + - 'app/controllers/checkout_controller.rb' + - 'app/controllers/enterprises_controller.rb' + - 'app/controllers/line_items_controller.rb' + - 'app/controllers/shop_controller.rb' + - 'app/controllers/spree/admin/line_items_controller_decorator.rb' + - 'app/controllers/spree/admin/products_controller_decorator.rb' + - 'app/controllers/spree/credit_cards_controller.rb' + - 'app/controllers/spree/orders_controller_decorator.rb' + - 'app/controllers/spree/store_controller_decorator.rb' + - 'app/controllers/stripe/callbacks_controller.rb' + - 'app/controllers/stripe/webhooks_controller.rb' + # Offense count: 11 Rails/OutputSafety: Exclude: @@ -1310,7 +1547,7 @@ Rails/OutputSafety: - 'lib/spree/money_decorator.rb' - 'spec/features/admin/orders_spec.rb' -# Offense count: 6 +# Offense count: 7 # Cop supports --auto-correct. Rails/PluralizationGrammar: Exclude: @@ -1319,6 +1556,12 @@ Rails/PluralizationGrammar: - 'spec/jobs/update_billable_periods_spec.rb' - 'spec/models/order_cycle_spec.rb' +# Offense count: 1 +# Cop supports --auto-correct. +Rails/Presence: + Exclude: + - 'app/serializers/api/admin/customer_serializer.rb' + # Offense count: 5 # Cop supports --auto-correct. # Configuration parameters: NotNilAndNotEmpty, NotBlank, UnlessBlank. @@ -1357,7 +1600,7 @@ Rails/ScopeArgs: - 'app/models/spree/variant_decorator.rb' # Offense count: 18 -# Configuration parameters: EnforcedStyle, SupportedStyles. +# Configuration parameters: EnforcedStyle. # SupportedStyles: strict, flexible Rails/TimeZone: Exclude: @@ -1374,6 +1617,13 @@ Rails/TimeZone: - 'spec/models/enterprise_relationship_spec.rb' - 'spec/models/variant_override_spec.rb' +# Offense count: 1 +# Configuration parameters: Environments. +# Environments: development, test, production +Rails/UnknownEnv: + Exclude: + - 'lib/open_food_network/cached_products_renderer.rb' + # Offense count: 21 # Cop supports --auto-correct. # Configuration parameters: Include. @@ -1391,18 +1641,9 @@ Rails/Validation: - 'app/models/spree/variant_decorator.rb' - 'app/models/variant_override.rb' -# Offense count: 8 -Style/AccessorMethodName: - Exclude: - - 'app/models/product_importer.rb' - - 'app/models/spree/adjustment_decorator.rb' - - 'app/models/spree/order_decorator.rb' - - 'spec/support/request/shop_workflow.rb' - - 'spec/support/request/web_helper.rb' - # Offense count: 35 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. +# Configuration parameters: EnforcedStyle. # SupportedStyles: always, conditionals Style/AndOr: Exclude: @@ -1423,16 +1664,16 @@ Style/AndOr: # Offense count: 2 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. +# Configuration parameters: EnforcedStyle. # SupportedStyles: percent_q, bare_percent Style/BarePercentLiterals: Exclude: - 'spec/features/admin/variants_spec.rb' - 'spec/support/request/web_helper.rb' -# Offense count: 209 +# Offense count: 210 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. +# Configuration parameters: EnforcedStyle. # SupportedStyles: braces, no_braces, context_dependent Style/BracesAroundHashParameters: Exclude: @@ -1480,7 +1721,6 @@ Style/BracesAroundHashParameters: - 'spec/controllers/spree/api/variants_controller_spec.rb' - 'spec/controllers/spree/orders_controller_spec.rb' - 'spec/controllers/user_confirmations_controller_spec.rb' - - 'spec/factories.rb' - 'spec/features/admin/accounts_and_billing_settings_spec.rb' - 'spec/features/admin/business_model_configuration_spec.rb' - 'spec/features/admin/order_cycles_spec.rb' @@ -1514,7 +1754,8 @@ Style/CaseEquality: - 'spec/models/spree/payment_spec.rb' # Offense count: 87 -# Configuration parameters: EnforcedStyle, SupportedStyles. +# Cop supports --auto-correct. +# Configuration parameters: AutoCorrect, EnforcedStyle. # SupportedStyles: nested, compact Style/ClassAndModuleChildren: Exclude: @@ -1601,7 +1842,7 @@ Style/ClassAndModuleChildren: # Offense count: 3 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. +# Configuration parameters: EnforcedStyle. # SupportedStyles: is_a?, kind_of? Style/ClassCheck: Exclude: @@ -1624,7 +1865,7 @@ Style/ColonMethodCall: # Offense count: 12 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles, SingleLineConditionsOnly, IncludeTernaryExpressions. +# Configuration parameters: EnforcedStyle, SingleLineConditionsOnly, IncludeTernaryExpressions. # SupportedStyles: assign_to_condition, assign_inside_condition Style/ConditionalAssignment: Exclude: @@ -1639,6 +1880,11 @@ Style/ConditionalAssignment: - 'app/models/spree/payment_decorator.rb' - 'spec/lib/open_food_network/order_grouper_spec.rb' +# Offense count: 2 +Style/DateTime: + Exclude: + - 'lib/open_food_network/users_and_enterprises_report.rb' + # Offense count: 5 # Cop supports --auto-correct. Style/EachWithObject: @@ -1650,7 +1896,7 @@ Style/EachWithObject: # Offense count: 1 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. +# Configuration parameters: EnforcedStyle. # SupportedStyles: empty, nil, both Style/EmptyElse: Exclude: @@ -1665,7 +1911,7 @@ Style/EmptyLiteral: # Offense count: 6 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. +# Configuration parameters: EnforcedStyle. # SupportedStyles: compact, expanded Style/EmptyMethod: Exclude: @@ -1677,18 +1923,20 @@ Style/EmptyMethod: - 'app/controllers/spree/admin/products_controller_decorator.rb' # Offense count: 2 -# Configuration parameters: ExpectMatchingDefinition, Regex, IgnoreExecutableScripts, AllowedAcronyms. -# AllowedAcronyms: CLI, DSL, ACL, API, ASCII, CPU, CSS, DNS, EOF, GUID, HTML, HTTP, HTTPS, ID, IP, JSON, LHS, QPS, RAM, RHS, RPC, SLA, SMTP, SQL, SSH, TCP, TLS, TTL, UDP, UI, UID, UUID, URI, URL, UTF8, VM, XML, XMPP, XSRF, XSS -Style/FileName: +# Cop supports --auto-correct. +Style/ExpandPathArguments: Exclude: - - 'Gemfile' - - 'Guardfile' + - 'spec/features/admin/products_spec.rb' + - 'spec/performance/shop_controller_spec.rb' -# Offense count: 1 -# Configuration parameters: SupportedStyles. -# SupportedStyles: annotated, template +# Offense count: 5 +# Configuration parameters: EnforcedStyle. +# SupportedStyles: annotated, template, unannotated Style/FormatStringToken: - EnforcedStyle: template + Exclude: + - 'app/helpers/order_cycles_helper.rb' + - 'lib/open_food_network/sales_tax_report.rb' + - 'spec/models/enterprise_spec.rb' # Offense count: 88 # Configuration parameters: MinBodyLength. @@ -1741,9 +1989,9 @@ Style/GuardClause: - 'spec/support/request/distribution_helper.rb' - 'spec/support/request/shop_workflow.rb' -# Offense count: 1109 +# Offense count: 1040 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles, UseHashRocketsWithSymbolValues, PreferHashRocketsForNonAlnumEndingSymbols. +# Configuration parameters: EnforcedStyle, UseHashRocketsWithSymbolValues, PreferHashRocketsForNonAlnumEndingSymbols. # SupportedStyles: ruby19, hash_rockets, no_mixed_keys, ruby19_no_mixed_keys Style/HashSyntax: Exclude: @@ -1856,7 +2104,6 @@ Style/HashSyntax: - 'spec/controllers/spree/orders_controller_spec.rb' - 'spec/controllers/spree/user_sessions_controller_spec.rb' - 'spec/controllers/user_registrations_controller_spec.rb' - - 'spec/factories.rb' - 'spec/features/admin/bulk_order_management_spec.rb' - 'spec/features/admin/bulk_product_update_spec.rb' - 'spec/features/admin/customers_spec.rb' @@ -1935,6 +2182,7 @@ Style/LineEndConcatenation: # Offense count: 11 # Cop supports --auto-correct. +# Configuration parameters: IgnoredMethods. Style/MethodCallWithoutArgsParentheses: Exclude: - 'app/controllers/spree/admin/payment_methods_controller_decorator.rb' @@ -1947,7 +2195,7 @@ Style/MethodCallWithoutArgsParentheses: # Offense count: 14 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. +# Configuration parameters: EnforcedStyle. # SupportedStyles: require_parentheses, require_no_parentheses, require_no_parentheses_except_multiline Style/MethodDefParentheses: Exclude: @@ -1968,6 +2216,16 @@ Style/MethodMissing: Exclude: - 'app/helpers/application_helper.rb' +# Offense count: 6 +Style/MixinUsage: + Exclude: + - 'lib/open_food_network/orders_and_fulfillments_report.rb' + - 'spec/features/admin/orders_spec.rb' + - 'spec/lib/open_food_network/bulk_coop_report_spec.rb' + - 'spec/lib/open_food_network/order_cycle_management_report_spec.rb' + - 'spec/lib/open_food_network/orders_and_fulfillments_report_spec.rb' + - 'spec/lib/open_food_network/packing_report_spec.rb' + # Offense count: 4 # Cop supports --auto-correct. Style/MultilineIfModifier: @@ -1985,6 +2243,8 @@ Style/MutableConstant: # Offense count: 7 # Cop supports --auto-correct. +# Configuration parameters: Whitelist. +# Whitelist: be, be_a, be_an, be_between, be_falsey, be_kind_of, be_instance_of, be_truthy, be_within, eq, eql, end_with, include, match, raise_error, respond_to, start_with Style/NestedParenthesizedCalls: Exclude: - 'app/controllers/admin/enterprises_controller.rb' @@ -2002,7 +2262,7 @@ Style/NestedTernaryOperator: # Offense count: 3 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, MinBodyLength, SupportedStyles. +# Configuration parameters: EnforcedStyle, MinBodyLength. # SupportedStyles: skip_modifier_ifs, always Style/Next: Exclude: @@ -2029,13 +2289,13 @@ Style/Not: # Offense count: 16 # Cop supports --auto-correct. -# Configuration parameters: EnforcedOctalStyle, SupportedOctalStyles. +# Configuration parameters: EnforcedOctalStyle. # SupportedOctalStyles: zero_with_o, zero_only Style/NumericLiteralPrefix: Exclude: - 'spec/features/admin/order_cycles_spec.rb' -# Offense count: 15 +# Offense count: 12 # Cop supports --auto-correct. # Configuration parameters: Strict. Style/NumericLiterals: @@ -2043,7 +2303,7 @@ Style/NumericLiterals: # Offense count: 14 # Cop supports --auto-correct. -# Configuration parameters: AutoCorrect, EnforcedStyle, SupportedStyles. +# Configuration parameters: AutoCorrect, EnforcedStyle. # SupportedStyles: predicate, comparison Style/NumericPredicate: Exclude: @@ -2065,11 +2325,6 @@ Style/OneLineConditional: Exclude: - 'app/controllers/spree/admin/orders_controller_decorator.rb' -# Offense count: 1 -Style/OpMethod: - Exclude: - - 'app/models/exchange.rb' - # Offense count: 1 # Cop supports --auto-correct. # Configuration parameters: AllowSafeAssignment. @@ -2077,37 +2332,9 @@ Style/ParenthesesAroundCondition: Exclude: - 'app/controllers/checkout_controller.rb' -# Offense count: 25 -# Configuration parameters: NamePrefix, NamePrefixBlacklist, NameWhitelist. -# NamePrefix: is_, has_, have_ -# NamePrefixBlacklist: is_, has_, have_ -# NameWhitelist: is_a? -Style/PredicateName: - Exclude: - - 'spec/**/*' - - 'app/mailers/producer_mailer.rb' - - 'app/models/enterprise.rb' - - 'app/models/enterprise_relationship.rb' - - 'app/models/order_cycle.rb' - - 'app/models/product_importer.rb' - - 'app/models/spreadsheet_entry.rb' - - 'app/models/spree/ability_decorator.rb' - - 'app/models/spree/adjustment_decorator.rb' - - 'app/models/spree/line_item_decorator.rb' - - 'app/models/spree/order_decorator.rb' - - 'app/models/spree/payment_method_decorator.rb' - - 'app/models/spree/preferences/file_configuration.rb' - - 'app/models/spree/product_decorator.rb' - - 'app/models/spree/shipping_method_decorator.rb' - - 'lib/open_food_network/customers_report.rb' - - 'lib/open_food_network/order_cycle_management_report.rb' - - 'lib/open_food_network/order_grouper.rb' - - 'lib/open_food_network/packing_report.rb' - - 'lib/tasks/data.rake' - # Offense count: 4 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. +# Configuration parameters: EnforcedStyle. # SupportedStyles: short, verbose Style/PreferredHashMethods: Exclude: @@ -2126,7 +2353,7 @@ Style/Proc: # Offense count: 5 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. +# Configuration parameters: EnforcedStyle. # SupportedStyles: compact, exploded Style/RaiseArgs: Exclude: @@ -2158,7 +2385,7 @@ Style/RedundantParentheses: - 'spec/controllers/admin/enterprises_controller_spec.rb' - 'spec/features/admin/bulk_product_update_spec.rb' -# Offense count: 12 +# Offense count: 13 # Cop supports --auto-correct. # Configuration parameters: AllowMultipleReturnValues. Style/RedundantReturn: @@ -2166,6 +2393,7 @@ Style/RedundantReturn: - 'app/controllers/admin/enterprise_fees_controller.rb' - 'app/controllers/admin/enterprises_controller.rb' - 'app/controllers/admin/product_import_controller.rb' + - 'app/controllers/spree/credit_cards_controller.rb' - 'app/models/enterprise_fee.rb' - 'app/models/spree/adjustment_decorator.rb' - 'app/models/spree/classification_decorator.rb' @@ -2209,7 +2437,7 @@ Style/RedundantSelf: # Offense count: 13 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles, AllowInnerSlashes. +# Configuration parameters: EnforcedStyle, AllowInnerSlashes. # SupportedStyles: slashes, percent_r, mixed Style/RegexpLiteral: Exclude: @@ -2234,7 +2462,7 @@ Style/RescueModifier: # Offense count: 5 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. +# Configuration parameters: EnforcedStyle. # SupportedStyles: require_parentheses, require_no_parentheses Style/StabbyLambdaParentheses: Exclude: @@ -2243,7 +2471,7 @@ Style/StabbyLambdaParentheses: # Offense count: 14 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. +# Configuration parameters: EnforcedStyle. # SupportedStyles: single_quotes, double_quotes Style/StringLiteralsInInterpolation: Exclude: @@ -2293,7 +2521,7 @@ Style/SymbolProc: # Offense count: 5 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles, AllowSafeAssignment. +# Configuration parameters: EnforcedStyle, AllowSafeAssignment. # SupportedStyles: require_parentheses, require_no_parentheses, require_parentheses_when_complex Style/TernaryParentheses: Exclude: @@ -2370,19 +2598,8 @@ Style/UnneededPercentQ: - 'spec/features/consumer/producers_spec.rb' - 'spec/support/request/web_helper.rb' -# Offense count: 4 -# Configuration parameters: EnforcedStyle, SupportedStyles. -# SupportedStyles: snake_case, camelCase -Style/VariableName: - Exclude: - - 'app/controllers/spree/admin/reports_controller_decorator.rb' - - 'app/helpers/admin/injection_helper.rb' - -# Offense count: 16 -# Configuration parameters: EnforcedStyle, SupportedStyles. -# SupportedStyles: snake_case, normalcase, non_integer -Style/VariableNumber: - Exclude: - - 'spec/archive/features/consumer/checkout_spec.rb' - - 'spec/lib/open_food_network/products_and_inventory_report_spec.rb' - - 'spec/models/calculator/weight_spec.rb' +# Offense count: 6392 +# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns. +# URISchemes: http, https +Metrics/LineLength: + Max: 623 diff --git a/Gemfile.lock b/Gemfile.lock index d12583b8f4..442d477f35 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -184,7 +184,7 @@ GEM angularjs-file-upload-rails (1.1.6) angularjs-rails (1.5.5) arel (3.0.3) - ast (2.3.0) + ast (2.4.0) atomic (1.1.99) awesome_nested_set (2.1.5) activerecord (>= 3.0.0) @@ -512,8 +512,8 @@ GEM parallel (1.11.2) parallel_tests (2.14.1) parallel - parser (2.4.0.0) - ast (~> 2.2) + parser (2.5.1.0) + ast (~> 2.4.0) paypal-sdk-core (0.2.10) multi_json (~> 1.0) xml-simple @@ -565,8 +565,7 @@ GEM rake (>= 0.8.7) rdoc (~> 3.4) thor (>= 0.14.6, < 2.0) - rainbow (2.2.2) - rake + rainbow (3.0.0) raindrops (0.13.0) rake (10.5.0) ransack (0.7.2) @@ -620,11 +619,11 @@ GEM rspec-retry (0.5.6) rspec-core (> 3.3, < 3.8) rspec-support (3.7.0) - rubocop (0.49.1) + rubocop (0.55.0) parallel (~> 1.10) - parser (>= 2.3.3.1, < 3.0) + parser (>= 2.5) powerpack (~> 0.1) - rainbow (>= 1.99.1, < 3.0) + rainbow (>= 2.2.2, < 4.0) ruby-progressbar (~> 1.7) unicode-display_width (~> 1.0, >= 1.0.1) ruby-progressbar (1.8.1) @@ -670,7 +669,7 @@ GEM uglifier (2.7.1) execjs (>= 0.3.0) json (>= 1.8.0) - unicode-display_width (1.3.0) + unicode-display_width (1.3.2) unicorn (4.9.0) kgio (~> 2.6) rack From 21b96c63afed1353f366fe956aa93e07043f1c05 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Tue, 8 May 2018 14:52:13 +1000 Subject: [PATCH 030/206] Update cop naming --- app/models/spree/credit_card_decorator.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/spree/credit_card_decorator.rb b/app/models/spree/credit_card_decorator.rb index b9c0fca337..fd4dbc9261 100644 --- a/app/models/spree/credit_card_decorator.rb +++ b/app/models/spree/credit_card_decorator.rb @@ -18,7 +18,7 @@ Spree::CreditCard.class_eval do # 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 From fcb9d1411fcd7bc2109bd156d7573116f132412b Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Tue, 8 May 2018 15:19:44 +1000 Subject: [PATCH 031/206] Exclude file that rubocop fails to parse --- .rubocop.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.rubocop.yml b/.rubocop.yml index 3edb24aad7..25acf35f57 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -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 From 8a3244513fd7290a93ac503295ea368bc225bfbd Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Tue, 8 May 2018 15:29:04 +1000 Subject: [PATCH 032/206] Run Codeclimate with new rubocop --- .codeclimate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.codeclimate.yml b/.codeclimate.yml index 4e8512dd15..14569f6b0f 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -2,7 +2,7 @@ version: "2" plugins: rubocop: enabled: true - channel: "rubocop-0-48" + channel: "rubocop-0-55" scss-lint: enabled: false duplication: From 48217a54b9210b0a8d91382bee73cfa894810e6f Mon Sep 17 00:00:00 2001 From: Transifex-Openfoodnetwork Date: Wed, 9 May 2018 22:58:59 +1000 Subject: [PATCH 033/206] Updating translations for config/locales/es.yml --- config/locales/es.yml | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/config/locales/es.yml b/config/locales/es.yml index 7d6e146b7a..1088789400 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -365,7 +365,13 @@ es: tax_category: Categoría de impuestos inherits_properties?: ¿Hereda propiedades? available_on: Disponible en + av_on: "Av. En" upload_an_image: Subir una imagen + product_search_keywords: Palabras clave de búsqueda de productos + product_search_tip: Escriba palabras para ayudar a buscar sus productos en las tiendas. Use espacio para separar cada palabra clave. + SEO_keywords: Palabras clave de SEO + seo_tip: Escriba palabras para ayudar a buscar sus productos en la web. Use espacio para separar cada palabra clave. + Search: Buscar properties: property_name: Nombre de la Propiedad inherited_property: Propiedad Heredada @@ -373,7 +379,6 @@ es: to_order_tip: "Los artículos hechos según demanda no tienen un nivel de stock, como por ejemplo panes hechos según demanda." product_distributions: "Distribuciones de productos" group_buy_options: "Opciones de compra grupales" - seo: "SEO" back_to_products_list: "Volver a la lista de productos" variant_overrides: loading_flash: @@ -1131,7 +1136,6 @@ es: email_admin_html: "Puede administrar su cuenta iniciando sesión en %{link} o haciendo clic en el engrane arriba a la derecha de la página de inicio, y seleccionando Administración." email_community_html: "También tenemos un foro en líea para la discusión comunal relacionada con el programa OFN y los retos únicos del funcionamiento de una organización de alimentación. Lo invitamos a unirse. Estamos evolucionando de forma constante y su aporte en este formo le dará forma a lo que pase luego. %{link}" join_community: "Unirse a la comunidad" - email_help: "Si tiene dificultades, revise nuestras preguntas frecuentes, navegue el foro o haga una entrada de con tema de 'Soporte' y ¡alguien le ayudará!" email_confirmation_activate_account: "Antes de que podamos activar su nueva cuenta, necesitamos confirmar su dirección de correo electrónico." email_confirmation_greeting: "Hola, %{contact}!" email_confirmation_profile_created: "¡Se creó un un perfil para %{name} con éxito! Para activar su Perfil necesitamos que confirme esta dirección de correos." @@ -1193,9 +1197,8 @@ es: invite_email: greeting: "¡Hola!" invited_to_manage: "Ha sido invitado a administrar %{enterprise} en %{instance}." - confirm_your_email: "En breve recibirá un correo electrónico para confirmar su registro." set_a_password: "Luego se le pedirá que establezca una contraseña antes de poder administrar la organización." - mistakenly_sent: "¿No está seguro de por qué ha recibido este correo electrónico? Por favor, póngase en contacto con %{owner_email} para obtener más información, o puede ponerse en contacto con %{instance} en %{instance_email}." + mistakenly_sent: "¿No está seguro de por qué ha recibido este correo electrónico? Por favor, póngase en contacto con %{owner_email} para más información." producer_mail_greeting: "Estimada" producer_mail_text_before: "Ahora tenemos todas los pedidos de las consumidoras para la siguiente ronda." producer_mail_order_text: "Se muestra un resumen de los pedidos de tus productos:" @@ -2035,7 +2038,12 @@ es: order_cycles_no_permission_to_create_error: "No tienes permiso para crear un ciclo de pedido coordinado por esta empresa." back_to_orders_list: "Volver a la lista de pedidos" order_information: "información del pedido" + date_completed: "Fecha de finalización" amount: "Cantidad" + state_names: + ready: Listo + pending: Pendiente + shipped: Enviado js: saving: 'Guardando...' changes_saved: 'Cambios guardados.' @@ -2057,6 +2065,9 @@ es: enterprise_limit_reached: "Has alcanzado el límite estándar de organizaciones por cuenta. Escriba a %{contact_email} si necesita aumentarlo." modals: got_it: Lo entiendo + close: "Cerrar" + invite: "Invitar" + invite_title: "Invitar a un usuario no registrado" tag_rule_help: title: Reglas de las Etiquetas overview: Visión general @@ -2235,6 +2246,8 @@ es: email: Email account_updated: "Cuenta actualizada!" my_account: "Mi cuenta" + date: "Fecha" + time: "Hora" admin: orders: invoice: From 7caf166768ebab95d8d44b223c8b84bfdc009ebe Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 10 May 2018 09:42:34 +1000 Subject: [PATCH 034/206] Updates pulled from Transifex --- config/locales/de_DE.yml | 20 +++- config/locales/en_GB.yml | 3 +- config/locales/en_US.yml | 4 + config/locales/es.yml | 17 +++ config/locales/fr.yml | 28 ++++- config/locales/it.yml | 4 +- config/locales/nb.yml | 4 +- config/locales/pt.yml | 227 +++++++++++++++++++++------------------ config/locales/sv.yml | 3 +- 9 files changed, 189 insertions(+), 121 deletions(-) diff --git a/config/locales/de_DE.yml b/config/locales/de_DE.yml index 27b3bf40f4..299c4fe89a 100644 --- a/config/locales/de_DE.yml +++ b/config/locales/de_DE.yml @@ -367,6 +367,11 @@ de_DE: available_on: Verfügbar auf av_on: "Ein V. Auf" upload_an_image: Lade ein Bild hoch + product_search_keywords: Keywords für die Produktsuche + product_search_tip: Geben Sie Wörter ein, um Ihre Produkte in den Geschäften zu durchsuchen. Verwenden Sie Leerzeichen, um jedes Keyword zu trennen. + SEO_keywords: SEO Schlüsselwörter + seo_tip: Geben Sie Wörter ein, um Ihre Produkte im Internet zu durchsuchen. Verwenden Sie Leerzeichen, um jedes Keyword zu trennen. + Search: Suche properties: property_name: Name des Anwesens inherited_property: Vererbte Eigenschaft @@ -374,7 +379,6 @@ de_DE: to_order_tip: "Artikel, die auf Bestellung hergestellt werden, haben keinen festgelegten Lagerbestand, wie zum Beispiel frisch gebackene Brote." product_distributions: "Produktverteilungen" group_buy_options: "Gruppenkaufoptionen" - seo: "SEO" back_to_products_list: "Zurück zur Produktliste" variant_overrides: loading_flash: @@ -1132,7 +1136,6 @@ de_DE: email_admin_html: "Sie können Ihr Konto verwalten, indem Sie sich bei %{link} anmelden oder indem Sie oben rechts auf der Startseite auf das Zahnrad klicken und Administration auswählen." email_community_html: "Wir haben auch ein Online-Forum für Community-Diskussionen in Bezug auf OFN-Software und die einzigartigen Herausforderungen eines Lebensmittelunternehmens. Sie werden ermutigt mitzumachen. Wir entwickeln uns ständig weiter und Ihr Beitrag in diesem Forum wird prägen, was als nächstes passiert. %{link}" join_community: "Trete der Community bei" - email_help: "Wenn Sie irgendwelche Schwierigkeiten haben, lesen Sie unsere FAQs, durchsuchen Sie das Forum oder posten Sie ein \"Support\" -Thema und jemand wird Ihnen helfen!" email_confirmation_activate_account: "Bevor wir Ihr neues Konto aktivieren können, müssen wir Ihre E-Mail-Adresse bestätigen." email_confirmation_greeting: "Hallo, %{contact}!" email_confirmation_profile_created: "Ein Profil für %{name} wurde erfolgreich erstellt! Um Ihr Profil zu aktivieren, müssen wir diese E-Mail-Adresse bestätigen." @@ -1194,9 +1197,7 @@ de_DE: invite_email: greeting: "Hallo!" invited_to_manage: "Sie wurden eingeladen, %{enterprise} auf %{instance} zu verwalten." - confirm_your_email: "Sie werden in Kürze eine E-Mail erhalten, um Ihre Registrierung zu bestätigen." set_a_password: "Sie werden dann aufgefordert, ein Kennwort festzulegen, bevor Sie das Unternehmen verwalten können." - mistakenly_sent: "Nicht sicher, warum Sie diese E-Mail erhalten haben? Bitte kontaktieren Sie %{owner_email} für weitere Informationen, oder kontaktieren Sie %{instance} unter %{instance_email}." producer_mail_greeting: "Liebe/r" producer_mail_text_before: "Wir haben jetzt alle Verbraucherbestellungen für den nächsten Essenstropfen." producer_mail_order_text: "Hier finden Sie eine Zusammenfassung der Bestellungen für Ihre Produkte:" @@ -2035,7 +2036,13 @@ de_DE: order_cycles_no_permission_to_coordinate_error: "Keines Ihrer Unternehmen ist berechtigt, einen Bestellzyklus zu koordinieren" order_cycles_no_permission_to_create_error: "Sie sind nicht berechtigt, einen von diesem Unternehmen koordinierten Bestellzyklus zu erstellen" back_to_orders_list: "Zurück zur Bestellliste" + order_information: "Bestellinformationen" + date_completed: "Datum abgeschlossen" amount: "Menge" + state_names: + ready: Bereit + pending: steht aus + shipped: Wird versendet js: saving: 'Speichern ...' changes_saved: 'Änderungen gespeichert' @@ -2057,6 +2064,9 @@ de_DE: enterprise_limit_reached: "Sie haben die Standardgrenze für Unternehmen pro Konto erreicht. Schreiben Sie an %{contact_email}, wenn Sie es erhöhen müssen." modals: got_it: Ich habs + close: "Schließen" + invite: "Einladen" + invite_title: "Laden Sie einen nicht registrierten Benutzer ein" tag_rule_help: title: Tag-Regeln overview: Überblick @@ -2237,6 +2247,8 @@ de_DE: email: Email account_updated: "Konto aktualisiert!" my_account: "Mein Konto" + date: "Datum" + time: "Zeit" admin: orders: invoice: diff --git a/config/locales/en_GB.yml b/config/locales/en_GB.yml index 15bb74b37f..e684fd9b03 100644 --- a/config/locales/en_GB.yml +++ b/config/locales/en_GB.yml @@ -264,6 +264,7 @@ en_GB: manages: manages products: unit_name_placeholder: 'eg. bunches' + Search: Search properties: property_name: Property Name inherited_property: Inherited Property @@ -923,7 +924,6 @@ en_GB: email_admin_html: "You can manage your account by logging into the %{link} or by clicking on the cog in the top right hand side of the homepage, and selecting Administration." email_community_html: "We also have an online forum for community discussion related to OFN software and the unique challenges of running a food enterprise. You are encouraged to join in. We are constantly evolving and your input into this forum will shape what happens next. %{link}" join_community: "Join the community" - email_help: "If you have any difficulties, check out our FAQs, browse the forum or post a 'Support' topic and someone will help you out!" email_confirmation_greeting: "Hi, %{contact}!" email_confirmation_profile_created: "A profile for %{name} has been successfully created! To activate your Profile we need to confirm this email address." email_confirmation_click_link: "Please click the link below to confirm your email and to continue setting up your profile." @@ -1734,7 +1734,6 @@ en_GB: products_unsaved: "Changes to %{n} products remain unsaved." is_already_manager: "is already a manager!" no_change_to_save: "No change to save" - add_manager: "Add a manager" users: "Users" about: "About" images: "Images" diff --git a/config/locales/en_US.yml b/config/locales/en_US.yml index 65d0155eb2..a58d9a3ae0 100644 --- a/config/locales/en_US.yml +++ b/config/locales/en_US.yml @@ -849,10 +849,13 @@ en_US: allowed_payment_method_types_tip: Only Cash and Stripe payment methods may be used at the moment credit_card: Credit Card no_cards_available: No cards available + loading_flash: + loading: LOADING SUBSCRIPTIONS review: details: Details address: Address products: Products + product_already_in_order: This product has already been added to the order. Please edit the quantity directly. orders: number: Number confirm_edit: Are you sure you want to edit this order? Doing so may make it more difficult to automatically sync changes to the subscription in the future. @@ -2369,6 +2372,7 @@ en_US: subscription_state: active: active pending: pending + ended: ended paused: paused canceled: cancelled payment_states: diff --git a/config/locales/es.yml b/config/locales/es.yml index 1088789400..d203a617ea 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -10,6 +10,8 @@ es: email: E-mail del consumidor spree/payment: amount: Cantidad + order_cycle: + orders_close_at: Fecha de cierre errors: models: spree/user: @@ -18,6 +20,10 @@ es: taken: "Ya existe una cuenta con este email. Inicie sesión o restablezca tu contraseña." spree/order: no_card: No hay tarjetas de crédito válidas disponibles + order_cycle: + attributes: + orders_close_at: + after_orders_open_at: debe ser después de la fecha de apertura activemodel: errors: models: @@ -728,6 +734,8 @@ es: destroy_errors: orders_present: Ese ciclo de pedido ha sido seleccionado por un cliente y no puede ser eliminado. Para evitar que los clientes accedan a él, ciérrelo. schedule_present: Ese ciclo de pedido está vinculado a un horario y no puede ser eliminado. Desvincula o elimina el calendario primero. + bulk_update: + no_data: Hm, algo salió mal. No se encontraron datos de ciclo de pedido. producer_properties: index: title: Propiedades de la Productora @@ -869,6 +877,9 @@ es: no_subscriptions: Aún no hay suscripciones ... why_dont_you_add_one: ¿Por qué no agregas una? :) no_matching_subscriptions: No se encontraron suscripciones coincidentes + schedules: + destroy: + associated_subscriptions_error: Este horario no se puede eliminar porque tiene suscripciones asociadas stripe_connect_settings: edit: title: "Stripe Connect" @@ -1197,6 +1208,7 @@ es: invite_email: greeting: "¡Hola!" invited_to_manage: "Ha sido invitado a administrar %{enterprise} en %{instance}." + confirm_your_email: "Debería haber recibido o recibirá pronto un correo electrónico con un enlace de confirmación. No podrá acceder al perfil de %{enterprise} hasta que haya confirmado su correo electrónico." set_a_password: "Luego se le pedirá que establezca una contraseña antes de poder administrar la organización." mistakenly_sent: "¿No está seguro de por qué ha recibido este correo electrónico? Por favor, póngase en contacto con %{owner_email} para más información." producer_mail_greeting: "Estimada" @@ -1447,6 +1459,7 @@ es: november: "Noviembre" december: "Diciembre" email_not_found: "Dirección de correo electrónico no encontrada" + email_unconfirmed: "Debe confirmar su dirección de correo electrónico antes de poder restablecer su contraseña." email_required: "Debe brindar una dirección de correo electrónico" logging_in: "Espere un momento, le vamos a iniciar una sesión" signup_email: "Tu correo electrónico" @@ -1698,6 +1711,10 @@ es: calculator: "Calculadora" calculator_values: "Calculadora de valores" flat_percent_per_item: "Porcentaje fijo (por artículo)" + flat_rate_per_item: "Tarifa plana (por artículo)" + flat_rate_per_order: "Tarifa plana (por pedido)" + flexible_rate: "Tarifa flexible" + price_sack: "Precio saco" new_order_cycles: "Nuevos Ciclos de Pedidos" new_order_cycle: "Nuevo Ciclo de Pedido" select_a_coordinator_for_your_order_cycle: "Selecciona un coordinador para vuestro ciclo de pedido" diff --git a/config/locales/fr.yml b/config/locales/fr.yml index c054a485c1..be35f826cd 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -10,6 +10,8 @@ fr: email: Email acheteur spree/payment: amount: Montant + order_cycle: + orders_close_at: Date de fermeture errors: models: spree/user: @@ -18,6 +20,10 @@ fr: taken: "Un compte existe déjà pour cet e-mail. Connectez-vous ou demandez un nouveau mot de passe." spree/order: no_card: Aucune carte de crédit valide trouvée + order_cycle: + attributes: + orders_close_at: + after_orders_open_at: doit être postérieure à Date d'ouverture activemodel: errors: models: @@ -101,7 +107,7 @@ fr: title: Autre échec (%{count} commandes) explainer: Le traitement automatique de ces commandes a échoué pour une raison inconnue. Cela n'aurait pas dû arriver, veuillez nous contacter si vous constatez quelque chose d'anormal. home: "OFF" - title: Open Food Network France + title: Open Food France welcome_to: 'Bienvenue sur ' site_meta_description: "Tout commence dans le sol. Avec ces paysans, agriculteurs, producteurs, engagés pour une agriculture durable et régénératrice, et désireux de partager leur histoire et leur passion avec fierté. Avec ces distributeurs souhaitant reconnecter les individus à leurs aliments et aux gens qui les produisent, soutenir les prises de conscience, dans une démarche de transparence, d'honnêteté, en assurant une juste rémunération des producteurs. Avec ces acheteurs qui croient que de meilleures décisions d'achats peuvent ..." search_by_name: Recherche par nom ou département... @@ -368,6 +374,9 @@ fr: av_on: "Disp. via" upload_an_image: Importer une image product_search_keywords: Mots-clés de recherche produits + product_search_tip: Saisissez des mots qui peuvent simplifier la recherche de vo produits dans les boutiques. Laissez un espace entre chaque mot-clé. + SEO_keywords: Mot-clés de référencement web + seo_tip: Saisissez des mots qui peuvent simplifier la recherche de vos produits sur le web. Laissez un espace entre chaque mot-clé. Search: Rechercher properties: property_name: Nom du label @@ -724,6 +733,8 @@ fr: destroy_errors: orders_present: Ce cycle de vente a déjà été utilisé par un acheteur et ne peut être supprimé. Pour empêcher aux acheteurs d'y accéder, veuillez plutôt le fermer. schedule_present: Ce cycle de vente est lié à un rythme d'abonnement et ne peut pas être supprimé. Veuillez d'abord supprimer ce lien ou supprimer le rythme d'abonnement. + bulk_update: + no_data: Une erreur s'est produite. Aucune donnée trouvée. producer_properties: index: title: Propriétés / labels du producteur @@ -816,12 +827,12 @@ fr: cancel_subscription: Annuler Abonnement setup_explanation: just_a_few_more_steps: 'Encore quelques étapes avant de pouvoir commencer:' - enable_subscriptions: "Activer la fonction abonnements pour au moins une de vos boutiques" + enable_subscriptions: "Activez la fonction abonnements pour au moins une de vos boutiques" enable_subscriptions_step_1_html: 1. Allez à %{enterprises_link}, trouvez votre boutique, et cliquez sur "Gérer" enable_subscriptions_step_2: 2. Sous "Préférences boutiques", activez la fonction Abonnements - set_up_shipping_and_payment_methods_html: Paramétrez des méthodes de %{shipping_link} et %{payment_link} + set_up_shipping_and_payment_methods_html: Paramétrez au moins une méthode d'%{shipping_link} et une méthode de %{payment_link} set_up_shipping_and_payment_methods_note_html: Notez bien que seules des méthodes de paiement de type "cash" ou "Stripe" pourront
être utilisées pour les Abonnements - ensure_at_least_one_customer_html: Assurez-vous qu'au moins une %{customer_link} existe + ensure_at_least_one_customer_html: Assurez-vous qu'au moins un %{customer_link} est enregistré dans votre liste d'acheteurs. create_at_least_one_schedule: Créez au moins un rythme d'abonnement create_at_least_one_schedule_step_1_html: 1. Allez à la page %{order_cycles_link} create_at_least_one_schedule_step_2: 2. Créez un cycle de vente si ce n'est pas encore fait @@ -865,6 +876,9 @@ fr: no_subscriptions: Pas encore d'abonnements... why_dont_you_add_one: Pourquoi ne pas en créer un? :) no_matching_subscriptions: Aucun abonnement correspondant trouvé + schedules: + destroy: + associated_subscriptions_error: Ce rythme d'abonnement ne peut pas être supprimé car il est associé à des abonnements. stripe_connect_settings: edit: title: "Stripe Connect" @@ -1193,7 +1207,9 @@ fr: invite_email: greeting: "Bonjour!" invited_to_manage: "Vous avez été invité(e) à gérer %{enterprise} sur %{instance}." + confirm_your_email: "Vous avez reçu ou allez recevoir prochainement un email avec un lien de validation. Vous n'aurez pas accès au profil de l'entreprise %{enterprise} avant d'avoir cliqué sur ce lien." set_a_password: "Vous serez ensuite invité(e) à choisir un mot de passe avant de pouvoir accéder et gérer le profil de l'entreprise." + mistakenly_sent: "Vous ne savez pas pourquoi vous recevez cet email? Veuillez contacter %{owner_email} pour plus d'informations." producer_mail_greeting: "Cher(ère)" producer_mail_text_before: "Nous avons reçu toutes les commandes pour la prochaine livraison." producer_mail_order_text: "Voilà la liste et les quantités des produits commandés vous concernant:" @@ -1693,6 +1709,10 @@ fr: calculator: "Calculateur" calculator_values: "Valeurs applicables" flat_percent_per_item: "Pourcentage net" + flat_rate_per_item: "Montant fixe par article (hors articles au poids/volume)" + flat_rate_per_order: "Montant fixe par commande" + flexible_rate: "Montant variable selon nb articles" + price_sack: "Montant variable selon total commande" new_order_cycles: "Nouveau cycle de vente" new_order_cycle: "Nouveau Cycle de Vente" select_a_coordinator_for_your_order_cycle: "Choisissez un coordinateur pour votre cycle de vente" diff --git a/config/locales/it.yml b/config/locales/it.yml index fbef1581d7..fe81ff7f31 100644 --- a/config/locales/it.yml +++ b/config/locales/it.yml @@ -125,6 +125,8 @@ it: cache_settings: show: error: Eorrore + products: + Search: Cerca variant_overrides: index: title: Inventario @@ -363,7 +365,6 @@ it: email_admin_html: "Puoi gestire il tuo profilo facendo il log in al link %{link} o cliccando sull'ingranaggio in alto a destra della homepage, e selezionando Amministrazione" email_community_html: "Abbiamo anche un forum on-line per le discussioni della comunità sul software OFN e le sfide uniche legate all'avere un'impresa del cibo. Sei invitato ad unirti. Ci evolviamo in continuo e il tuo contributo in questo forum plasmerà ciò che sarà. %{link}" join_community: "Unisciti alla community" - email_help: "Se hai difficoltà, controlla le nostre FAQ, naviga nel forum o crea una discussione di 'Supporto' e qualcuno ti aiuterà!" email_confirmation_greeting: "Ciao, %{contact}!" email_confirmation_profile_created: "Un profilo per %{name} è stato creato con successo! Per attivare il tuo Profilo abbiamo bisogno di confermare questo indirizzo email." email_confirmation_click_link: "Per favore clicca il link di seguito per confermare la tua email e continuare l'impostazione del tuo profilo" @@ -914,7 +915,6 @@ it: products_unsaved: "Modifiche a %{n} prodotti rimangono non salvate." is_already_manager: "è già un gestore!" no_change_to_save: "Nessuna modifica da salvare" - add_manager: "Aggiungi un gestore" users: "Utenti" about: "About" images: "Immagini" diff --git a/config/locales/nb.yml b/config/locales/nb.yml index cbd91b72fd..cf94cab202 100644 --- a/config/locales/nb.yml +++ b/config/locales/nb.yml @@ -353,6 +353,7 @@ nb: manages: administrerer products: unit_name_placeholder: 'f.eks. bunter' + Search: Søk properties: property_name: Navn på egenskap inherited_property: Arvet egenskap @@ -360,7 +361,6 @@ nb: to_order_tip: "Varer laget for bestilling har ikke et lagernivå, slik som ferske skiver brød laget for bestilling." product_distributions: "Produktdistribusjoner" group_buy_options: "Gruppekjøpsalternativer" - seo: "SEO" back_to_products_list: "Tilbake til produktlisten" variant_overrides: loading_flash: @@ -1109,7 +1109,6 @@ nb: email_admin_html: "Du kan administrere din konto ved å logge inn på %{link} eller ved klikke på tannhjulet øverst til høyre på hjemmesiden og velge Administrasjon." email_community_html: "Vi har også et online forum for diskusjon relatert til OFN programvaren og de forskjellige utfordringene med å drive et matfirma. Vi oppfordrer deg til å bli med. Vi utvikler oss hele tiden og dine innspill til dette forumet vil forme det som skjer videre. %{link}" join_community: "Bli med" - email_help: "Hvis du har problemer, sjekk vår FAQ, utforsk forumet eller skriv et 'Support'-emne og noen vil hjelpe deg!" email_confirmation_activate_account: "Før vi kan aktivere den nye kontoen din, må vi bekrefte epostadressen din." email_confirmation_greeting: "Hei, %{contact}!" email_confirmation_profile_created: "En profil for %{name} har blitt opprettet! For å aktivere din profil må du bekrefte denne epostadressen." @@ -1941,7 +1940,6 @@ nb: products_unsaved: "Endringer i %{n} produkter er fortsatt ulagret." is_already_manager: "er allerede administrator!" no_change_to_save: "Ingen endring å lagre" - add_manager: "Legg til administrator" users: "Brukere" about: "Om" images: "Bilder" diff --git a/config/locales/pt.yml b/config/locales/pt.yml index 25603b30ab..ff22fc192d 100644 --- a/config/locales/pt.yml +++ b/config/locales/pt.yml @@ -10,6 +10,8 @@ pt: email: Email do/a consumidor/a spree/payment: amount: Quantia + order_cycle: + orders_close_at: Data de fecho errors: models: spree/user: @@ -17,7 +19,11 @@ pt: email: taken: "Já existe uma conta associada a este email. Por favor faça login ou defina uma nova senha." spree/order: - no_card: Não estão disponíveis cartões de crédito válidos + no_card: Não há cartões de crédito válidos disponíveis + order_cycle: + attributes: + orders_close_at: + after_orders_open_at: tem de ser após data de abertura activemodel: errors: models: @@ -25,7 +31,7 @@ pt: attributes: subscription_line_items: at_least_one_product: "^Por favor adicione pelo menos um produto" - not_available: "^%{name} não está disponível no calendário selecionado" + not_available: "^%{name} não está disponível no horário selecionado" ends_at: after_begins_at: "tem de ser depois de começar às" customer: @@ -33,20 +39,20 @@ pt: schedule: not_coordinated_by_shop: "não é coordenado por %{shop}" payment_method: - not_available_to_shop: "não está disponível para a %{shop}" + not_available_to_shop: "não está disponível para %{shop}" invalid_type: "tem de ser em dinheiro ou método Stripe" shipping_method: - not_available_to_shop: "não está disponível para a %{shop}" + not_available_to_shop: "não está disponível para %{shop}" credit_card: not_available: "não está disponível" blank: "é obrigatório" devise: confirmations: - send_instructions: "Receberá um email com instruções sobre como confirmar a sua conta em alguns minutos." - failed_to_send: "Ocorreu um erro no envio do email de confirmação" + send_instructions: "Daqui a uns minutos irá receber um email com instruções sobre como confirmar a sua conta." + failed_to_send: "Ocorreu um erro enquanto enviávamos o seu email de confirmação" resend_confirmation_email: "Reenviar email de confirmação." confirmed: "Obrigada por confirmar o seu email! Agora já pode fazer login." - not_confirmed: "O seu email não pôde ser confirmado. Talvez já tenha completado este passo?" + not_confirmed: "O seu email não pôde ser confirmado. Talvez já tenha concluído este passo?" user_registrations: spree_user: signed_up_but_unconfirmed: "Foi enviada uma mensagem para o seu endereço de email com um link de confirmação. Por favor clique nesse link para activar a sua conta." @@ -57,29 +63,29 @@ pt: unconfirmed: "Tem de confirmar a sua conta antes de continuar." enterprise_mailer: confirmation_instructions: - subject: "Por favor confirme o endereço de email da %{enterprise}" + subject: "Por favor confirme o endereço de email de %{enterprise}" welcome: subject: "%{enterprise} está agora em %{sitename}" invite_manager: - subject: "%{enterprise} enviou-lhe um convite para ser gestora/a" + subject: "%{enterprise} convidou-o/a para gestor/a" producer_mailer: order_cycle: subject: "Relatório de ciclo de encomendas de %{producer}" subscription_mailer: placement_summary_email: - subject: Um resumo das encomendas de subscrição recentes + subject: Um resumo das encomendas de subscrição feitas recentemente greeting: "Olá %{name}," - intro: "Abaixo está um resumo das encomendas de subscrição que acabam de ser feitas na %{shop}." + intro: "Abaixo está um resumo das encomendas de subscrição que acabam de ser feitas em %{shop}." confirmation_summary_email: subject: Um resumo das encomendas de subscrição confirmadas recentemente greeting: "Olá %{name}," - intro: "Abaixo está um resumo das encomendas de subscrição que acabam de ser finalizadas na %{shop}." + intro: "Abaixo está um resumo das encomendas de subscrição que acabam de ser finalizadas em %{shop}." summary_overview: total: 'Um total de %{count} subscrições foram marcadas para processamento automático. ' success_zero: Destas, nenhuma foi processada com sucesso. success_some: 'Destas, %{count} foram processadas com sucesso. ' success_all: Todas foram processadas com sucesso. - issues: Detalhes dos problemas encontrados estão listados abaixo. + issues: Abaixo estão listados os detalhes dos problemas encontrados. summary_detail: no_message_provided: Não foi fornecida nenhuma mensagem de erro changes: @@ -87,10 +93,10 @@ pt: explainer: Estas encomendas foram processadas mas não existe stock suficiente para alguns dos itens requisitados empty: title: Sem Stock (%{count} encomendas) - explainer: Não foi possível processar estas encomendas porque não existe stock disponível de nenhum dos produtos requisitados + explainer: Não foi possível processar estas encomendas porque não existe stock disponível para nenhum dos produtos requisitados complete: - title: Já foi processada (%{count} encomendas) - explainer: Estas encomendas já foram marcadas como completas, e portanto foram deixadas como estão + title: Já foram processadas (%{count} encomendas) + explainer: Estas encomendas já foram marcadas como concluídas, e portanto deixámo-las como estão processing: title: Encontrado Erro (%{count} encomendas) explainer: O processamento automático destas encomendas falhou devido a um erro. O erro foi listado quando possível. @@ -99,13 +105,13 @@ pt: explainer: O processamento automático do pagamento destas encomendas falhou devido a um erro. O erro foi listado quando possível. other: title: Outra Falha (%{count} encomendas) - explainer: 'O processamento automático destas encomendas falhou devido a razão desconhecida. Isto não deveria estar a acontecer, por favor contacte-nos se estiver a ver esta mensagem. ' + explainer: 'O processamento automático destas encomendas falhou devido a uma razão desconhecida. Isto não deveria estar a acontecer, por favor contacte-nos se estiver a ver esta mensagem. ' home: "OFN" title: Open Food Network welcome_to: 'Bem vindo a' site_meta_description: "Começamos a partir da terra. Com agricultores e produtores prontos a contarem as suas histórias com um brilho nos olhos. Com distribuidores prontos a estabelecerem ligações entre pessoas e produtos de forma justa e honesta. Com consumidores que acreditam que melhores decisões no momento da compra..." - search_by_name: Procurar por nome ou localidade - producers_join: Produtores e produtoras nacionais, estão convidados a juntarem-se à Open Food Network! + search_by_name: Procurar por nome ou localidade... + producers_join: Produtores e produtoras de proximidade, estão convidados a juntarem-se à Open Food Network! charges_sales_tax: Cobra GST? print_invoice: "Imprimir factura" print_ticket: "Imprimir bilhetes" @@ -116,15 +122,15 @@ pt: edit_order: "Editar encomenda" ship_order: "Enviar encomenda" cancel_order: "Cancelar encomenda" - confirm_send_invoice: "Vai ser enviada ao cliente uma factura desta encomenda. Tem a certeza que deseja continuar?" + confirm_send_invoice: "Vai ser enviada uma factura desta encomenda ao cliente. Tem a certeza que deseja continuar?" confirm_resend_order_confirmation: "Tem a certeza que deseja enviar novamente o email de confirmação de encomenda? " must_have_valid_business_number: "%{enterprise_name}tem de ter um ABN válido antes de se poder enviar facturas." invoice: "Factura" percentage_of_sales: "%{percentage} de vendas" capped_at_cap: "limitado em %{cap}" per_month: "por mês" - free: "grátis" - free_trial: "faça um teste gratuitamente" + free: "gratuito" + free_trial: "experimentar sem pagar" plus_tax: "mais taxas" min_bill_turnover_desc: "assim que o volume de negócios exceder %{mbt_amount}" say_no: "Não" @@ -134,7 +140,7 @@ pt: bill_address: Endereço de Facturação ship_address: Endereço de Envio sort_order_cycles_on_shopfront_by: "Ordenar Ciclos de Encomendas no Mercado Por" - required_fields: Os campos obrigatórios são indicados com um asterisco + required_fields: Os campos obrigatórios estão indicados com um asterisco select_continue: Seleccionar e continuar remove: Remover or: ou @@ -143,7 +149,7 @@ pt: loading: A carregar... show_more: Mostrar mais show_all: Mostrar tudo - show_all_with_more: "Mostrar tudo (%{num} Mais)" + show_all_with_more: "Mostrar tudo (Mais %{num})" cancel: Cancelar edit: Editar clone: Clonar @@ -173,7 +179,7 @@ pt: 'no': "Não" y: 'S' n: 'N' - powered_by: Distribuído por + powered_by: Impulsionado por blocked_cookies_alert: "O seu browser pode estar a bloquear cookies necessárias para o correcto funcionamento deste mercado. Clique abaixo para autorizar cookies e recarregue a página." allow_cookies: "Autorizar cookies" notes: Notas @@ -183,13 +189,13 @@ pt: filter_results: Filtrar resultados quantity: Quantidade pick_up: Levantamento - copy: Cópia + copy: Copiar actions: create_and_add_another: "Criar e acrescentar outro" admin: begins_at: Começa às begins_on: Começa em - customer: Cliente + customer: Consumidor/a date: Data email: Email ends_at: 'Termina às ' @@ -203,7 +209,7 @@ pt: payment_method: Método de Pagamento phone: Telefone price: Preço - producer: Produtor + producer: Produtor/a image: Imagem product: Produto quantity: Quantidade @@ -216,37 +222,37 @@ pt: tags: Etiquetas variant: Variante weight: Peso - volume: Tamanho + volume: Volume items: Itens - select_all: Selecionar tudo + select_all: Seleccionar tudo obsolete_master: Mestre obsoleto quick_search: Pesquisa Rápida clear_all: Limpar Tudo start_date: "Data de Início" - end_date: "Data de Término" + end_date: "Data de Fim" form_invalid: "O formulário contém campos incompletos ou inválidos" clear_filters: Limpar filtros clear: Limpar show_more: Mostrar mais show_n_more: Mostrar mais %{num} choose: "Escolher..." - please_select: Por favor selecione... + please_select: Por favor seleccione... columns: Colunas actions: Acções viewing: "Visualizando: %{current_view_name}" description: Descrição whats_this: O que é isto? tag_has_rules: "Regras existentes para esta etiqueta: %{num}" - has_one_rule: "possui uma regra" + has_one_rule: "tem uma regra" has_n_rules: "tem %{num} regras" unsaved_confirm_leave: "Existem alterações por guardar nesta página. Continuar sem guardar? " - unsaved_changes: "Algumas alterações não foram guardadas" + unsaved_changes: "Tem alterações por guardar" accounts_and_billing_settings: method_settings: default_accounts_payment_method: "Método de pagamento da conta por defeito" default_accounts_shipping_method: "Método de envio da conta por defeito" edit: - accounts_and_billing: "Contas e facturação" + accounts_and_billing: "Contabilidade & Facturação" accounts_administration_distributor: "Administração de contas de distribuidor" admin_settings: "Configurações" update_invoice: "Actualizar facturas" @@ -299,33 +305,33 @@ pt: total_monthly_bill_incl_tax_tip: "O exemplo de conta mensal total incluindo taxas, dadas as definições e o volume de negócios indicado." customers: index: - add_customer: "Adicionar Cliente" - new_customer: "Novo Cliente" - customer_placeholder: "cliente@exemplo.org" + add_customer: "Adicionar Consumidor/a" + new_customer: "Novo/a Consumidor/a" + customer_placeholder: "consumidora@exemplo.org" valid_email_error: Por favor usar um endereço de email válido - add_a_new_customer_for: Adicionar novo cliente para %{shop_name} + add_a_new_customer_for: Adicionar novo/a consumidor/a a %{shop_name} code: Código duplicate_code: "Este código já está a ser usado." - bill_address: "Endereço de Factura" + bill_address: "Endereço de Facturação" ship_address: "Endereço de Entrega" update_address_success: 'Endereço actualizado com sucesso' - update_address_error: 'Por favor preencher todos os campos obrigatórios' - edit_bill_address: 'Editar Endereço de Factura' + update_address_error: 'Perdão! Por favor preencha todos os campos obrigatórios!' + edit_bill_address: 'Editar Endereço de Facturação' edit_ship_address: 'Editar Endereço de Entrega' required_fileds: 'Os campos obrigatórios estão marcados com um asterisco' - select_country: 'Selecionar País' - select_state: 'Selecionar Estado' + select_country: 'Seleccionar País' + select_state: 'Seleccionar Região' edit: 'Editar' update_address: 'Actualizar Endereço' confirm_delete: 'De certeza que é para apagar? ' search_by_email: "Pesquisar por e-mail/código" destroy: - has_associated_orders: 'Não foi possível apagar: o cliente tem encomendas associadas a esta loja.' + has_associated_orders: 'Não foi possível apagar: o/a consumidor/a tem encomendas associadas a esta loja.' cache_settings: show: title: A carregar distributor: Distribuidor - order_cycle: Ciclo de Pedidos + order_cycle: Ciclo de Encomendas status: Status diff: Diff error: Erro @@ -367,9 +373,9 @@ pt: available_on: Disponível em av_on: "Disp. em" upload_an_image: Carregar uma imagem - product_search_keywords: Palavras-chave de Pesquisa de Produto + product_search_keywords: Palavras-chave para Pesquisa de Produto product_search_tip: Insira palavras que ajudem a encontrar os seus produtos nas lojas. Use um espaço para separar cada palavra-chave - SEO_keywords: Palavras-chave SEO + SEO_keywords: Palavras-chave para fins de SEO seo_tip: Insira palavras que ajudem a encontrar os seus produtos na web. Use um espaço para separar cada palavra-chave Search: Procurar properties: @@ -382,7 +388,7 @@ pt: back_to_products_list: "Voltar à lista de produtos" variant_overrides: loading_flash: - loading_inventory: CARREGANDO INVENTÁRIO + loading_inventory: A CARREGAR INVENTÁRIO... index: title: Inventário description: Utilize esta página para gerir os inventários das suas organizações. Qualquer detalhe de produto que seja introduzido aqui, irá substituir o que foi especificado na página de 'Produtos' @@ -390,16 +396,16 @@ pt: inherit?: Herdar? add: Adicionar hide: Esconder - select_a_shop: Selecionar Uma Loja - review_now: Avaliar Agora - new_products_alert_message: Há %{new_product_count} novos produtos disponíveis para serem adicionados ao seu inventário. - currently_empty: Actualmente, o seu inventário está vazio - no_matching_products: Nenhum produto correspondente encontrado no seu inventário - no_hidden_products: Nenhum produto foi ocultado neste inventário + select_a_shop: Seleccionar Uma Loja + review_now: Rever Agora + new_products_alert_message: Há %{new_product_count} novos produtos disponíveis para adicionar ao seu inventário. + currently_empty: Actualmente o seu inventário está vazio + no_matching_products: Não foi encontrado nenhum produto correspondente no seu inventário + no_hidden_products: Não foi ocultado nenhum produto deste inventário no_matching_hidden_products: Nenhum produto oculto corresponde à sua pesquisa no_new_products: Nenhum novo produto está disponível para ser adicionado a este inventário - no_matching_new_products: Nenhum novo produto corresponde à sua busca - inventory_powertip: Este é o seu inventário de produtos. Para adicionar produtos ao seu inventário, selecione 'Novos Produtos' no menu Visualizar + no_matching_new_products: Nenhum produto novo corresponde à sua pesquisa + inventory_powertip: Este é o seu inventário de produtos. Para adicionar produtos ao seu inventário, seleccione 'Novos Produtos' no menu Visualizar hidden_powertip: Estes produtos foram escondidos no seu inventário e não estarão disponíveis para serem adicionados à sua loja. Pode clicar em 'Adicionar' para adicionar um produto ao seu inventário. new_powertip: 'Estes produtos estão disponíveis para serem adicionados ao seu inventário. Clique em ''Adicionar'' para adicionar um produto em seu inventário, ou ''Ocultar'' para escondê-lo. Pode voltar atrás quando quiser! ' controls: @@ -591,8 +597,8 @@ pt: managers: Administradores managers_tip: Os outros utilizadores com permissão para gerir esta organização. invite_manager: "Convidar Gestor/a" - invite_manager_tip: "Convidar um utilizador não-registado a inscrever-se e tornar-se gestor desta organização." - add_unregistered_user: "Adicionar um utilizador não-registado" + invite_manager_tip: "Convidar um/a utilizador/a não-registado/a a inscrever-se e tornar-se gestor/a desta organização." + add_unregistered_user: "Adicionar um/a utilizador/a não-registado/a" email_confirmed: "Email confirmado" email_not_confirmed: "Email não confirmado" actions: @@ -653,7 +659,7 @@ pt: next_step: Próximo passo choose_starting_point: 'Escolha o seu ponto de partida:' invite_manager: - user_already_exists: "O utilizador já existe" + user_already_exists: "O/A utilizador/a já existe" error: "Algo correu mal" order_cycles: edit: @@ -726,6 +732,8 @@ pt: destroy_errors: orders_present: Esse ciclo de encomendas foi selecionado por um/a consumidor/a e não pode ser apagado. Para evitar que os clientes acedam, por favor feche-o. schedule_present: Esse ciclo de encomendas está ligado a um horário e não pode ser apagado. Por favor elimine a ligação ou apague primeiro o horário. + bulk_update: + no_data: Hmmm, algo correu mal. Não foram encontrados dados do ciclo de encomendas. producer_properties: index: title: Propriedades do produtor @@ -908,13 +916,13 @@ pt: checkout: "Finalizar compra agora" already_ordered_products: "Já encomendado neste ciclo de pedidos" register_call: - selling_on_ofn: "Interessado em participar da Open Food Network?" - register: "Registre-se aqui" + selling_on_ofn: "Tens interesse em participar na Open Food Network?" + register: "Regista-te aqui" shop: messages: login: "Entrar" - register: "registro" - contact: "contato" + register: "registo" + contact: "contacto" require_customer_login: "Essa loja é somente para clientes." require_login_html: "Fazer o %{login} se você já possui uma conta. Caso contrário, %{register} para se tornar cliente. " require_customer_html: "Por favor %{contact} a %{enterprise} para se tornar consumidor/a. " @@ -924,10 +932,10 @@ pt: invoice_column_tax: "GST" invoice_column_price: "Preço" invoice_column_item: "Item" - invoice_column_qty: "Quantidade" - invoice_column_unit_price_with_taxes: "Preço unitário (com taxa)" + invoice_column_qty: "Qtd" + invoice_column_unit_price_with_taxes: "Preço unitário (incl. taxa)" invoice_column_unit_price_without_taxes: "Preço unitário (sem taxa)" - invoice_column_price_with_taxes: "Preço total (com taxas)" + invoice_column_price_with_taxes: "Preço total (incl. taxas)" invoice_column_price_without_taxes: "Preço total (sem taxas)" invoice_column_tax_rate: "Taxa de imposto" invoice_tax_total: "Total GST:" @@ -966,14 +974,14 @@ pt: email: Email phone: Telefone next: Próximo - address: Endereço + address: Morada address_placeholder: 'ex: Rua Alta, 123' address2: Complemento city: Cidade city_placeholder: 'ex: Porto' postcode: Código postal - postcode_placeholder: 'ex: 3070' - state: Estado + postcode_placeholder: 'ex: 4000-125' + state: Região country: País unauthorized: Não autorizado terms_of_service: "Termos de Serviço" @@ -1029,58 +1037,58 @@ pt: card_could_not_be_removed: Pedimos desculpa, o cartão não pôde ser removido. ie_warning_headline: "O seu navegador está desactualizado :-(" ie_warning_text: "Para ter uma melhor experiência com a Open Food Network, recomendamos vivamente que actualize o seu navegador:" - ie_warning_chrome: Baixar Chrome - ie_warning_firefox: Baixar Firefox + ie_warning_chrome: Descarregar Chrome + ie_warning_firefox: Descarregar Firefox ie_warning_ie: Actualizar Internet Explorer ie_warning_other: "Não consegue actualizar o navegador? Tente aceder à OFN pelo smartphone :-)" footer_global_headline: "OFN Global" footer_global_home: "Início" - footer_global_news: "Novidades" + footer_global_news: "Notícias" footer_global_about: "Sobre" footer_global_contact: "Contacto" footer_sites_headline: "Páginas OFN" footer_sites_developer: "Desenvolvimento" footer_sites_community: "Comunidade" footer_sites_userguide: "Manual de Utilizador" - footer_secure: "Seguro e confiável." + footer_secure: "Seguro e de confiança." footer_secure_text: "A Open Food Network utiliza a criptografia SSL (2048 bit RSA) para manter as suas informações em segurança. Os nossos servidores não guardam os detalhes do seu cartão de crédito e os pagamentos são processados por serviços compatíveis com PCI." - footer_contact_headline: "Mantenha-se em contacto" + footer_contact_headline: "Ficamos em contacto" footer_contact_email: "Envie-nos um email" footer_nav_headline: "Navegar" footer_join_headline: "Junte-se a nós" - footer_join_body: "Criar uma lista de ofertas, loja ou cooperativa na Open Food Network" + footer_join_body: "Criar uma lista de ofertas, uma loja ou um grupo de consumo na Open Food Network" footer_join_cta: "Quero saber mais!" - footer_legal_call: "Leia o nosso" + footer_legal_call: "Leia os nossos" footer_legal_tos: "Termos e condições" - footer_legal_visit: "Encontre-nos em " - footer_legal_text_html: "A Open Food Network é uma plataforma livre e open-source. O nosso conteúdo é disponibilizado sob uma licença %{content_license} e o nosso código %{code_license}." - home_shop: Compre Agora - brandstory_headline: "Alimentos, com liberdade" - brandstory_intro: "Às vezes, a melhor maneira de consertar o sistema é construir um novo..." + footer_legal_visit: "Encontre-nos no" + footer_legal_text_html: "A Open Food Network é uma plataforma livre e de código aberto. O nosso conteúdo tem uma licença %{content_license} e o nosso código %{code_license}." + home_shop: Ir às compras + brandstory_headline: "Para quem consome com princípios" + brandstory_intro: "Às vezes a melhor forma de consertar o sistema é construir um novo..." brandstory_part1: "Começamos a partir da terra. Com agricultores e produtores prontos a contarem as suas histórias com um brilho nos olhos. Com distribuidores prontos a estabelecerem ligações entre pessoas e produtos de forma justa e honesta. Com consumidores que acreditam que melhores decisões no momento da compra podem mudar o mundo. " - brandstory_part2: "Precisamos de uma ferramenta para empoderar a todos que produzem, vendem e compram comida. Uma maneira de contar histórias, e controlar toda a logística. " - brandstory_part3: "Por isso construímos um mercado online, transparente, capaz de criar conexões verdadeiras. O código é aberto, e pode ser modificado para melhor se adaptar as particularidades de cada canto do planeta. " - brandstory_part4: "Queremos de volta o controle sobre os alimentos que consumimos." - brandstory_part5_strong: "Bem vindos à Open Food Network" - brandstory_part6: "Todos amamos comida. Agora a gente também pode amar nosso sistema alimentar. " - learn_body: "Conheça novos modelos, histórias e fornecedores para dar suporte à sua iniciativa, mercado ou organização. Encontre oportunidades para aprender com quem faz parte do seu setor. " + brandstory_part2: "Depois precisamos de uma forma de torná-lo real. Uma forma de dar poder a todos e todas que cultivam, produzem, vendem e compram alimentos. Uma forma de contar todas as histórias, e de lidar com toda a logística. Una forma de transformar transacção em transformação todos os dias. " + brandstory_part3: "Por isso construímos um mercado online para quem quer jogar noutro campeonato. É transparente, e portanto promove relações verdadeiras. É de código aberto, e portanto somos todos donos e donas, podendo modificá-lo para melhor se adaptar às particularidades de cada lugar. " + brandstory_part4: "Funciona em todo o lado e muda tudo. " + brandstory_part5_strong: "Chamamos-lhe Open Food Network" + brandstory_part6: "Todos nós adoramos comer bem. Agora também podemos amar o nosso sistema alimentar. " + learn_body: "Explore novos modelos, histórias e recursos para apoiar a sua iniciativa agroalimentar. Encontre acções de formação, eventos e outras oportunidades para aprender com os seus pares. " learn_cta: "Inspire-se" - connect_body: "Procure em nossa lista por produtores, distribuidores e cooperativas para encontrar um comércio justo, perto de você. Registre seu negócio na OFN para que os consumidores possam te encontrar. Junte-se à comunidade para trocar experiências e resolver problemas, juntos. " + connect_body: "Procure no nosso directório de produtores, centrais de abastecimento e grupos de consumo para encontrar um comércio justo perto de si. Registe o seu negócio ou organização na OFN para que os consumidores possam encontrá-lo. Junte-se à comunidade para trocar experiências e resolver problemas em conjunto." connect_cta: "Explore" - system_headline: "A feira funciona assim:" - system_step1: "1. Busca" - system_step1_text: "Escolha entre diversos mercados independentes por alimentos locais, da estação. Procure por região, tipo de alimentos, ou se você prefere retirar ou receber em casa. " - system_step2: "2. Compra" - system_step2_text: "Transforme seu comércio com fornecedores de alimentos locais. Conheça as histórias por trás do seu produto, e daqueles que o fazem!" - system_step3: "3. Coleta / Entrega" - system_step3_text: "Espere que sua compre chegue até você, ou retire nos pontos de entrega determinados pelo seu fornecedor. Simples assim!" - cta_headline: "A feira que incentiva a economia local." - cta_label: "Estou pronto" - stats_headline: "Estamos criando um novo sistema alimentar" - stats_producers: "produtores" + system_headline: "As Compras — eis como funcionam:" + system_step1: "1. Procurar" + system_step1_text: "Escolha produtos sazonais de proximidade entre diversos pontos de venda independentes. Procure por região, tipo de alimento, e participe num grupo de consumo. " + system_step2: "2. Comprar" + system_step2_text: "Transforme o seu consumo, adquirindo produtos locais acessíveis de diversos produtores e produtoras. Conheça as histórias por trás da sua comida e quem a produz!" + system_step3: "3. Levantar" + system_step3_text: "Há a possibilidade de entrega ao domicílio ou de levantamento no ponto de distribuição definido por cada fornecedor. Visite o seu Grupo de Consumo para um vínculo mais directo com os produtores e com a vizinhança. " + cta_headline: "Incentivar a economia local, fazer do mundo um lugar melhor. " + cta_label: "Eu voto com o meu garfo" + stats_headline: "Estamos a criar um novo sistema alimentar" + stats_producers: "produtores/as" stats_shops: "lojas" - stats_shoppers: "compradores" - stats_orders: "pedidos" + stats_shoppers: "consumidores/as" + stats_orders: "encomendas" checkout_title: Fechar pedido checkout_now: Fechar pedido agora checkout_order_ready: Pedido pronto para @@ -1449,6 +1457,7 @@ pt: november: "Novembro" december: "Dezembro" email_not_found: "Endereço de email não encontrado" + email_unconfirmed: "Tem de confirmar o seu endereço de email antes de poder redefinir a sua password." email_required: "Você precisa providenciar um endereço de email" logging_in: "Fazendo o login, aguarde um momento" signup_email: "Seu email" @@ -1700,6 +1709,10 @@ pt: calculator: "Calculadora" calculator_values: "Valores da calculadora" flat_percent_per_item: "Percentual (por unidade)" + flat_rate_per_item: "Taxa Fixa (por item)" + flat_rate_per_order: "Taxa fixa (por encomenda)" + flexible_rate: "Taxa flexível" + price_sack: "Saco de Preços" new_order_cycles: "Novos Ciclo de Encomendas" new_order_cycle: "Novo ciclo de encomendas" select_a_coordinator_for_your_order_cycle: "Escolher um coordenador para o seu ciclo de encomendas" @@ -1975,6 +1988,8 @@ pt: products_unsaved: "Modificações para %{n} produtos permanecem não salvas." is_already_manager: "já é um gestor!" no_change_to_save: "Nenhuma modificação a ser salva" + user_invited: "%{email} foi convidado/a para gerir esta organização" + add_manager: "Adicionar um/a utilizador/a existente" users: "Usuários" about: "Sobre" images: "Imagens" @@ -2038,11 +2053,12 @@ pt: order_cycles_no_permission_to_create_error: "Não tem permissão para criar um ciclo de encomendas coordenado por essa organização." back_to_orders_list: "Voltar à lista de encomendas" order_information: "Informação da Encomenda" - date_completed: "Data Conclusão" + date_completed: "Data Concluído" amount: "Quantia" state_names: - ready: Pronto + ready: Pronta pending: Pendente + shipped: Enviada js: saving: 'A guardar....' changes_saved: 'Alterações guardadas.' @@ -2064,6 +2080,9 @@ pt: enterprise_limit_reached: "Atingiu o número limite de organizações por conta. Escreva para %{contact_email} se precisar de aumentá-lo." modals: got_it: Percebi + close: "Fechar" + invite: "Convidar" + invite_title: "Convidar um/a utilizador/a não-registado/a" tag_rule_help: title: Regras de Etiquetas overview: Visão geral diff --git a/config/locales/sv.yml b/config/locales/sv.yml index 7c549a2ac3..b5453da3b3 100644 --- a/config/locales/sv.yml +++ b/config/locales/sv.yml @@ -247,6 +247,7 @@ sv: manages: hanterar products: unit_name_placeholder: 't.ex. klasar' + Search: Sök properties: property_name: Egendomens Namn inherited_property: Ärvd Egendom @@ -843,7 +844,6 @@ sv: email_admin_html: "Du kan sköta ditt konto genom att logga in %{link} eller att klicka på kuggen i det övre högra hörnet på hemsidan och välja Administration." email_community_html: "Vi har också ett internetforum för gemensamma diskussioner som är relaterade till OFN programvara och den unika utmaningen att använda ett matföretag. Vi utvecklas ständigt och dina inlägg till detta forum påverkar vad som händer i fortsättningen. %{link}" join_community: "Gå med i gemenskapen" - email_help: "Om du hamnar i svårigheter, titta i vårt FAQ, sök igenom vårt forum eller skicka en fråga till Support och du kommer att få hjälp." email_confirmation_greeting: "Hej, %{contact}!" email_confirmation_profile_created: "En profil för %{name} har skapats! För att aktivera din profil behöver vi få en bekräftelse på din e-postadress." email_confirmation_click_link: "Var vänlig att klicka på länken nedan för att bekräfta din e-postadress och fortsätt att skapa din profil." @@ -1590,7 +1590,6 @@ sv: products_unsaved: "Ändringar till %{n} produkter är fortfarande osparade." is_already_manager: "är allaredan chef!" no_change_to_save: "Ingen ändring att spara" - add_manager: "Utse en chef" users: "Användare" about: "Om oss" images: "Bilder" From 4551fa60c5a923a720ed643003116635fa809120 Mon Sep 17 00:00:00 2001 From: Daniel Dominguez Date: Wed, 2 May 2018 09:35:23 -0300 Subject: [PATCH 035/206] Lowercase tag when adding a tag to the array, as they are lowercased after at the database level, there is no point to display them with uppercase letters on the view. --- .../admin/utils/directives/tags_with_translation.js.coffee | 3 ++- app/assets/javascripts/templates/admin/tags_input.html.haml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/admin/utils/directives/tags_with_translation.js.coffee b/app/assets/javascripts/admin/utils/directives/tags_with_translation.js.coffee index 9722ab26af..ef84570f5e 100644 --- a/app/assets/javascripts/admin/utils/directives/tags_with_translation.js.coffee +++ b/app/assets/javascripts/admin/utils/directives/tags_with_translation.js.coffee @@ -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() diff --git a/app/assets/javascripts/templates/admin/tags_input.html.haml b/app/assets/javascripts/templates/admin/tags_input.html.haml index f08c0cc6f6..c652aea610 100644 --- a/app/assets/javascripts/templates/admin/tags_input.html.haml +++ b/app/assets/javascripts/templates/admin/tags_input.html.haml @@ -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", From 598677be3fd938f737494272f4216be2ae2599a3 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Wed, 7 Feb 2018 21:00:02 +0000 Subject: [PATCH 036/206] Disable guest checkout for registered users --- .../authentication/login_controller.js.coffee | 7 ++ .../checkout/checkout_controller.js.coffee | 21 +++- .../checkout/details_controller.js.coffee | 9 +- .../services/authentication_service.js.coffee | 3 + .../spree/users_controller_decorator.rb | 6 + app/views/checkout/_details.html.haml | 2 +- config/locales/en.yml | 1 + config/routes.rb | 1 + .../consumer/shopping/checkout_auth_spec.rb | 104 +++++++++++------- 9 files changed, 110 insertions(+), 44 deletions(-) diff --git a/app/assets/javascripts/darkswarm/controllers/authentication/login_controller.js.coffee b/app/assets/javascripts/darkswarm/controllers/authentication/login_controller.js.coffee index f1bb13bcca..c6c54e38d1 100644 --- a/app/assets/javascripts/darkswarm/controllers/authentication/login_controller.js.coffee +++ b/app/assets/javascripts/darkswarm/controllers/authentication/login_controller.js.coffee @@ -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)-> diff --git a/app/assets/javascripts/darkswarm/controllers/checkout/checkout_controller.js.coffee b/app/assets/javascripts/darkswarm/controllers/checkout/checkout_controller.js.coffee index 3397077a00..4f18572436 100644 --- a/app/assets/javascripts/darkswarm/controllers/checkout/checkout_controller.js.coffee +++ b/app/assets/javascripts/darkswarm/controllers/checkout/checkout_controller.js.coffee @@ -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 @@ -21,7 +21,26 @@ Darkswarm.controller "CheckoutCtrl", ($scope, localStorageService, Checkout, Cur $scope.purchase = (event, form) -> event.preventDefault() $scope.submitted = true + + if CurrentUser.id + $scope.validateForm(form) + else + $scope.confirmGuest(true) + + $scope.validateForm = (form) -> if form.$valid $scope.Checkout.purchase() else $scope.$broadcast 'purchaseFormInvalid', form + + $scope.confirmGuest = -> + $http.post("/user/registered_email", {email: $scope.order.email}).success (data)-> + if data.registered == true + $scope.promptLogin() + else + $scope.validateForm() if $scope.submitted + + $scope.promptLogin = -> + SpreeUser.spree_user.email = $scope.order.email + AuthenticationService.pushMessage t('devise.failure.already_registered') + AuthenticationService.open '/login' diff --git a/app/assets/javascripts/darkswarm/controllers/checkout/details_controller.js.coffee b/app/assets/javascripts/darkswarm/controllers/checkout/details_controller.js.coffee index e0fef8e343..dcc6b44c63 100644 --- a/app/assets/javascripts/darkswarm/controllers/checkout/details_controller.js.coffee +++ b/app/assets/javascripts/darkswarm/controllers/checkout/details_controller.js.coffee @@ -1,8 +1,15 @@ -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.confirmGuest() + + $scope.next() + $scope.summary = -> [$scope.fullName(), $scope.order.email, diff --git a/app/assets/javascripts/darkswarm/services/authentication_service.js.coffee b/app/assets/javascripts/darkswarm/services/authentication_service.js.coffee index b504d92c76..a51ccdedee 100644 --- a/app/assets/javascripts/darkswarm/services/authentication_service.js.coffee +++ b/app/assets/javascripts/darkswarm/services/authentication_service.js.coffee @@ -2,6 +2,7 @@ Darkswarm.factory "AuthenticationService", (Navigation, $modal, $location, Redir new class AuthenticationService selectedPath: "/login" + modalMessage: null constructor: -> if $location.path() in ["/login", "/signup", "/forgot"] || location.pathname is '/register/auth' @@ -32,6 +33,8 @@ Darkswarm.factory "AuthenticationService", (Navigation, $modal, $location, Redir 'registration_authentication.html' else 'authentication.html' + pushMessage: (message) -> + @modalMessage = String(message) select: (path)=> @selectedPath = path diff --git a/app/controllers/spree/users_controller_decorator.rb b/app/controllers/spree/users_controller_decorator.rb index aca371c75b..53cfd09324 100644 --- a/app/controllers/spree/users_controller_decorator.rb +++ b/app/controllers/spree/users_controller_decorator.rb @@ -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 diff --git a/app/views/checkout/_details.html.haml b/app/views/checkout/_details.html.haml index 788996be37..775c00c670 100644 --- a/app/views/checkout/_details.html.haml +++ b/app/views/checkout/_details.html.haml @@ -28,5 +28,5 @@ .row .small-12.columns.text-right - %button.primary{"ng-disabled" => "details.$invalid", "ng-click" => "next($event)"} + %button.primary{"ng-disabled" => "details.$invalid", "ng-click" => "login_or_next($event)"} = t :next diff --git a/config/locales/en.yml b/config/locales/en.yml index 4a2ee53afa..bf51c438ef 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -117,6 +117,7 @@ en: Invalid email or password. Were you a guest last time? Perhaps you need to create an account or reset your password. unconfirmed: "You have to confirm your account before continuing." + already_registered: "This email address is already registered. Please log in to continue, or go back and use another email address." enterprise_mailer: confirmation_instructions: subject: "Please confirm the email address for %{enterprise}" diff --git a/config/routes.rb b/config/routes.rb index 2062a1d60f..fb4b652bac 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -19,6 +19,7 @@ Openfoodnetwork::Application.routes.draw do get "/register", to: "registration#index", as: :registration get "/register/auth", to: "registration#authenticate", as: :registration_auth + post "/user/registered_email", to: "spree/users#registered_email" # Redirects to global website get "/connect", to: redirect("https://openfoodnetwork.org/#{ENV['DEFAULT_COUNTRY_CODE'].andand.downcase}/connect/") diff --git a/spec/features/consumer/shopping/checkout_auth_spec.rb b/spec/features/consumer/shopping/checkout_auth_spec.rb index e23e4c15a8..517144ed81 100644 --- a/spec/features/consumer/shopping/checkout_auth_spec.rb +++ b/spec/features/consumer/shopping/checkout_auth_spec.rb @@ -7,54 +7,76 @@ feature "As a consumer I want to check out my cart", js: true do include CheckoutWorkflow include UIComponentHelper - let(:distributor) { create(:distributor_enterprise, with_payment_and_shipping: true) } - let(:supplier) { create(:supplier_enterprise) } - let!(:order_cycle) { create(:simple_order_cycle, distributors: [distributor], coordinator: create(:distributor_enterprise), variants: [product.variants.first]) } - let(:product) { create(:simple_product, supplier: supplier) } - let(:order) { create(:order, order_cycle: order_cycle, distributor: distributor) } - let(:address) { create(:address, firstname: "Foo", lastname: "Bar") } - let(:user) { create(:user, bill_address: address, ship_address: address) } - after { Warden.test_reset! } + describe "using the checkout" do + let(:distributor) { create(:distributor_enterprise, with_payment_and_shipping: true) } + let(:supplier) { create(:supplier_enterprise) } + let!(:order_cycle) { create(:simple_order_cycle, distributors: [distributor], coordinator: create(:distributor_enterprise), variants: [product.variants.first]) } + let(:product) { create(:simple_product, supplier: supplier) } + let(:order) { create(:order, order_cycle: order_cycle, distributor: distributor) } + let(:address) { create(:address, firstname: "Foo", lastname: "Bar") } + let(:user) { create(:user, bill_address: address, ship_address: address) } - before do - set_order order - add_product_to_cart order, product - end + after { Warden.test_reset! } - it "does not not render the login form when logged in" do - quick_login_as user - visit checkout_path - within "section[role='main']" do - page.should_not have_content "Login" - page.should have_checkout_details + before do + set_order order + add_product_to_cart order, product end - end - it "renders the login buttons when logged out" do - visit checkout_path - within "section[role='main']" do - page.should have_content "Login" - click_button "Login" + it "does not not render the login form when logged in" do + quick_login_as user + visit checkout_path + within "section[role='main']" do + page.should_not have_content "Login" + page.should have_checkout_details + end end - page.should have_login_modal - end - it "populates user details once logged in" do - visit checkout_path - within("section[role='main']") { click_button "Login" } - page.should have_login_modal - fill_in "Email", with: user.email - fill_in "Password", with: user.password - within(".login-modal") { click_button 'Login' } - toggle_details + it "renders the login buttons when logged out" do + visit checkout_path + within "section[role='main']" do + page.should have_content "Login" + click_button "Login" + end + page.should have_login_modal + end - page.should have_field 'First Name', with: 'Foo' - page.should have_field 'Last Name', with: 'Bar' - end + it "populates user details once logged in" do + visit checkout_path + within("section[role='main']") { click_button "Login" } + page.should have_login_modal + fill_in "Email", with: user.email + fill_in "Password", with: user.password + within(".login-modal") { click_button 'Login' } + toggle_details - it "allows user to checkout as guest" do - visit checkout_path - checkout_as_guest - page.should have_checkout_details + page.should have_field 'First Name', with: 'Foo' + page.should have_field 'Last Name', with: 'Bar' + end + + context "using the guest checkout" do + it "allows user to checkout as guest" do + visit checkout_path + checkout_as_guest + page.should have_checkout_details + end + + it "asks the user to log in if they are using a registered email" do + visit checkout_path + checkout_as_guest + + fill_in 'First Name', with: 'Not' + fill_in 'Last Name', with: 'Guest' + fill_in 'Email', with: user.email + fill_in 'Phone', with: '098712736' + + within '#details' do + click_button 'Next' + end + + expect(page).to have_selector 'div.login-modal', visible: true + expect(page).to have_content I18n.t('devise.failure.already_registered') + end + end end end From 94b90b4a7336ade2841dc2cc18fd055372efc03d Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Wed, 14 Feb 2018 16:01:49 +0000 Subject: [PATCH 037/206] Registered user controller method spec --- spec/controllers/spree/users_controller_spec.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/spec/controllers/spree/users_controller_spec.rb b/spec/controllers/spree/users_controller_spec.rb index f361cba0b5..35e0e0f6a3 100644 --- a/spec/controllers/spree/users_controller_spec.rb +++ b/spec/controllers/spree/users_controller_spec.rb @@ -1,4 +1,5 @@ require 'spec_helper' +require 'spree/api/testing_support/helpers' describe Spree::UsersController, type: :controller do include AuthenticationWorkflow @@ -46,4 +47,18 @@ describe Spree::UsersController, type: :controller do expect(orders).not_to include d1o3 end end + + describe "registered_email" do + let!(:user) { create(:user) } + + it "returns true if email corresponds to a registered user" do + spree_post :registered_email, email: user.email + expect(json_response['registered']).to eq true + end + + it "returns false if email does not correspond to a registered user" do + spree_post :registered_email, email: 'nonregistereduser@example.com' + expect(json_response['registered']).to eq false + end + end end From 6b2c4de20ffff13b2f1068e686ff7d23f51d43a3 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Fri, 23 Feb 2018 10:36:40 +0000 Subject: [PATCH 038/206] Tidy up checkout spec --- .../consumer/shopping/checkout_auth_spec.rb | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/spec/features/consumer/shopping/checkout_auth_spec.rb b/spec/features/consumer/shopping/checkout_auth_spec.rb index 517144ed81..d940f3290a 100644 --- a/spec/features/consumer/shopping/checkout_auth_spec.rb +++ b/spec/features/consumer/shopping/checkout_auth_spec.rb @@ -23,42 +23,42 @@ feature "As a consumer I want to check out my cart", js: true do add_product_to_cart order, product end - it "does not not render the login form when logged in" do + it "does not render the login form when logged in" do quick_login_as user visit checkout_path within "section[role='main']" do - page.should_not have_content "Login" - page.should have_checkout_details + expect(page).to_not have_content "Login" + expect(page).to have_checkout_details end end it "renders the login buttons when logged out" do visit checkout_path within "section[role='main']" do - page.should have_content "Login" + expect(page).to have_content "Login" click_button "Login" end - page.should have_login_modal + expect(page).to have_login_modal end it "populates user details once logged in" do visit checkout_path within("section[role='main']") { click_button "Login" } - page.should have_login_modal + expect(page).to have_login_modal fill_in "Email", with: user.email fill_in "Password", with: user.password within(".login-modal") { click_button 'Login' } toggle_details - page.should have_field 'First Name', with: 'Foo' - page.should have_field 'Last Name', with: 'Bar' + expect(page).to have_field 'First Name', with: 'Foo' + expect(page).to have_field 'Last Name', with: 'Bar' end context "using the guest checkout" do it "allows user to checkout as guest" do visit checkout_path checkout_as_guest - page.should have_checkout_details + expect(page).to have_checkout_details end it "asks the user to log in if they are using a registered email" do From 358edb47272e6e82198b57a2048df02ec05da7f3 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Sat, 3 Mar 2018 23:28:10 +0000 Subject: [PATCH 039/206] Disable guest checkout in model --- app/models/spree/order_decorator.rb | 13 +++++++++++++ spec/models/spree/order_spec.rb | 22 ++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/app/models/spree/order_decorator.rb b/app/models/spree/order_decorator.rb index 0228586327..1c0248c9b1 100644 --- a/app/models/spree/order_decorator.rb +++ b/app/models/spree/order_decorator.rb @@ -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, if: lambda { using_guest_checkout? && registered_email? } attr_accessible :order_cycle_id, :distributor_id, :customer_id before_validation :shipping_address_from_distributor @@ -82,6 +83,18 @@ 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.find_by_email(email).present? + end + + def disallow_guest_order + errors.add(:base, I18n.t('devise.failure.already_registered')) + end + def empty_with_clear_shipping_and_payments! empty_without_clear_shipping_and_payments! payments.clear diff --git a/spec/models/spree/order_spec.rb b/spec/models/spree/order_spec.rb index d872e90ddb..345cbb661f 100644 --- a/spec/models/spree/order_spec.rb +++ b/spec/models/spree/order_spec.rb @@ -673,6 +673,28 @@ describe Spree::Order do end end + describe "when a guest order is placed with a registered email" do + let(:order) { create(:order_with_totals_and_distribution, user: nil) } + let(:payment_method) { create(:payment_method, distributors: [order.distributor]) } + let(:shipping_method) { create(:shipping_method, distributors: [order.distributor]) } + let(:user) { create(:user, email: 'registered@email.com') } + + before do + order.bill_address = create(:address) + order.ship_address = create(:address) + order.shipping_method = shipping_method + order.email = user.email + order.user = nil + order.state = 'cart' + end + + it "returns a validation error" do + expect{order.next}.to change(order.errors, :count).from(0).to(1) + expect(order.errors.messages[:base]).to eq [ I18n.t('devise.failure.already_registered') ] + expect(order.state).to eq 'cart' + end + end + describe "a completed order with shipping and transaction fees" do let(:distributor) { create(:distributor_enterprise, charges_sales_tax: true, allow_order_changes: true) } let(:order) { create(:completed_order_with_fees, distributor: distributor, shipping_fee: shipping_fee, payment_fee: payment_fee) } From fe979b801fe2ac4154169564e0631bd9753ece23 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Wed, 7 Mar 2018 11:45:37 +1100 Subject: [PATCH 040/206] Improve readability by grouping depending logic --- app/models/spree/order_decorator.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/models/spree/order_decorator.rb b/app/models/spree/order_decorator.rb index 1c0248c9b1..9070ab8648 100644 --- a/app/models/spree/order_decorator.rb +++ b/app/models/spree/order_decorator.rb @@ -17,7 +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, if: lambda { using_guest_checkout? && registered_email? } + validate :disallow_guest_order attr_accessible :order_cycle_id, :distributor_id, :customer_id before_validation :shipping_address_from_distributor @@ -92,7 +92,9 @@ Spree::Order.class_eval do end def disallow_guest_order - errors.add(:base, I18n.t('devise.failure.already_registered')) + if using_guest_checkout? && registered_email? + errors.add(:base, I18n.t('devise.failure.already_registered')) + end end def empty_with_clear_shipping_and_payments! From 45fc42723c0ce662b614f1e2f7bccb5d6a36f2b3 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Tue, 13 Mar 2018 09:10:37 +1100 Subject: [PATCH 041/206] Simplify query for existing email It should be easier to read and more efficient now. --- app/models/spree/order_decorator.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/spree/order_decorator.rb b/app/models/spree/order_decorator.rb index 9070ab8648..c6def73d68 100644 --- a/app/models/spree/order_decorator.rb +++ b/app/models/spree/order_decorator.rb @@ -88,7 +88,7 @@ Spree::Order.class_eval do end def registered_email? - Spree.user_class.find_by_email(email).present? + Spree.user_class.exists?(email: email) end def disallow_guest_order From 0741b5fa58c91c7568de1fd6bbab2dcef0dec2ee Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Fri, 30 Mar 2018 12:33:56 +0100 Subject: [PATCH 042/206] Ensure checkout modal opens at correct height --- .../darkswarm/controllers/checkout/details_controller.js.coffee | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/javascripts/darkswarm/controllers/checkout/details_controller.js.coffee b/app/assets/javascripts/darkswarm/controllers/checkout/details_controller.js.coffee index dcc6b44c63..907e475f84 100644 --- a/app/assets/javascripts/darkswarm/controllers/checkout/details_controller.js.coffee +++ b/app/assets/javascripts/darkswarm/controllers/checkout/details_controller.js.coffee @@ -7,6 +7,7 @@ Darkswarm.controller "DetailsCtrl", ($scope, $timeout, $http, CurrentUser, Authe event.preventDefault() unless CurrentUser.id $scope.confirmGuest() + return $scope.next() From 9841f27f92d55363d4030f899b1b369feb546304 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Fri, 30 Mar 2018 12:38:15 +0100 Subject: [PATCH 043/206] Add missing key for devise message --- config/locales/en.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/locales/en.yml b/config/locales/en.yml index bf51c438ef..cb7f1e1f7c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -118,6 +118,9 @@ en: Were you a guest last time? Perhaps you need to create an account or reset your password. unconfirmed: "You have to confirm your account before continuing." already_registered: "This email address is already registered. Please log in to continue, or go back and use another email address." + user_passwords: + spree_user: + updated_not_active: "Your password has been reset, but your email has not been confirmed yet." enterprise_mailer: confirmation_instructions: subject: "Please confirm the email address for %{enterprise}" From d3344973b78b975bcffc24b3e9ae548255bd9e86 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Sat, 21 Apr 2018 13:25:20 +0100 Subject: [PATCH 044/206] checkout_controller clarity --- .../controllers/checkout/checkout_controller.js.coffee | 4 ++-- .../controllers/checkout/details_controller.js.coffee | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/darkswarm/controllers/checkout/checkout_controller.js.coffee b/app/assets/javascripts/darkswarm/controllers/checkout/checkout_controller.js.coffee index 4f18572436..7e89bd2126 100644 --- a/app/assets/javascripts/darkswarm/controllers/checkout/checkout_controller.js.coffee +++ b/app/assets/javascripts/darkswarm/controllers/checkout/checkout_controller.js.coffee @@ -25,7 +25,7 @@ Darkswarm.controller "CheckoutCtrl", ($scope, localStorageService, Checkout, Cur if CurrentUser.id $scope.validateForm(form) else - $scope.confirmGuest(true) + $scope.ensureUserIsGuest() $scope.validateForm = (form) -> if form.$valid @@ -33,7 +33,7 @@ Darkswarm.controller "CheckoutCtrl", ($scope, localStorageService, Checkout, Cur else $scope.$broadcast 'purchaseFormInvalid', form - $scope.confirmGuest = -> + $scope.ensureUserIsGuest = -> $http.post("/user/registered_email", {email: $scope.order.email}).success (data)-> if data.registered == true $scope.promptLogin() diff --git a/app/assets/javascripts/darkswarm/controllers/checkout/details_controller.js.coffee b/app/assets/javascripts/darkswarm/controllers/checkout/details_controller.js.coffee index 907e475f84..db403c0f43 100644 --- a/app/assets/javascripts/darkswarm/controllers/checkout/details_controller.js.coffee +++ b/app/assets/javascripts/darkswarm/controllers/checkout/details_controller.js.coffee @@ -6,7 +6,7 @@ Darkswarm.controller "DetailsCtrl", ($scope, $timeout, $http, CurrentUser, Authe $scope.login_or_next = (event) -> event.preventDefault() unless CurrentUser.id - $scope.confirmGuest() + $scope.ensureUserIsGuest() return $scope.next() From 74689afb8afee9d578d5a274a2a64b13aab851cb Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Mon, 7 May 2018 13:20:49 +0100 Subject: [PATCH 045/206] Unregistered user checkout issue --- .../controllers/checkout/checkout_controller.js.coffee | 10 ++++++---- .../controllers/checkout/details_controller.js.coffee | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/darkswarm/controllers/checkout/checkout_controller.js.coffee b/app/assets/javascripts/darkswarm/controllers/checkout/checkout_controller.js.coffee index 7e89bd2126..67ac51293c 100644 --- a/app/assets/javascripts/darkswarm/controllers/checkout/checkout_controller.js.coffee +++ b/app/assets/javascripts/darkswarm/controllers/checkout/checkout_controller.js.coffee @@ -20,6 +20,7 @@ Darkswarm.controller "CheckoutCtrl", ($scope, localStorageService, Checkout, Cur $scope.purchase = (event, form) -> event.preventDefault() + $scope.formdata = form $scope.submitted = true if CurrentUser.id @@ -27,18 +28,19 @@ Darkswarm.controller "CheckoutCtrl", ($scope, localStorageService, Checkout, Cur else $scope.ensureUserIsGuest() - $scope.validateForm = (form) -> - if form.$valid + $scope.validateForm = -> + if $scope.formdata.$valid $scope.Checkout.purchase() else - $scope.$broadcast 'purchaseFormInvalid', form + $scope.$broadcast 'purchaseFormInvalid', $scope.formdata - $scope.ensureUserIsGuest = -> + $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 diff --git a/app/assets/javascripts/darkswarm/controllers/checkout/details_controller.js.coffee b/app/assets/javascripts/darkswarm/controllers/checkout/details_controller.js.coffee index db403c0f43..e8a6b1ff23 100644 --- a/app/assets/javascripts/darkswarm/controllers/checkout/details_controller.js.coffee +++ b/app/assets/javascripts/darkswarm/controllers/checkout/details_controller.js.coffee @@ -6,7 +6,7 @@ Darkswarm.controller "DetailsCtrl", ($scope, $timeout, $http, CurrentUser, Authe $scope.login_or_next = (event) -> event.preventDefault() unless CurrentUser.id - $scope.ensureUserIsGuest() + $scope.ensureUserIsGuest($scope.next) return $scope.next() From 495f93206919264498fd92819bf5dd7eb53ef787 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Mon, 7 May 2018 15:54:24 +0100 Subject: [PATCH 046/206] Authentication service class description --- .../darkswarm/services/authentication_service.js.coffee | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/assets/javascripts/darkswarm/services/authentication_service.js.coffee b/app/assets/javascripts/darkswarm/services/authentication_service.js.coffee index a51ccdedee..4b53ee2230 100644 --- a/app/assets/javascripts/darkswarm/services/authentication_service.js.coffee +++ b/app/assets/javascripts/darkswarm/services/authentication_service.js.coffee @@ -1,3 +1,8 @@ +# 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 From 91351c3f7853d6590c8d75a874beaf714eed3beb Mon Sep 17 00:00:00 2001 From: Frank West Date: Mon, 14 May 2018 15:16:30 -0700 Subject: [PATCH 047/206] Confirm first user when seeding database Currently the first user is not confirmed until running the task `openfoodnetwork:dev:load_sample_data`. This task does not need to be run on a minimum implementation of a new server or development setup. We now confirm the first user during seeding. This could be the default email address or the user entered email address entered during seeding. --- db/seeds.rb | 3 +++ lib/tasks/dev.rake | 3 --- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/db/seeds.rb b/db/seeds.rb index 34e812799b..d54987f68f 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -50,3 +50,6 @@ def create_mail_method end create_mail_method + +spree_user = Spree::User.first +spree_user && spree_user.confirm! diff --git a/lib/tasks/dev.rake b/lib/tasks/dev.rake index 84d51466d3..6fa9af6f3e 100644 --- a/lib/tasks/dev.rake +++ b/lib/tasks/dev.rake @@ -9,7 +9,6 @@ namespace :openfoodnetwork do require_relative '../../spec/support/spree/init' task_name = "openfoodnetwork:dev:load_sample_data" - spree_user = Spree::User.find_by_email('spree@example.com') country = Spree::Country.find_by_iso(ENV.fetch('DEFAULT_COUNTRY_CODE')) state = country.states.first @@ -202,8 +201,6 @@ namespace :openfoodnetwork do CreateOrderCycle.new(enterprise2, variants).call EnterpriseRole.create!(user: Spree::User.first, enterprise: enterprise2) - - spree_user.confirm! end end end From 7c68ac9d0c675357c18e0d875f3d27160fd59fe9 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Tue, 15 May 2018 15:00:07 +1000 Subject: [PATCH 048/206] Wait for dialog to appear before using it Travis was failing a few times. This should make the spec more robust. --- spec/features/admin/schedules_spec.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/features/admin/schedules_spec.rb b/spec/features/admin/schedules_spec.rb index d400d5f087..c569475a9d 100644 --- a/spec/features/admin/schedules_spec.rb +++ b/spec/features/admin/schedules_spec.rb @@ -71,6 +71,7 @@ feature 'Schedules', js: true do find('a', text: "Weekly").click end + expect(page).to have_selector "#schedule-dialog" within "#schedule-dialog" do find("#selected-order-cycles .order-cycle", text: oc3.name).click find("#add-remove-buttons a.remove").click From 585bba0e23f52d30124670454f049b1235f79bb5 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Tue, 8 May 2018 14:12:36 +1000 Subject: [PATCH 049/206] Update activemerchant to v1.78 with new root cert Fixes https://github.com/openfoodfoundation/openfoodnetwork/issues/2265. Most changes are in gateways we don't use, I believe. There has been a change in Stripe, but we use another implementation, I guess. --- Gemfile | 4 +++- Gemfile.lock | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index 6304ba0480..3c8451f8fc 100644 --- a/Gemfile +++ b/Gemfile @@ -21,7 +21,9 @@ gem 'spree_auth_devise', github: 'openfoodfoundation/spree_auth_devise', branch: gem 'spree_paypal_express', github: "openfoodfoundation/better_spree_paypal_express", branch: "spree-upgrade-intermediate" #gem 'spree_paypal_express', github: "spree-contrib/better_spree_paypal_express", branch: "1-3-stable" gem 'stripe', '~> 3.3.1' -gem 'activemerchant', '~> 1.71.0' +# 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' diff --git a/Gemfile.lock b/Gemfile.lock index 442d477f35..3bd3cb2763 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -152,7 +152,7 @@ GEM sprockets (~> 2.2.1) active_model_serializers (0.8.3) activemodel (>= 3.0) - activemerchant (1.71.0) + activemerchant (1.78.0) activesupport (>= 3.2.14, < 6.x) builder (>= 2.1.2, < 4.0.0) i18n (>= 0.6.9) @@ -704,7 +704,7 @@ PLATFORMS DEPENDENCIES active_model_serializers - activemerchant (~> 1.71.0) + activemerchant (~> 1.78) acts-as-taggable-on (~> 3.4) andand angular-rails-templates (~> 0.2.0) From 5706cecf26256cee35525f665f4274f34d630fdc Mon Sep 17 00:00:00 2001 From: Rory Trunkhill Date: Thu, 17 May 2018 18:10:35 +0000 Subject: [PATCH 050/206] modify enterprise eror name message #620 --- config/locales/en.yml | 2 +- spec/models/enterprise_spec.rb | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index c13de7611d..968fd0a06b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2208,7 +2208,7 @@ See the %{link} to find out more about %{sitename}'s features and to start using content_configuration_pricing_table: "(TODO: Pricing table)" content_configuration_case_studies: "(TODO: Case studies)" content_configuration_detail: "(TODO: Detail)" - enterprise_name_error: "has already been taken. If this is your enterprise and you would like to claim ownership, please contact the current manager of this profile at %{email}." + enterprise_name_error: "has already been taken. If this is your enterprise and you would like to claim ownership, or if you would like to trade with this enterprise please contact the current manager of this profile at %{email}." enterprise_owner_error: "^%{email} is not permitted to own any more enterprises (limit is %{enterprise_limit})." enterprise_role_uniqueness_error: "^That role is already present." inventory_item_visibility_error: must be true or false diff --git a/spec/models/enterprise_spec.rb b/spec/models/enterprise_spec.rb index 3f598c2ea0..890c1a987d 100644 --- a/spec/models/enterprise_spec.rb +++ b/spec/models/enterprise_spec.rb @@ -129,17 +129,15 @@ describe Enterprise do it "prevents duplicate names for new records" do e = Enterprise.new name: enterprise.name - e.should_not be_valid - e.errors[:name].first.should == - "has already been taken. If this is your enterprise and you would like to claim ownership, please contact the current manager of this profile at owner@example.com." + expect(e).to_not be_valid + expect(e.errors[:name].first).to include I18n.t('enterprise_name_error', email: owner.email) end it "prevents duplicate names for existing records" do e = create(:enterprise, name: 'foo') e.name = enterprise.name - e.should_not be_valid - e.errors[:name].first.should == - "has already been taken. If this is your enterprise and you would like to claim ownership, please contact the current manager of this profile at owner@example.com." + expect(e).to_not be_valid + expect(e.errors[:name].first).to include I18n.t('enterprise_name_error', email: owner.email) end it "does not prohibit the saving of an enterprise with no name clash" do From 6efc0ab802a847d040af4217ea0b7c935e2ee6bb Mon Sep 17 00:00:00 2001 From: stveep Date: Mon, 7 May 2018 11:16:40 +0100 Subject: [PATCH 051/206] Set response headers to disable cache - to avoid back button emptying cart (#1213) --- app/controllers/application_controller.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e19c9bb26b..a4bdc88b8f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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 @@ -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 From 6a52ca8113e005064aa7e97b767fe899111c69ca Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 26 Apr 2018 20:15:01 +1000 Subject: [PATCH 052/206] Remove code duplication --- .../admin/reports_controller_decorator.rb | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index 6c16031c0b..018aeb2d27 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -295,20 +295,21 @@ Spree::Admin::ReportsController.class_eval do 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.map { |report| [report, describe_report(report)] }.to_h 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 @@ -327,6 +328,12 @@ Spree::Admin::ReportsController.class_eval do reports.select { |action| can? action, :report } end + def describe_report(report) + name = I18n.t(:name, scope: [:admin, :reports, report]) + description = I18n.t(:description, scope: [:admin, :reports, report]) + { name: name, description: description } + end + def timestamp Time.zone.now.strftime("%Y%m%d") end From 742e9d2a5f15153f41a3ce920d6805f6a16adbe7 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 26 Apr 2018 20:16:30 +1000 Subject: [PATCH 053/206] Remove more code duplication --- .../admin/reports_controller_decorator.rb | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index 018aeb2d27..cd028e4361 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -311,26 +311,21 @@ Spree::Admin::ReportsController.class_eval do ] reports = all_reports.map { |report| [report, describe_report(report)] }.to_h - 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 } end def describe_report(report) name = I18n.t(:name, scope: [:admin, :reports, report]) - description = I18n.t(:description, 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 From 36b5f0eea75d7a10d5081df2113f5d9ad517bb7f Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 26 Apr 2018 20:18:13 +1000 Subject: [PATCH 054/206] Render only displayed report options --- app/controllers/spree/admin/reports_controller_decorator.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index cd028e4361..1c13916852 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -309,10 +309,8 @@ Spree::Admin::ReportsController.class_eval do :xero_invoices, :packing ] - reports = all_reports.map { |report| [report, describe_report(report)] }.to_h - - # Return only reports the user is authorized to view. - reports.select { |action| can? action, :report } + reports = all_reports.select { |action| can? action, :report } + reports.map { |report| [report, describe_report(report)] }.to_h end def describe_report(report) From f3d542a3ecacc1340fbeb91627b814dc4dba811a Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Fri, 27 Apr 2018 09:17:06 +1000 Subject: [PATCH 055/206] Fix style and spelling --- .../spree/admin/reports_controller_decorator.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index 1c13916852..2a1f64989d 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -53,13 +53,13 @@ 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 + # This action is short because we re-factored it like bosses def customers @report_types = report_types[:customers] @report_type = params[:report_type] @@ -289,8 +289,8 @@ 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 From 99a6afd9cda80e8764e1857acd2fff1bb202c946 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Fri, 27 Apr 2018 14:52:41 +1000 Subject: [PATCH 056/206] Convert specs to RSpec 3.7.0 syntax with Transpec This conversion is done by Transpec 3.3.0 with the following command: transpec spec/controllers/spree/admin/reports_controller_spec.rb * 20 conversions from: obj.should to: expect(obj).to * 14 conversions from: obj.should_not to: expect(obj).not_to * 5 conversions from: == expected to: eq(expected) * 4 conversions from: obj.stub(:message) to: allow(obj).to receive(:message) * 2 conversions from: obj.should_receive(:message) to: expect(obj).to receive(:message) For more details: https://github.com/yujinakayama/transpec#supported-conversions --- .../spree/admin/reports_controller_spec.rb | 82 +++++++++---------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/spec/controllers/spree/admin/reports_controller_spec.rb b/spec/controllers/spree/admin/reports_controller_spec.rb index 82186b9b9d..0585f80670 100644 --- a/spec/controllers/spree/admin/reports_controller_spec.rb +++ b/spec/controllers/spree/admin/reports_controller_spec.rb @@ -70,8 +70,8 @@ describe Spree::Admin::ReportsController, type: :controller do it "shows all orders in order cycles I coordinate" do spree_get :orders_and_fulfillment - resulting_orders.should include orderA1, orderA2 - resulting_orders.should_not include orderB1, orderB2 + expect(resulting_orders).to include orderA1, orderA2 + expect(resulting_orders).not_to include orderB1, orderB2 end end end @@ -84,9 +84,9 @@ describe Spree::Admin::ReportsController, type: :controller do it "only shows orders that I have access to" do spree_get :orders_and_distributors - assigns(:search).result.should include(orderA1, orderB1) - assigns(:search).result.should_not include(orderA2) - assigns(:search).result.should_not include(orderB2) + expect(assigns(:search).result).to include(orderA1, orderB1) + expect(assigns(:search).result).not_to include(orderA2) + expect(assigns(:search).result).not_to include(orderB2) end end @@ -94,9 +94,9 @@ describe Spree::Admin::ReportsController, type: :controller do it "only shows orders that I have access to" do spree_get :bulk_coop - resulting_orders.should include(orderA1, orderB1) - resulting_orders.should_not include(orderA2) - resulting_orders.should_not include(orderB2) + expect(resulting_orders).to include(orderA1, orderB1) + expect(resulting_orders).not_to include(orderA2) + expect(resulting_orders).not_to include(orderB2) end end @@ -104,9 +104,9 @@ describe Spree::Admin::ReportsController, type: :controller do it "only shows orders that I have access to" do spree_get :payments - resulting_orders_prelim.should include(orderA1, orderB1) - resulting_orders_prelim.should_not include(orderA2) - resulting_orders_prelim.should_not include(orderB2) + expect(resulting_orders_prelim).to include(orderA1, orderB1) + expect(resulting_orders_prelim).not_to include(orderA2) + expect(resulting_orders_prelim).not_to include(orderB2) end end @@ -114,15 +114,15 @@ describe Spree::Admin::ReportsController, type: :controller do it "only shows orders that I distribute" do spree_get :orders_and_fulfillment - resulting_orders.should include orderA1, orderB1 - resulting_orders.should_not include orderA2, orderB2 + expect(resulting_orders).to include orderA1, orderB1 + expect(resulting_orders).not_to include orderA2, orderB2 end it "only shows the selected order cycle" do spree_get :orders_and_fulfillment, q: {order_cycle_id_in: [ocA.id.to_s]} - resulting_orders.should include(orderA1) - resulting_orders.should_not include(orderB1) + expect(resulting_orders).to include(orderA1) + expect(resulting_orders).not_to include(orderB1) end end end @@ -150,8 +150,8 @@ describe Spree::Admin::ReportsController, type: :controller do it "only shows product line items that I am supplying" do spree_get :bulk_coop - resulting_products.should include p1 - resulting_products.should_not include p2, p3 + expect(resulting_products).to include p1 + expect(resulting_products).not_to include p2, p3 end end @@ -159,7 +159,7 @@ describe Spree::Admin::ReportsController, type: :controller do it "shows product line items that I am supplying" do spree_get :bulk_coop - resulting_products.should_not include p1, p2, p3 + expect(resulting_products).not_to include p1, p2, p3 end end end @@ -173,15 +173,15 @@ describe Spree::Admin::ReportsController, type: :controller do it "only shows product line items that I am supplying" do spree_get :orders_and_fulfillment - resulting_products.should include p1 - resulting_products.should_not include p2, p3 + expect(resulting_products).to include p1 + expect(resulting_products).not_to include p2, p3 end it "only shows the selected order cycle" do spree_get :orders_and_fulfillment, q: {order_cycle_id_eq: ocA.id} - resulting_orders_prelim.should include(orderA1) - resulting_orders_prelim.should_not include(orderB1) + expect(resulting_orders_prelim).to include(orderA1) + expect(resulting_orders_prelim).not_to include(orderB1) end end @@ -189,7 +189,7 @@ describe Spree::Admin::ReportsController, type: :controller do it "does not show me line_items I supply" do spree_get :orders_and_fulfillment - resulting_products.should_not include p1, p2, p3 + expect(resulting_products).not_to include p1, p2, p3 end end end @@ -200,32 +200,32 @@ describe Spree::Admin::ReportsController, type: :controller do it "should build distributors for the current user" do spree_get :products_and_inventory - assigns(:distributors).should match_array [c1, c2, d1, d2, d3] + expect(assigns(:distributors)).to match_array [c1, c2, d1, d2, d3] end it "builds suppliers for the current user" do spree_get :products_and_inventory - assigns(:suppliers).should match_array [s1, s2, s3] + expect(assigns(:suppliers)).to match_array [s1, s2, s3] end it "builds order cycles for the current user" do spree_get :products_and_inventory - assigns(:order_cycles).should match_array [ocB, ocA] + expect(assigns(:order_cycles)).to match_array [ocB, ocA] end it "assigns report types" do spree_get :products_and_inventory - assigns(:report_types).should == subject.report_types[:products_and_inventory] + expect(assigns(:report_types)).to eq(subject.report_types[:products_and_inventory]) end it "creates a ProductAndInventoryReport" do - OpenFoodNetwork::ProductsAndInventoryReport.should_receive(:new) + expect(OpenFoodNetwork::ProductsAndInventoryReport).to receive(:new) .with(@admin_user, {"test" => "foo", "controller" => "spree/admin/reports", "action" => "products_and_inventory"}) .and_return(report = double(:report)) - report.stub(:header).and_return [] - report.stub(:table).and_return [] + allow(report).to receive(:header).and_return [] + allow(report).to receive(:table).and_return [] spree_get :products_and_inventory, test: "foo" - assigns(:report).should == report + expect(assigns(:report)).to eq(report) end end @@ -233,40 +233,40 @@ describe Spree::Admin::ReportsController, type: :controller do before { login_as_admin } it "should have report types for customers" do - subject.report_types[:customers].should == [ + expect(subject.report_types[:customers]).to eq([ ["Mailing List", :mailing_list], ["Addresses", :addresses] - ] + ]) end it "should build distributors for the current user" do spree_get :customers - assigns(:distributors).should match_array [c1, c2, d1, d2, d3] + expect(assigns(:distributors)).to match_array [c1, c2, d1, d2, d3] end it "builds suppliers for the current user" do spree_get :customers - assigns(:suppliers).should match_array [s1, s2, s3] + expect(assigns(:suppliers)).to match_array [s1, s2, s3] end it "builds order cycles for the current user" do spree_get :customers - assigns(:order_cycles).should match_array [ocB, ocA] + expect(assigns(:order_cycles)).to match_array [ocB, ocA] end it "assigns report types" do spree_get :customers - assigns(:report_types).should == subject.report_types[:customers] + expect(assigns(:report_types)).to eq(subject.report_types[:customers]) end it "creates a CustomersReport" do - OpenFoodNetwork::CustomersReport.should_receive(:new) + expect(OpenFoodNetwork::CustomersReport).to receive(:new) .with(@admin_user, {"test" => "foo", "controller" => "spree/admin/reports", "action" => "customers"}) .and_return(report = double(:report)) - report.stub(:header).and_return [] - report.stub(:table).and_return [] + allow(report).to receive(:header).and_return [] + allow(report).to receive(:table).and_return [] spree_get :customers, test: "foo" - assigns(:report).should == report + expect(assigns(:report)).to eq(report) end end end From 682b92e6175aea339b016da7cbd57040c4181a42 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Fri, 27 Apr 2018 15:53:21 +1000 Subject: [PATCH 057/206] Avoid deprication warning for using stub --- spec/support/controller_helper.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/support/controller_helper.rb b/spec/support/controller_helper.rb index de0720283a..f49907dbe0 100644 --- a/spec/support/controller_helper.rb +++ b/spec/support/controller_helper.rb @@ -7,7 +7,7 @@ module OpenFoodNetwork user end - controller.stub spree_current_user: @admin_user + allow(controller).to receive_messages(spree_current_user: @admin_user) end def login_as_enterprise_user(enterprises) @@ -20,7 +20,7 @@ module OpenFoodNetwork user end - controller.stub spree_current_user: @enterprise_user + allow(controller).to receive_messages(spree_current_user: @enterprise_user) end end end From 5aef7031d25061a9d0f2939b987785bd68e0d87d Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 3 May 2018 13:14:35 +1000 Subject: [PATCH 058/206] Convert specs to RSpec 3.7.0 syntax with Transpec This conversion is done by Transpec 3.3.0 with the following command: transpec spec/features/admin/reports_spec.rb * 40 conversions from: obj.should to: expect(obj).to * 10 conversions from: == expected to: eq(expected) * 3 conversions from: obj.should_not to: expect(obj).not_to For more details: https://github.com/yujinakayama/transpec#supported-conversions --- spec/features/admin/reports_spec.rb | 102 ++++++++++++++-------------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/spec/features/admin/reports_spec.rb b/spec/features/admin/reports_spec.rb index 8a8acc1da1..46d7d24579 100644 --- a/spec/features/admin/reports_spec.rb +++ b/spec/features/admin/reports_spec.rb @@ -17,16 +17,16 @@ feature %q{ it "does not show super admin only reports" do login_to_admin_as user click_link "Reports" - page.should_not have_content "Sales Total" - page.should_not have_content "Users & Enterprises" + expect(page).not_to have_content "Sales Total" + expect(page).not_to have_content "Users & Enterprises" end end context "As an admin user" do it "shows the super admin only reports" do login_to_admin_section click_link "Reports" - page.should have_content "Sales Total" - page.should have_content "Users & Enterprises" + expect(page).to have_content "Sales Total" + expect(page).to have_content "Users & Enterprises" end end end @@ -42,9 +42,9 @@ feature %q{ rows = find("table#listing_customers").all("thead tr") table = rows.map { |r| r.all("th").map { |c| c.text.strip } } - table.sort.should == [ + expect(table.sort).to eq([ ["Email", "First Name", "Last Name", "Suburb"] - ].sort + ].sort) end scenario "customers report" do @@ -53,9 +53,9 @@ feature %q{ rows = find("table#listing_customers").all("thead tr") table = rows.map { |r| r.all("th").map { |c| c.text.strip } } - table.sort.should == [ + expect(table.sort).to eq([ ["First Name", "Last Name", "Billing Address", "Email", "Phone", "Hub", "Hub Address", "Shipping Method"] - ].sort + ].sort) end end @@ -69,18 +69,18 @@ feature %q{ click_link "Payment Methods Report" rows = find("table#listing_ocm_orders").all("thead tr") table = rows.map { |r| r.all("th").map { |c| c.text.strip } } - table.sort.should == [ + expect(table.sort).to eq([ ["First Name", "Last Name", "Hub", "Hub Code", "Email", "Phone", "Shipping Method", "Payment Method", "Amount", "Balance"] - ].sort + ].sort) end scenario "delivery report" do click_link "Delivery Report" rows = find("table#listing_ocm_orders").all("thead tr") table = rows.map { |r| r.all("th").map { |c| c.text.strip } } - table.sort.should == [ + expect(table.sort).to eq([ ["First Name", "Last Name", "Hub", "Hub Code", "Delivery Address", "Delivery Postcode", "Phone", "Shipping Method", "Payment Method", "Amount", "Balance", "Temp Controlled Items?", "Special Instructions"] - ].sort + ].sort) end end @@ -121,10 +121,10 @@ feature %q{ rows = find("table#listing_orders.index").all("thead tr") table = rows.map { |r| r.all("th").map { |c| c.text.strip } } - table.sort.should == [ + expect(table.sort).to eq([ ["Hub", "Code", "First Name", "Last Name", "Supplier", "Product", "Variant", "Quantity", "TempControlled?"] - ].sort - page.should have_selector 'table#listing_orders tbody tr', count: 5 # Totals row per order + ].sort) + expect(page).to have_selector 'table#listing_orders tbody tr', count: 5 # Totals row per order end scenario "Pack By Supplier" do @@ -136,10 +136,10 @@ feature %q{ rows = find("table#listing_orders").all("thead tr") table = rows.map { |r| r.all("th").map { |c| c.text.strip } } - table.sort.should == [ + expect(table.sort).to eq([ ["Hub", "Supplier", "Code", "First Name", "Last Name", "Product", "Variant", "Quantity", "TempControlled?"] - ].sort - all('table#listing_orders tbody tr').count.should == 4 # Totals row per supplier + ].sort) + expect(all('table#listing_orders tbody tr').count).to eq(4) # Totals row per supplier end end @@ -149,7 +149,7 @@ feature %q{ click_link 'Reports' click_link 'Orders And Distributors' - page.should have_content 'Order date' + expect(page).to have_content 'Order date' end scenario "bulk co-op report" do @@ -157,7 +157,7 @@ feature %q{ click_link 'Reports' click_link 'Bulk Co-Op' - page.should have_content 'Supplier' + expect(page).to have_content 'Supplier' end scenario "payments reports" do @@ -165,7 +165,7 @@ feature %q{ click_link 'Reports' click_link 'Payment Reports' - page.should have_content 'Payment State' + expect(page).to have_content 'Payment State' end describe "sales tax report" do @@ -204,28 +204,28 @@ feature %q{ it "reports" do # Then it should give me access only to managed enterprises - page.should have_select 'q_distributor_id_eq', with_options: [user1.enterprises.first.name] - page.should_not have_select 'q_distributor_id_eq', with_options: [user2.enterprises.first.name] + expect(page).to have_select 'q_distributor_id_eq', with_options: [user1.enterprises.first.name] + expect(page).not_to have_select 'q_distributor_id_eq', with_options: [user2.enterprises.first.name] # When I filter to just one distributor select user1.enterprises.first.name, from: 'q_distributor_id_eq' click_button 'Search' # Then I should see the relevant order - page.should have_content "#{order1.number}" + expect(page).to have_content "#{order1.number}" # And the totals and sales tax should be correct - page.should have_content "1512.99" # items total - page.should have_content "1500.45" # taxable items total - page.should have_content "250.08" # sales tax - page.should have_content "20.0" # enterprise fee tax + expect(page).to have_content "1512.99" # items total + expect(page).to have_content "1500.45" # taxable items total + expect(page).to have_content "250.08" # sales tax + expect(page).to have_content "20.0" # enterprise fee tax # And the shipping cost and tax should be correct - page.should have_content "100.55" # shipping cost - page.should have_content "16.76" # shipping tax + expect(page).to have_content "100.55" # shipping cost + expect(page).to have_content "16.76" # shipping tax # And the total tax should be correct - page.should have_content "286.84" # total tax + expect(page).to have_content "286.84" # total tax end end @@ -235,7 +235,7 @@ feature %q{ click_link 'Reports' click_link 'Orders & Fulfillment Reports' - page.should have_content 'Supplier' + expect(page).to have_content 'Supplier' end context "with two orders on the same day at different times" do @@ -267,7 +267,7 @@ feature %q{ click_button 'Search' # Then I should see the rows for the first order but not the second - all('table#listing_orders tbody tr').count.should == 2 # Two rows per order + expect(all('table#listing_orders tbody tr').count).to eq(2) # Two rows per order end end @@ -279,7 +279,7 @@ feature %q{ login_to_admin_section visit spree.orders_and_fulfillment_admin_reports_path - page.should have_content "My Order Cycle" + expect(page).to have_content "My Order Cycle" end end @@ -311,14 +311,14 @@ feature %q{ login_to_admin_section click_link 'Reports' - page.should have_content "All products" - page.should have_content "Inventory (on hand)" + expect(page).to have_content "All products" + expect(page).to have_content "Inventory (on hand)" click_link 'Products & Inventory' - page.should have_content "Supplier" - page.should have_table_row ["Supplier", "Producer Suburb", "Product", "Product Properties", "Taxons", "Variant Value", "Price", "Group Buy Unit Quantity", "Amount", "SKU"].map(&:upcase) - page.should have_table_row [product1.supplier.name, product1.supplier.address.city, "Product Name", product1.properties.map(&:presentation).join(", "), product1.primary_taxon.name, "Test", "100.0", product1.group_buy_unit_size.to_s, "", "sku1"] - page.should have_table_row [product1.supplier.name, product1.supplier.address.city, "Product Name", product1.properties.map(&:presentation).join(", "), product1.primary_taxon.name, "Something", "80.0", product1.group_buy_unit_size.to_s, "", "sku2"] - page.should have_table_row [product2.supplier.name, product1.supplier.address.city, "Product 2", product1.properties.map(&:presentation).join(", "), product2.primary_taxon.name, "100g", "99.0", product1.group_buy_unit_size.to_s, "", "product_sku"] + expect(page).to have_content "Supplier" + expect(page).to have_table_row ["Supplier", "Producer Suburb", "Product", "Product Properties", "Taxons", "Variant Value", "Price", "Group Buy Unit Quantity", "Amount", "SKU"].map(&:upcase) + expect(page).to have_table_row [product1.supplier.name, product1.supplier.address.city, "Product Name", product1.properties.map(&:presentation).join(", "), product1.primary_taxon.name, "Test", "100.0", product1.group_buy_unit_size.to_s, "", "sku1"] + expect(page).to have_table_row [product1.supplier.name, product1.supplier.address.city, "Product Name", product1.properties.map(&:presentation).join(", "), product1.primary_taxon.name, "Something", "80.0", product1.group_buy_unit_size.to_s, "", "sku2"] + expect(page).to have_table_row [product2.supplier.name, product1.supplier.address.city, "Product 2", product1.properties.map(&:presentation).join(", "), product2.primary_taxon.name, "100g", "99.0", product1.group_buy_unit_size.to_s, "", "product_sku"] end it "shows the LettuceShare report" do @@ -326,8 +326,8 @@ feature %q{ click_link 'Reports' click_link 'LettuceShare' - page.should have_table_row ['PRODUCT', 'Description', 'Qty', 'Pack Size', 'Unit', 'Unit Price', 'Total', 'GST incl.', 'Grower and growing method', 'Taxon'].map(&:upcase) - page.should have_table_row ['Product 2', '100g', '', '100', 'g', '99.0', '', '0', 'Supplier Name (Organic - NASAA 12345)', 'Taxon Name'] + expect(page).to have_table_row ['PRODUCT', 'Description', 'Qty', 'Pack Size', 'Unit', 'Unit Price', 'Total', 'GST incl.', 'Grower and growing method', 'Taxon'].map(&:upcase) + expect(page).to have_table_row ['Product 2', '100g', '', '100', 'g', '99.0', '', '0', 'Supplier Name (Organic - NASAA 12345)', 'Taxon Name'] end end @@ -349,7 +349,7 @@ feature %q{ rows = find("table#users_and_enterprises").all("tr") table = rows.map { |r| r.all("th,td").map { |c| c.text.strip }[0..2] } - table.sort.should == [ + expect(table.sort).to eq([ [ "User", "Relationship", "Enterprise" ], [ enterprise1.owner.email, "owns", enterprise1.name ], [ enterprise1.owner.email, "manages", enterprise1.name ], @@ -358,7 +358,7 @@ feature %q{ [ enterprise3.owner.email, "owns", enterprise3.name ], [ enterprise3.owner.email, "manages", enterprise3.name ], [ enterprise1.owner.email, "manages", enterprise3.name ] - ].sort + ].sort) end it "filters the list" do @@ -370,10 +370,10 @@ feature %q{ rows = find("table#users_and_enterprises").all("tr") table = rows.map { |r| r.all("th,td").map { |c| c.text.strip }[0..2] } - table.sort.should == [ + expect(table.sort).to eq([ [ "User", "Relationship", "Enterprise" ], [ enterprise1.owner.email, "manages", enterprise3.name ] - ].sort + ].sort) end end @@ -421,7 +421,7 @@ feature %q{ end it "shows Xero invoices report" do - xero_invoice_table.should match_table [ + expect(xero_invoice_table).to match_table [ xero_invoice_header, xero_invoice_summary_row('Total untaxable produce (no tax)', 12.54, 'GST Free Income'), xero_invoice_summary_row('Total taxable produce (tax inclusive)', 1500.45, 'GST on Income'), @@ -442,7 +442,7 @@ feature %q{ opts = {invoice_number: '5', invoice_date: '2015-02-12', due_date: '2015-03-12', account_code: 'abc123'} - xero_invoice_table.should match_table [ + expect(xero_invoice_table).to match_table [ xero_invoice_header, xero_invoice_summary_row('Total untaxable produce (no tax)', 12.54, 'GST Free Income', opts), xero_invoice_summary_row('Total taxable produce (tax inclusive)', 1500.45, 'GST on Income', opts), @@ -460,7 +460,7 @@ feature %q{ opts = {} - xero_invoice_table.should match_table [ + expect(xero_invoice_table).to match_table [ xero_invoice_header, xero_invoice_li_row(line_item1), xero_invoice_li_row(line_item2), @@ -495,7 +495,7 @@ feature %q{ opts = {} - xero_invoice_table.should match_table [ + expect(xero_invoice_table).to match_table [ xero_invoice_header, xero_invoice_account_invoice_row(adjustment) ] From 28d66ad3e0b89e777ccf502695456848de12510e Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 3 May 2018 14:09:49 +1000 Subject: [PATCH 059/206] Activate dormant specs It looks like a typo prevented a bunch of specs from being run. --- spec/features/admin/reports_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/features/admin/reports_spec.rb b/spec/features/admin/reports_spec.rb index 46d7d24579..23788a7e3f 100644 --- a/spec/features/admin/reports_spec.rb +++ b/spec/features/admin/reports_spec.rb @@ -416,7 +416,7 @@ feature %q{ around do |example| Timecop.travel(Time.zone.local(2015, 4, 26, 14, 0, 0)) do - example.yield + example.run end end From 41bb5e4e96fce6c193a581ab2be9c1c32e3a7a3f Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Fri, 27 Apr 2018 18:06:26 +1000 Subject: [PATCH 060/206] Unify report rendering --- .../admin/reports_controller_decorator.rb | 23 +++---------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index 2a1f64989d..088d81281a 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -134,33 +134,16 @@ Spree::Admin::ReportsController.class_eval do 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 + 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 + render_report(@report.header, @report.table, params[:csv], "sales_tax.csv") end def bulk_coop From 031c4d417e9456a431a595829d28d1bd5ebc0355 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 3 May 2018 10:22:49 +1000 Subject: [PATCH 061/206] Simplify report rendering --- .../admin/reports_controller_decorator.rb | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index 088d81281a..bb3c942510 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -237,19 +237,19 @@ Spree::Admin::ReportsController.class_eval do render_report(@report.header, @report.table, params[:csv], "xero_invoices_#{timestamp}.csv") end + private + 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 + send_data csv_report(header, table), filename: csv_file_name if create_csv + # Rendering HTML is the default. end - private + def csv_report(header, table) + CSV.generate do |csv| + csv << header + table.each { |row| csv << row } + end + end def prepare_date_params(params) # -- Prepare parameters From fcd41c67fa5b8a4c1e7e08ce927f895d98ab7347 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 3 May 2018 10:27:44 +1000 Subject: [PATCH 062/206] Add logic for showing empty reports initially --- app/controllers/spree/admin/reports_controller_decorator.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index bb3c942510..c30518fe66 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -239,6 +239,12 @@ Spree::Admin::ReportsController.class_eval do private + # We don't want to render data unless search params are supplied. + # Compiling data can take a long time. + def render_content? + request.post? + end + def render_report(header, table, create_csv, csv_file_name) send_data csv_report(header, table), filename: csv_file_name if create_csv # Rendering HTML is the default. From 8393b1d4c09495fbecd665e735ed47a45d9c3b4c Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 3 May 2018 10:42:33 +1000 Subject: [PATCH 063/206] Don't compile user-enterprises report before search --- .../spree/admin/reports_controller_decorator.rb | 3 +-- .../users_and_enterprises_report.rb | 4 +++- .../spree/admin/reports_controller_spec.rb | 16 ++++++++++++++++ spec/features/admin/reports_spec.rb | 2 ++ .../users_and_enterprises_report_spec.rb | 12 ++++++------ 5 files changed, 28 insertions(+), 9 deletions(-) diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index c30518fe66..6ca4b6019e 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -218,8 +218,7 @@ Spree::Admin::ReportsController.class_eval do 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 diff --git a/lib/open_food_network/users_and_enterprises_report.rb b/lib/open_food_network/users_and_enterprises_report.rb index 4f816af8bd..89dba0b73a 100644 --- a/lib/open_food_network/users_and_enterprises_report.rb +++ b/lib/open_food_network/users_and_enterprises_report.rb @@ -1,8 +1,9 @@ module OpenFoodNetwork class UsersAndEnterprisesReport attr_reader :params - def initialize(params = {}) + def initialize(params = {}, compile_table = false) @params = params + @compile_table = compile_table # Convert arrays of ids to comma delimited strings @params[:enterprise_id_in] = @params[:enterprise_id_in].join(',') if @params[:enterprise_id_in].kind_of? Array @@ -22,6 +23,7 @@ module OpenFoodNetwork end def table + return [] unless @compile_table users_and_enterprises.map do |uae| [ uae["user_email"], diff --git a/spec/controllers/spree/admin/reports_controller_spec.rb b/spec/controllers/spree/admin/reports_controller_spec.rb index 0585f80670..b9d73d48be 100644 --- a/spec/controllers/spree/admin/reports_controller_spec.rb +++ b/spec/controllers/spree/admin/reports_controller_spec.rb @@ -269,4 +269,20 @@ describe Spree::Admin::ReportsController, type: :controller do expect(assigns(:report)).to eq(report) end end + + context "Admin" do + before { login_as_admin } + + describe "users_and_enterprises" do + it "shows report search forms" do + spree_get :users_and_enterprises + expect(assigns(:report).table).to eq [] + end + + it "shows report data" do + spree_post :users_and_enterprises + expect(assigns(:report).table.empty?).to be false + end + end + end end diff --git a/spec/features/admin/reports_spec.rb b/spec/features/admin/reports_spec.rb index 23788a7e3f..079c0c2b5e 100644 --- a/spec/features/admin/reports_spec.rb +++ b/spec/features/admin/reports_spec.rb @@ -346,6 +346,8 @@ feature %q{ end it "shows users and enterprises report" do + click_button "Search" + rows = find("table#users_and_enterprises").all("tr") table = rows.map { |r| r.all("th,td").map { |c| c.text.strip }[0..2] } diff --git a/spec/lib/open_food_network/users_and_enterprises_report_spec.rb b/spec/lib/open_food_network/users_and_enterprises_report_spec.rb index 906bef1eef..bd33825722 100644 --- a/spec/lib/open_food_network/users_and_enterprises_report_spec.rb +++ b/spec/lib/open_food_network/users_and_enterprises_report_spec.rb @@ -7,7 +7,7 @@ module OpenFoodNetwork describe "users_and_enterprises" do let!(:owners_and_enterprises) { double(:owners_and_enterprises) } let!(:managers_and_enterprises) { double(:managers_and_enterprises) } - let!(:subject) { OpenFoodNetwork::UsersAndEnterprisesReport.new {} } + let!(:subject) { OpenFoodNetwork::UsersAndEnterprisesReport.new({}, true) } before do subject.stub(:owners_and_enterprises) { owners_and_enterprises } @@ -24,7 +24,7 @@ module OpenFoodNetwork end describe "sorting results" do - let!(:subject) { OpenFoodNetwork::UsersAndEnterprisesReport.new {} } + let!(:subject) { OpenFoodNetwork::UsersAndEnterprisesReport.new({}, true) } it "sorts by creation date" do uae_mock = [ @@ -68,7 +68,7 @@ module OpenFoodNetwork describe "for owners and enterprises" do describe "by enterprise id" do let!(:params) { { enterprise_id_in: [enterprise1.id.to_s] } } - let!(:subject) { OpenFoodNetwork::UsersAndEnterprisesReport.new params } + let!(:subject) { OpenFoodNetwork::UsersAndEnterprisesReport.new params, true } it "excludes enterprises that are not explicitly requested" do results = subject.owners_and_enterprises.to_a.map{ |oae| oae["name"] } @@ -79,7 +79,7 @@ module OpenFoodNetwork describe "by user id" do let!(:params) { { user_id_in: [enterprise1.owner.id.to_s] } } - let!(:subject) { OpenFoodNetwork::UsersAndEnterprisesReport.new params } + let!(:subject) { OpenFoodNetwork::UsersAndEnterprisesReport.new params, true } it "excludes enterprises that are not explicitly requested" do results = subject.owners_and_enterprises.to_a.map{ |oae| oae["name"] } @@ -92,7 +92,7 @@ module OpenFoodNetwork describe "for managers and enterprises" do describe "by enterprise id" do let!(:params) { { enterprise_id_in: [enterprise1.id.to_s] } } - let!(:subject) { OpenFoodNetwork::UsersAndEnterprisesReport.new params } + let!(:subject) { OpenFoodNetwork::UsersAndEnterprisesReport.new params, true } it "excludes enterprises that are not explicitly requested" do results = subject.managers_and_enterprises.to_a.map{ |mae| mae["name"] } @@ -105,7 +105,7 @@ module OpenFoodNetwork let!(:manager1) { create_enterprise_user } let!(:manager2) { create_enterprise_user } let!(:params) { { user_id_in: [manager1.id.to_s] } } - let!(:subject) { OpenFoodNetwork::UsersAndEnterprisesReport.new params } + let!(:subject) { OpenFoodNetwork::UsersAndEnterprisesReport.new params, true } before do enterprise1.enterprise_roles.build(user: manager1).save From 14e7cdd138d560a681acf38dbbe8d73dc010df9b Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 3 May 2018 11:11:34 +1000 Subject: [PATCH 064/206] Don't compile customers report data before search Distributors, suppliers and order cycles are still loaded for the search form. --- app/controllers/spree/admin/reports_controller_decorator.rb | 2 +- lib/open_food_network/customers_report.rb | 4 +++- spec/controllers/spree/admin/reports_controller_spec.rb | 2 +- spec/lib/open_food_network/customers_report_spec.rb | 4 ++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index 6ca4b6019e..f5ab7ee597 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -63,7 +63,7 @@ Spree::Admin::ReportsController.class_eval do 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 diff --git a/lib/open_food_network/customers_report.rb b/lib/open_food_network/customers_report.rb index a84e1ce416..0a605b3596 100644 --- a/lib/open_food_network/customers_report.rb +++ b/lib/open_food_network/customers_report.rb @@ -1,9 +1,10 @@ module OpenFoodNetwork class CustomersReport attr_reader :params - def initialize(user, params = {}) + def initialize(user, params = {}, compile_table = false) @params = params @user = user + @compile_table = compile_table end def header @@ -25,6 +26,7 @@ module OpenFoodNetwork end def table + return [] unless @compile_table orders.map do |order| if is_mailing_list? [order.email, diff --git a/spec/controllers/spree/admin/reports_controller_spec.rb b/spec/controllers/spree/admin/reports_controller_spec.rb index b9d73d48be..a691126da1 100644 --- a/spec/controllers/spree/admin/reports_controller_spec.rb +++ b/spec/controllers/spree/admin/reports_controller_spec.rb @@ -261,7 +261,7 @@ describe Spree::Admin::ReportsController, type: :controller do it "creates a CustomersReport" do expect(OpenFoodNetwork::CustomersReport).to receive(:new) - .with(@admin_user, {"test" => "foo", "controller" => "spree/admin/reports", "action" => "customers"}) + .with(@admin_user, {"test" => "foo", "controller" => "spree/admin/reports", "action" => "customers"}, false) .and_return(report = double(:report)) allow(report).to receive(:header).and_return [] allow(report).to receive(:table).and_return [] diff --git a/spec/lib/open_food_network/customers_report_spec.rb b/spec/lib/open_food_network/customers_report_spec.rb index 289a32929e..c17d54368d 100644 --- a/spec/lib/open_food_network/customers_report_spec.rb +++ b/spec/lib/open_food_network/customers_report_spec.rb @@ -8,7 +8,7 @@ module OpenFoodNetwork user.spree_roles << Spree::Role.find_or_create_by_name!("admin") user end - subject { CustomersReport.new user } + subject { CustomersReport.new user, {}, true } describe "mailing list report" do before do @@ -81,7 +81,7 @@ module OpenFoodNetwork user end - subject { CustomersReport.new user } + subject { CustomersReport.new user, {}, true } describe "fetching orders" do let(:supplier) { create(:supplier_enterprise) } From b8ca37e9d2740a015ecc89cd0e17387131db6d06 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 3 May 2018 14:45:53 +1000 Subject: [PATCH 065/206] Don't compile xero report data before search --- app/controllers/spree/admin/reports_controller_decorator.rb | 2 +- lib/open_food_network/xero_invoices_report.rb | 4 +++- spec/features/admin/reports_spec.rb | 1 + spec/lib/open_food_network/xero_invoices_report_spec.rb | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index f5ab7ee597..2f534f044d 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -232,7 +232,7 @@ Spree::Admin::ReportsController.class_eval do @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 diff --git a/lib/open_food_network/xero_invoices_report.rb b/lib/open_food_network/xero_invoices_report.rb index 43f278c73e..5e351871e8 100644 --- a/lib/open_food_network/xero_invoices_report.rb +++ b/lib/open_food_network/xero_invoices_report.rb @@ -1,6 +1,6 @@ module OpenFoodNetwork class XeroInvoicesReport - def initialize(user, opts={}) + def initialize(user, opts = {}, compile_table = false) @user = user @opts = opts. @@ -9,6 +9,7 @@ module OpenFoodNetwork invoice_date: Time.zone.today, due_date: Time.zone.today + 1.month, account_code: 'food sales'}) + @compile_table = compile_table end def header @@ -26,6 +27,7 @@ module OpenFoodNetwork end def table + return [] unless @compile_table rows = [] orders.each_with_index do |order, i| diff --git a/spec/features/admin/reports_spec.rb b/spec/features/admin/reports_spec.rb index 079c0c2b5e..202e0fc5a7 100644 --- a/spec/features/admin/reports_spec.rb +++ b/spec/features/admin/reports_spec.rb @@ -423,6 +423,7 @@ feature %q{ end it "shows Xero invoices report" do + click_button "Search" expect(xero_invoice_table).to match_table [ xero_invoice_header, xero_invoice_summary_row('Total untaxable produce (no tax)', 12.54, 'GST Free Income'), diff --git a/spec/lib/open_food_network/xero_invoices_report_spec.rb b/spec/lib/open_food_network/xero_invoices_report_spec.rb index af67f8bed9..f2dce1fb5a 100644 --- a/spec/lib/open_food_network/xero_invoices_report_spec.rb +++ b/spec/lib/open_food_network/xero_invoices_report_spec.rb @@ -2,7 +2,7 @@ require 'open_food_network/xero_invoices_report' module OpenFoodNetwork describe XeroInvoicesReport do - subject { XeroInvoicesReport.new user } + subject { XeroInvoicesReport.new user, {}, true } let(:user) { create(:user) } From 983e128d90b4670007c1d60ea783b6cf7848e244 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 3 May 2018 15:07:00 +1000 Subject: [PATCH 066/206] Half test run time by loading less data On my computer it took 3 seconds to load all four orders with all the attached order cycles and enterprises. Runtime before: 56.38 seconds Runtime after: 25.14 seconds This really speeds up developing reports. --- .../spree/admin/reports_controller_spec.rb | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/spec/controllers/spree/admin/reports_controller_spec.rb b/spec/controllers/spree/admin/reports_controller_spec.rb index a691126da1..27e0f4bcae 100644 --- a/spec/controllers/spree/admin/reports_controller_spec.rb +++ b/spec/controllers/spree/admin/reports_controller_spec.rb @@ -23,7 +23,7 @@ describe Spree::Admin::ReportsController, type: :controller do let(:ocB) { create(:simple_order_cycle, coordinator: c2, distributors: [d1, d2], suppliers: [s1, s2, s3], variants: [p2.master]) } # orderA1 can only be accessed by s1, s3 and d1 - let!(:orderA1) do + let(:orderA1) do order = create(:order, distributor: d1, bill_address: ba, ship_address: sa, special_instructions: si, order_cycle: ocA) order.line_items << create(:line_item, variant: p1.master) order.line_items << create(:line_item, variant: p3.master) @@ -32,7 +32,7 @@ describe Spree::Admin::ReportsController, type: :controller do order end # orderA2 can only be accessed by s2 and d2 - let!(:orderA2) do + let(:orderA2) do order = create(:order, distributor: d2, bill_address: ba, ship_address: sa, special_instructions: si, order_cycle: ocA) order.line_items << create(:line_item, variant: p2.master) order.finalize! @@ -40,7 +40,7 @@ describe Spree::Admin::ReportsController, type: :controller do order end # orderB1 can only be accessed by s1, s3 and d1 - let!(:orderB1) do + let(:orderB1) do order = create(:order, distributor: d1, bill_address: ba, ship_address: sa, special_instructions: si, order_cycle: ocB) order.line_items << create(:line_item, variant: p1.master) order.line_items << create(:line_item, variant: p3.master) @@ -49,7 +49,7 @@ describe Spree::Admin::ReportsController, type: :controller do order end # orderB2 can only be accessed by s2 and d2 - let!(:orderB2) do + let(:orderB2) do order = create(:order, distributor: d2, bill_address: ba, ship_address: sa, special_instructions: si, order_cycle: ocB) order.line_items << create(:line_item, variant: p2.master) order.finalize! @@ -68,6 +68,9 @@ describe Spree::Admin::ReportsController, type: :controller do describe 'Orders & Fulfillment' do it "shows all orders in order cycles I coordinate" do + # create test objects + [orderA1, orderA2, orderB1, orderB2] + spree_get :orders_and_fulfillment expect(resulting_orders).to include orderA1, orderA2 @@ -82,6 +85,7 @@ describe Spree::Admin::ReportsController, type: :controller do describe 'Orders and Distributors' do it "only shows orders that I have access to" do + [orderA1, orderA2, orderB1, orderB2] spree_get :orders_and_distributors expect(assigns(:search).result).to include(orderA1, orderB1) @@ -92,6 +96,7 @@ describe Spree::Admin::ReportsController, type: :controller do describe 'Bulk Coop' do it "only shows orders that I have access to" do + [orderA1, orderA2, orderB1, orderB2] spree_get :bulk_coop expect(resulting_orders).to include(orderA1, orderB1) @@ -102,6 +107,7 @@ describe Spree::Admin::ReportsController, type: :controller do describe 'Payments' do it "only shows orders that I have access to" do + [orderA1, orderA2, orderB1, orderB2] spree_get :payments expect(resulting_orders_prelim).to include(orderA1, orderB1) @@ -112,6 +118,7 @@ describe Spree::Admin::ReportsController, type: :controller do describe 'Orders & Fulfillment' do it "only shows orders that I distribute" do + [orderA1, orderA2, orderB1, orderB2] spree_get :orders_and_fulfillment expect(resulting_orders).to include orderA1, orderB1 @@ -119,6 +126,7 @@ describe Spree::Admin::ReportsController, type: :controller do end it "only shows the selected order cycle" do + [orderA1, orderB1] spree_get :orders_and_fulfillment, q: {order_cycle_id_in: [ocA.id.to_s]} expect(resulting_orders).to include(orderA1) @@ -144,6 +152,7 @@ describe Spree::Admin::ReportsController, type: :controller do describe 'Bulk Coop' do context "where I have granted P-OC to the distributor" do before do + [orderA1, orderA2] create(:enterprise_relationship, parent: s1, child: d1, permissions_list: [:add_to_order_cycle]) end @@ -171,6 +180,7 @@ describe Spree::Admin::ReportsController, type: :controller do end it "only shows product line items that I am supplying" do + [orderA1, orderA2] spree_get :orders_and_fulfillment expect(resulting_products).to include p1 @@ -178,6 +188,7 @@ describe Spree::Admin::ReportsController, type: :controller do end it "only shows the selected order cycle" do + [orderA1, orderB1] spree_get :orders_and_fulfillment, q: {order_cycle_id_eq: ocA.id} expect(resulting_orders_prelim).to include(orderA1) @@ -187,6 +198,7 @@ describe Spree::Admin::ReportsController, type: :controller do context "where I have not granted P-OC to the distributor" do it "does not show me line_items I supply" do + [orderA1, orderA2] spree_get :orders_and_fulfillment expect(resulting_products).not_to include p1, p2, p3 @@ -199,16 +211,19 @@ describe Spree::Admin::ReportsController, type: :controller do before { login_as_admin } it "should build distributors for the current user" do + [c1, c2, s1, d1, d2, d3] spree_get :products_and_inventory expect(assigns(:distributors)).to match_array [c1, c2, d1, d2, d3] end it "builds suppliers for the current user" do + [s1, s2, s3, d1] spree_get :products_and_inventory expect(assigns(:suppliers)).to match_array [s1, s2, s3] end it "builds order cycles for the current user" do + [ocA, ocB] spree_get :products_and_inventory expect(assigns(:order_cycles)).to match_array [ocB, ocA] end @@ -240,16 +255,19 @@ describe Spree::Admin::ReportsController, type: :controller do end it "should build distributors for the current user" do + [c1, c2, s1, d1, d2, d3] spree_get :customers expect(assigns(:distributors)).to match_array [c1, c2, d1, d2, d3] end it "builds suppliers for the current user" do + [s1, s2, s3, d1] spree_get :customers expect(assigns(:suppliers)).to match_array [s1, s2, s3] end it "builds order cycles for the current user" do + [ocA, ocB] spree_get :customers expect(assigns(:order_cycles)).to match_array [ocB, ocA] end @@ -280,6 +298,7 @@ describe Spree::Admin::ReportsController, type: :controller do end it "shows report data" do + [c1] spree_post :users_and_enterprises expect(assigns(:report).table.empty?).to be false end From d464216027c4e086e3bf9b0b7926e373e5cbd413 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 3 May 2018 15:25:13 +1000 Subject: [PATCH 067/206] Don't compile Sales Tax report data before search --- .../spree/admin/reports_controller_decorator.rb | 2 +- lib/open_food_network/sales_tax_report.rb | 4 +++- spec/controllers/spree/admin/reports_controller_spec.rb | 7 +++++++ spec/lib/open_food_network/sales_tax_report_spec.rb | 2 +- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index 2f534f044d..e479982609 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -142,7 +142,7 @@ Spree::Admin::ReportsController.class_eval do 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 + @report = OpenFoodNetwork::SalesTaxReport.new spree_current_user, params, render_content? render_report(@report.header, @report.table, params[:csv], "sales_tax.csv") end diff --git a/lib/open_food_network/sales_tax_report.rb b/lib/open_food_network/sales_tax_report.rb index 061fd4b64a..02f0651ecf 100644 --- a/lib/open_food_network/sales_tax_report.rb +++ b/lib/open_food_network/sales_tax_report.rb @@ -3,9 +3,10 @@ module OpenFoodNetwork include Spree::ReportsHelper attr_accessor :user, :params - def initialize(user, params) + def initialize(user, params, render_table) @user = user @params = params + @render_table = render_table end def header @@ -42,6 +43,7 @@ module OpenFoodNetwork end def table + return [] unless @render_table case params[:report_type] when "tax_rates" orders.map do |order| diff --git a/spec/controllers/spree/admin/reports_controller_spec.rb b/spec/controllers/spree/admin/reports_controller_spec.rb index 27e0f4bcae..2e0e70d65e 100644 --- a/spec/controllers/spree/admin/reports_controller_spec.rb +++ b/spec/controllers/spree/admin/reports_controller_spec.rb @@ -303,5 +303,12 @@ describe Spree::Admin::ReportsController, type: :controller do expect(assigns(:report).table.empty?).to be false end end + + describe "sales_tax" do + it "shows report search forms" do + spree_get :sales_tax + expect(assigns(:report).table).to eq [] + end + end end end diff --git a/spec/lib/open_food_network/sales_tax_report_spec.rb b/spec/lib/open_food_network/sales_tax_report_spec.rb index a6445fa1f9..ac7bc7e47f 100644 --- a/spec/lib/open_food_network/sales_tax_report_spec.rb +++ b/spec/lib/open_food_network/sales_tax_report_spec.rb @@ -3,7 +3,7 @@ require 'open_food_network/sales_tax_report' module OpenFoodNetwork describe SalesTaxReport do let(:user) { create(:user) } - let(:report) { SalesTaxReport.new(user, {}) } + let(:report) { SalesTaxReport.new(user, {}, true) } describe "calculating totals for line items" do let(:li1) { double(:line_item, quantity: 1, amount: 12) } From 5f9d239f193916a8b3f71db365a0fdfea8f3f96f Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 3 May 2018 15:53:58 +1000 Subject: [PATCH 068/206] Compile Products & Inventory report only on search --- app/controllers/spree/admin/reports_controller_decorator.rb | 4 ++-- lib/open_food_network/lettuce_share_report.rb | 1 + lib/open_food_network/products_and_inventory_report.rb | 1 + lib/open_food_network/products_and_inventory_report_base.rb | 3 ++- spec/controllers/spree/admin/reports_controller_spec.rb | 2 +- spec/features/admin/reports_spec.rb | 2 ++ spec/lib/open_food_network/lettuce_share_report_spec.rb | 2 +- .../open_food_network/products_and_inventory_report_spec.rb | 4 ++-- 8 files changed, 12 insertions(+), 7 deletions(-) diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index e479982609..44faa87990 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -210,9 +210,9 @@ 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 + @report = OpenFoodNetwork::ProductsAndInventoryReport.new spree_current_user, params, render_content? else - @report = OpenFoodNetwork::LettuceShareReport.new spree_current_user, params + @report = OpenFoodNetwork::LettuceShareReport.new spree_current_user, params, render_content? end render_report(@report.header, @report.table, params[:csv], "products_and_inventory_#{timestamp}.csv") end diff --git a/lib/open_food_network/lettuce_share_report.rb b/lib/open_food_network/lettuce_share_report.rb index a1a157c3a3..7bfcf26ec4 100644 --- a/lib/open_food_network/lettuce_share_report.rb +++ b/lib/open_food_network/lettuce_share_report.rb @@ -19,6 +19,7 @@ module OpenFoodNetwork end def table + return [] unless @render_table variants.select { |v| v.in_stock? } .map do |variant| [ diff --git a/lib/open_food_network/products_and_inventory_report.rb b/lib/open_food_network/products_and_inventory_report.rb index 6f6ac4a44d..16d94dafb7 100644 --- a/lib/open_food_network/products_and_inventory_report.rb +++ b/lib/open_food_network/products_and_inventory_report.rb @@ -18,6 +18,7 @@ module OpenFoodNetwork end def table + return [] unless @render_table variants.map do |variant| [ variant.product.supplier.name, diff --git a/lib/open_food_network/products_and_inventory_report_base.rb b/lib/open_food_network/products_and_inventory_report_base.rb index 7641c056ea..dff53fdb9a 100644 --- a/lib/open_food_network/products_and_inventory_report_base.rb +++ b/lib/open_food_network/products_and_inventory_report_base.rb @@ -2,9 +2,10 @@ module OpenFoodNetwork class ProductsAndInventoryReportBase attr_reader :params - def initialize(user, params = {}) + def initialize(user, params = {}, render_table = false) @user = user @params = params + @render_table = render_table end def permissions diff --git a/spec/controllers/spree/admin/reports_controller_spec.rb b/spec/controllers/spree/admin/reports_controller_spec.rb index 2e0e70d65e..2356f238c5 100644 --- a/spec/controllers/spree/admin/reports_controller_spec.rb +++ b/spec/controllers/spree/admin/reports_controller_spec.rb @@ -235,7 +235,7 @@ describe Spree::Admin::ReportsController, type: :controller do it "creates a ProductAndInventoryReport" do expect(OpenFoodNetwork::ProductsAndInventoryReport).to receive(:new) - .with(@admin_user, {"test" => "foo", "controller" => "spree/admin/reports", "action" => "products_and_inventory"}) + .with(@admin_user, {"test" => "foo", "controller" => "spree/admin/reports", "action" => "products_and_inventory"}, false) .and_return(report = double(:report)) allow(report).to receive(:header).and_return [] allow(report).to receive(:table).and_return [] diff --git a/spec/features/admin/reports_spec.rb b/spec/features/admin/reports_spec.rb index 202e0fc5a7..c6c6f63602 100644 --- a/spec/features/admin/reports_spec.rb +++ b/spec/features/admin/reports_spec.rb @@ -314,6 +314,7 @@ feature %q{ expect(page).to have_content "All products" expect(page).to have_content "Inventory (on hand)" click_link 'Products & Inventory' + click_button "Search" expect(page).to have_content "Supplier" expect(page).to have_table_row ["Supplier", "Producer Suburb", "Product", "Product Properties", "Taxons", "Variant Value", "Price", "Group Buy Unit Quantity", "Amount", "SKU"].map(&:upcase) expect(page).to have_table_row [product1.supplier.name, product1.supplier.address.city, "Product Name", product1.properties.map(&:presentation).join(", "), product1.primary_taxon.name, "Test", "100.0", product1.group_buy_unit_size.to_s, "", "sku1"] @@ -325,6 +326,7 @@ feature %q{ login_to_admin_section click_link 'Reports' click_link 'LettuceShare' + click_button "Search" expect(page).to have_table_row ['PRODUCT', 'Description', 'Qty', 'Pack Size', 'Unit', 'Unit Price', 'Total', 'GST incl.', 'Grower and growing method', 'Taxon'].map(&:upcase) expect(page).to have_table_row ['Product 2', '100g', '', '100', 'g', '99.0', '', '0', 'Supplier Name (Organic - NASAA 12345)', 'Taxon Name'] diff --git a/spec/lib/open_food_network/lettuce_share_report_spec.rb b/spec/lib/open_food_network/lettuce_share_report_spec.rb index f37e15b69e..5a871cfbbb 100644 --- a/spec/lib/open_food_network/lettuce_share_report_spec.rb +++ b/spec/lib/open_food_network/lettuce_share_report_spec.rb @@ -3,7 +3,7 @@ require 'open_food_network/lettuce_share_report' module OpenFoodNetwork describe LettuceShareReport do let(:user) { create(:user) } - let(:report) { LettuceShareReport.new user } + let(:report) { LettuceShareReport.new user, {}, true } let(:v) { create(:variant) } describe "grower and method" do diff --git a/spec/lib/open_food_network/products_and_inventory_report_spec.rb b/spec/lib/open_food_network/products_and_inventory_report_spec.rb index f0fae499fe..80bdaebcf7 100644 --- a/spec/lib/open_food_network/products_and_inventory_report_spec.rb +++ b/spec/lib/open_food_network/products_and_inventory_report_spec.rb @@ -9,7 +9,7 @@ module OpenFoodNetwork user end subject do - ProductsAndInventoryReport.new user + ProductsAndInventoryReport.new user, {}, true end it "Should return headers" do @@ -72,7 +72,7 @@ module OpenFoodNetwork end subject do - ProductsAndInventoryReport.new enterprise_user + ProductsAndInventoryReport.new enterprise_user, {}, true end describe "fetching child variants" do From bf74282e5f10e3609d14f6305f0d0f68ef24762d Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 3 May 2018 16:03:32 +1000 Subject: [PATCH 069/206] Compile Bulk Co-Op report only on search --- app/controllers/spree/admin/reports_controller_decorator.rb | 3 +-- lib/open_food_network/bulk_coop_report.rb | 4 +++- spec/controllers/spree/admin/reports_controller_spec.rb | 6 +++--- spec/lib/open_food_network/bulk_coop_report_spec.rb | 6 +++--- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index 44faa87990..9b67b3490b 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -147,7 +147,6 @@ Spree::Admin::ReportsController.class_eval do end def bulk_coop - # -- Prepare date parameters prepare_date_params params # -- Prepare form options @@ -155,7 +154,7 @@ Spree::Admin::ReportsController.class_eval do @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" diff --git a/lib/open_food_network/bulk_coop_report.rb b/lib/open_food_network/bulk_coop_report.rb index a0437e7d42..4ff4555e18 100644 --- a/lib/open_food_network/bulk_coop_report.rb +++ b/lib/open_food_network/bulk_coop_report.rb @@ -4,9 +4,10 @@ require 'open_food_network/reports/bulk_coop_allocation_report' module OpenFoodNetwork class BulkCoopReport attr_reader :params - def initialize(user, params = {}) + def initialize(user, params = {}, render_table = false) @params = params @user = user + @render_table = render_table @supplier_report = OpenFoodNetwork::Reports::BulkCoopSupplierReport.new @allocation_report = OpenFoodNetwork::Reports::BulkCoopAllocationReport.new @@ -48,6 +49,7 @@ module OpenFoodNetwork end def table_items + return [] unless @render_table orders = search.result line_items = permissions.visible_line_items.merge(Spree::LineItem.where(order_id: orders)) diff --git a/spec/controllers/spree/admin/reports_controller_spec.rb b/spec/controllers/spree/admin/reports_controller_spec.rb index 2356f238c5..378ec7d1b3 100644 --- a/spec/controllers/spree/admin/reports_controller_spec.rb +++ b/spec/controllers/spree/admin/reports_controller_spec.rb @@ -97,7 +97,7 @@ describe Spree::Admin::ReportsController, type: :controller do describe 'Bulk Coop' do it "only shows orders that I have access to" do [orderA1, orderA2, orderB1, orderB2] - spree_get :bulk_coop + spree_post :bulk_coop expect(resulting_orders).to include(orderA1, orderB1) expect(resulting_orders).not_to include(orderA2) @@ -157,7 +157,7 @@ describe Spree::Admin::ReportsController, type: :controller do end it "only shows product line items that I am supplying" do - spree_get :bulk_coop + spree_post :bulk_coop expect(resulting_products).to include p1 expect(resulting_products).not_to include p2, p3 @@ -166,7 +166,7 @@ describe Spree::Admin::ReportsController, type: :controller do context "where I have not granted P-OC to the distributor" do it "shows product line items that I am supplying" do - spree_get :bulk_coop + spree_post :bulk_coop expect(resulting_products).not_to include p1, p2, p3 end diff --git a/spec/lib/open_food_network/bulk_coop_report_spec.rb b/spec/lib/open_food_network/bulk_coop_report_spec.rb index 53c2dd2ffe..7ee263fc55 100644 --- a/spec/lib/open_food_network/bulk_coop_report_spec.rb +++ b/spec/lib/open_food_network/bulk_coop_report_spec.rb @@ -14,7 +14,7 @@ module OpenFoodNetwork context "as a site admin" do let(:user) { create(:admin_user) } - subject { BulkCoopReport.new user } + subject { BulkCoopReport.new user, {}, true } it "fetches completed orders" do o2 = create(:order) @@ -31,7 +31,7 @@ module OpenFoodNetwork context "as a manager of a supplier" do let!(:user) { create(:user) } - subject { BulkCoopReport.new user } + subject { BulkCoopReport.new user, {}, true } let(:s1) { create(:supplier_enterprise) } @@ -70,7 +70,7 @@ module OpenFoodNetwork context "as a manager of a distributor" do let!(:user) { create(:user) } - subject { PackingReport.new user } + subject { PackingReport.new user, {}, true } before do d1.enterprise_roles.create!(user: user) From feb33c3ca592238802a9326e4383b162816f665f Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 3 May 2018 16:17:34 +1000 Subject: [PATCH 070/206] Compile Payments report only on search --- app/controllers/spree/admin/reports_controller_decorator.rb | 2 +- lib/open_food_network/payments_report.rb | 4 +++- spec/controllers/spree/admin/reports_controller_spec.rb | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index 9b67b3490b..c8046f439a 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -171,7 +171,7 @@ Spree::Admin::ReportsController.class_eval do @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" diff --git a/lib/open_food_network/payments_report.rb b/lib/open_food_network/payments_report.rb index 015b8af247..fc46422011 100644 --- a/lib/open_food_network/payments_report.rb +++ b/lib/open_food_network/payments_report.rb @@ -1,9 +1,10 @@ module OpenFoodNetwork class PaymentsReport attr_reader :params - def initialize(user, params = {}) + def initialize(user, params = {}, render_table = false) @params = params @user = user + @render_table = render_table end def header @@ -37,6 +38,7 @@ module OpenFoodNetwork end def table_items + return [] unless @render_table orders = search.result payments = orders.map { |o| o.payments.select { |payment| payment.completed? } }.flatten # Only select completed payments case params[:report_type] diff --git a/spec/controllers/spree/admin/reports_controller_spec.rb b/spec/controllers/spree/admin/reports_controller_spec.rb index 378ec7d1b3..45fb5f8151 100644 --- a/spec/controllers/spree/admin/reports_controller_spec.rb +++ b/spec/controllers/spree/admin/reports_controller_spec.rb @@ -108,7 +108,7 @@ describe Spree::Admin::ReportsController, type: :controller do describe 'Payments' do it "only shows orders that I have access to" do [orderA1, orderA2, orderB1, orderB2] - spree_get :payments + spree_post :payments expect(resulting_orders_prelim).to include(orderA1, orderB1) expect(resulting_orders_prelim).not_to include(orderA2) From 53436024e258282946c54786b7ffbfa8805d4c91 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 3 May 2018 16:23:15 +1000 Subject: [PATCH 071/206] Compile Orders & Fulfillment report only on search --- .../spree/admin/reports_controller_decorator.rb | 2 +- .../orders_and_fulfillments_report.rb | 4 +++- .../spree/admin/reports_controller_spec.rb | 12 ++++++------ .../orders_and_fulfillments_report_spec.rb | 6 +++--- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index c8046f439a..ad63693f14 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -198,7 +198,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" diff --git a/lib/open_food_network/orders_and_fulfillments_report.rb b/lib/open_food_network/orders_and_fulfillments_report.rb index 1de8c3ff40..8d11f81548 100644 --- a/lib/open_food_network/orders_and_fulfillments_report.rb +++ b/lib/open_food_network/orders_and_fulfillments_report.rb @@ -3,9 +3,10 @@ include Spree::ReportsHelper module OpenFoodNetwork class OrdersAndFulfillmentsReport attr_reader :params - def initialize(user, params = {}) + def initialize(user, params = {}, render_table = false) @params = params @user = user + @render_table = render_table end def header @@ -49,6 +50,7 @@ module OpenFoodNetwork end def table_items + return [] unless @render_table orders = search.result line_items = permissions.visible_line_items.merge(Spree::LineItem.where(order_id: orders)) diff --git a/spec/controllers/spree/admin/reports_controller_spec.rb b/spec/controllers/spree/admin/reports_controller_spec.rb index 45fb5f8151..bc0044e788 100644 --- a/spec/controllers/spree/admin/reports_controller_spec.rb +++ b/spec/controllers/spree/admin/reports_controller_spec.rb @@ -71,7 +71,7 @@ describe Spree::Admin::ReportsController, type: :controller do # create test objects [orderA1, orderA2, orderB1, orderB2] - spree_get :orders_and_fulfillment + spree_post :orders_and_fulfillment expect(resulting_orders).to include orderA1, orderA2 expect(resulting_orders).not_to include orderB1, orderB2 @@ -119,7 +119,7 @@ describe Spree::Admin::ReportsController, type: :controller do describe 'Orders & Fulfillment' do it "only shows orders that I distribute" do [orderA1, orderA2, orderB1, orderB2] - spree_get :orders_and_fulfillment + spree_post :orders_and_fulfillment expect(resulting_orders).to include orderA1, orderB1 expect(resulting_orders).not_to include orderA2, orderB2 @@ -127,7 +127,7 @@ describe Spree::Admin::ReportsController, type: :controller do it "only shows the selected order cycle" do [orderA1, orderB1] - spree_get :orders_and_fulfillment, q: {order_cycle_id_in: [ocA.id.to_s]} + spree_post :orders_and_fulfillment, q: {order_cycle_id_in: [ocA.id.to_s]} expect(resulting_orders).to include(orderA1) expect(resulting_orders).not_to include(orderB1) @@ -181,7 +181,7 @@ describe Spree::Admin::ReportsController, type: :controller do it "only shows product line items that I am supplying" do [orderA1, orderA2] - spree_get :orders_and_fulfillment + spree_post :orders_and_fulfillment expect(resulting_products).to include p1 expect(resulting_products).not_to include p2, p3 @@ -189,7 +189,7 @@ describe Spree::Admin::ReportsController, type: :controller do it "only shows the selected order cycle" do [orderA1, orderB1] - spree_get :orders_and_fulfillment, q: {order_cycle_id_eq: ocA.id} + spree_post :orders_and_fulfillment, q: {order_cycle_id_eq: ocA.id} expect(resulting_orders_prelim).to include(orderA1) expect(resulting_orders_prelim).not_to include(orderB1) @@ -199,7 +199,7 @@ describe Spree::Admin::ReportsController, type: :controller do context "where I have not granted P-OC to the distributor" do it "does not show me line_items I supply" do [orderA1, orderA2] - spree_get :orders_and_fulfillment + spree_post :orders_and_fulfillment expect(resulting_products).not_to include p1, p2, p3 end diff --git a/spec/lib/open_food_network/orders_and_fulfillments_report_spec.rb b/spec/lib/open_food_network/orders_and_fulfillments_report_spec.rb index f37dcefa02..a33722859c 100644 --- a/spec/lib/open_food_network/orders_and_fulfillments_report_spec.rb +++ b/spec/lib/open_food_network/orders_and_fulfillments_report_spec.rb @@ -14,7 +14,7 @@ module OpenFoodNetwork context "as a site admin" do let(:user) { create(:admin_user) } - subject { PackingReport.new user } + subject { PackingReport.new user, {}, true } it "fetches completed orders" do o2 = create(:order) @@ -31,7 +31,7 @@ module OpenFoodNetwork context "as a manager of a supplier" do let!(:user) { create(:user) } - subject { OrdersAndFulfillmentsReport.new user } + subject { OrdersAndFulfillmentsReport.new user, {}, true } let(:s1) { create(:supplier_enterprise) } @@ -70,7 +70,7 @@ module OpenFoodNetwork context "as a manager of a distributor" do let!(:user) { create(:user) } - subject { OrdersAndFulfillmentsReport.new user } + subject { OrdersAndFulfillmentsReport.new user, {}, true } before do d1.enterprise_roles.create!(user: user) From 1e80487afc474f2b871f32dd5e70dad10721e889 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 3 May 2018 16:46:58 +1000 Subject: [PATCH 072/206] Compile Order And Distributors only on search And move most logic into the report class like the others. --- .../admin/reports_controller_decorator.rb | 20 +--------- .../order_and_distributor_report.rb | 38 ++++++++++++++++--- .../spree/admin/reports_controller_spec.rb | 2 +- .../order_and_distributor_report_spec.rb | 6 +-- 4 files changed, 39 insertions(+), 27 deletions(-) diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index ad63693f14..7ef916bcc4 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -116,24 +116,8 @@ Spree::Admin::ReportsController.class_eval do 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 + @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 diff --git a/lib/open_food_network/order_and_distributor_report.rb b/lib/open_food_network/order_and_distributor_report.rb index 34784b40ac..1663d483a4 100644 --- a/lib/open_food_network/order_and_distributor_report.rb +++ b/lib/open_food_network/order_and_distributor_report.rb @@ -1,8 +1,12 @@ module OpenFoodNetwork class OrderAndDistributorReport - def initialize orders - @orders = orders + def initialize(user, params = {}, render_table = false) + @params = params + @user = user + @render_table = render_table + + @permissions = OpenFoodNetwork::Permissions.new(user) end def header @@ -27,10 +31,36 @@ module OpenFoodNetwork I18n.t(:report_header_shipping_instructions)] end + def search + @permissions.visible_orders.complete.not_state(:canceled).search(@params[:q]) + end + def table + return [] unless @render_table + + 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 + + line_item_details orders + end + + private + + def line_item_details(orders) order_and_distributor_details = [] - @orders.each do |order| + orders.each do |order| order.line_items.each do |line_item| order_and_distributor_details << row_for(line_item, order) end @@ -39,8 +69,6 @@ module OpenFoodNetwork order_and_distributor_details end - private - # Returns a row with the data to display for the specified line_item and # its order # diff --git a/spec/controllers/spree/admin/reports_controller_spec.rb b/spec/controllers/spree/admin/reports_controller_spec.rb index bc0044e788..4d2fb1d71e 100644 --- a/spec/controllers/spree/admin/reports_controller_spec.rb +++ b/spec/controllers/spree/admin/reports_controller_spec.rb @@ -86,7 +86,7 @@ describe Spree::Admin::ReportsController, type: :controller do describe 'Orders and Distributors' do it "only shows orders that I have access to" do [orderA1, orderA2, orderB1, orderB2] - spree_get :orders_and_distributors + spree_post :orders_and_distributors expect(assigns(:search).result).to include(orderA1, orderB1) expect(assigns(:search).result).not_to include(orderA2) diff --git a/spec/lib/open_food_network/order_and_distributor_report_spec.rb b/spec/lib/open_food_network/order_and_distributor_report_spec.rb index 5b96b20d75..642263b259 100644 --- a/spec/lib/open_food_network/order_and_distributor_report_spec.rb +++ b/spec/lib/open_food_network/order_and_distributor_report_spec.rb @@ -23,7 +23,7 @@ module OpenFoodNetwork end it "should return a header row describing the report" do - subject = OrderAndDistributorReport.new [@order] + subject = OrderAndDistributorReport.new nil header = subject.header header.should == ["Order date", "Order Id", @@ -34,9 +34,9 @@ module OpenFoodNetwork end it "should denormalise order and distributor details for display as csv" do - subject = OrderAndDistributorReport.new [@order] + subject = OrderAndDistributorReport.new create(:admin_user), {}, true - table = subject.table + table = subject.send(:line_item_details, [@order]) table[0].should == [@order.created_at, @order.id, @bill_address.full_name, @order.email, @bill_address.phone, @bill_address.city, From 7a546087b242eaa2bf23c6fbfbd958a9e9361ab6 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 3 May 2018 16:53:31 +1000 Subject: [PATCH 073/206] Compile Packing report data only on search --- app/controllers/spree/admin/reports_controller_decorator.rb | 2 +- lib/open_food_network/packing_report.rb | 5 ++++- spec/lib/open_food_network/packing_report_spec.rb | 6 +++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index 7ef916bcc4..2016eaba72 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -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) diff --git a/lib/open_food_network/packing_report.rb b/lib/open_food_network/packing_report.rb index d24048fcf6..4b3a9cbc64 100644 --- a/lib/open_food_network/packing_report.rb +++ b/lib/open_food_network/packing_report.rb @@ -1,9 +1,10 @@ module OpenFoodNetwork class PackingReport attr_reader :params - def initialize(user, params = {}) + def initialize(user, params = {}, render_table = false) @params = params @user = user + @render_table = render_table end def header @@ -39,6 +40,8 @@ module OpenFoodNetwork end def table_items + return [] unless @render_table + orders = search.result line_items = permissions.visible_line_items.merge(Spree::LineItem.where(order_id: orders)) diff --git a/spec/lib/open_food_network/packing_report_spec.rb b/spec/lib/open_food_network/packing_report_spec.rb index 6b02a26fed..b9074a92bf 100644 --- a/spec/lib/open_food_network/packing_report_spec.rb +++ b/spec/lib/open_food_network/packing_report_spec.rb @@ -14,7 +14,7 @@ module OpenFoodNetwork context "as a site admin" do let(:user) { create(:admin_user) } - subject { PackingReport.new user } + subject { PackingReport.new user, {}, true } it "fetches completed orders" do o2 = create(:order) @@ -31,7 +31,7 @@ module OpenFoodNetwork context "as a manager of a supplier" do let!(:user) { create(:user) } - subject { PackingReport.new user } + subject { PackingReport.new user, {}, true } let(:s1) { create(:supplier_enterprise) } @@ -70,7 +70,7 @@ module OpenFoodNetwork context "as a manager of a distributor" do let!(:user) { create(:user) } - subject { PackingReport.new user } + subject { PackingReport.new user, {}, true } before do d1.enterprise_roles.create!(user: user) From 8e2aee71dab53ad0b984f35d23cbb89289614d86 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 3 May 2018 17:06:13 +1000 Subject: [PATCH 074/206] Compile Order Cycle Management only on search --- app/controllers/spree/admin/reports_controller_decorator.rb | 2 +- lib/open_food_network/order_cycle_management_report.rb | 5 ++++- .../open_food_network/order_cycle_management_report_spec.rb | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index 2016eaba72..ee65299073 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -84,7 +84,7 @@ 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") diff --git a/lib/open_food_network/order_cycle_management_report.rb b/lib/open_food_network/order_cycle_management_report.rb index 53310e6fe1..d9921d15c2 100644 --- a/lib/open_food_network/order_cycle_management_report.rb +++ b/lib/open_food_network/order_cycle_management_report.rb @@ -3,9 +3,10 @@ require 'open_food_network/user_balance_calculator' module OpenFoodNetwork class OrderCycleManagementReport attr_reader :params - def initialize(user, params = {}) + def initialize(user, params = {}, render_table = false) @params = params @user = user + @render_table = render_table end def header @@ -50,6 +51,8 @@ module OpenFoodNetwork end def table_items + return [] unless @render_table + if is_payment_methods? orders.map { |o| payment_method_row o } else diff --git a/spec/lib/open_food_network/order_cycle_management_report_spec.rb b/spec/lib/open_food_network/order_cycle_management_report_spec.rb index 99ebf575af..49a9915301 100644 --- a/spec/lib/open_food_network/order_cycle_management_report_spec.rb +++ b/spec/lib/open_food_network/order_cycle_management_report_spec.rb @@ -10,7 +10,7 @@ module OpenFoodNetwork user.spree_roles << Spree::Role.find_or_create_by_name!("admin") user end - subject { OrderCycleManagementReport.new user } + subject { OrderCycleManagementReport.new user, {}, true } describe "fetching orders" do it "fetches completed orders" do @@ -30,7 +30,7 @@ module OpenFoodNetwork context "as an enterprise user" do let!(:user) { create_enterprise_user } - subject { OrderCycleManagementReport.new user } + subject { OrderCycleManagementReport.new user, {}, true } describe "fetching orders" do let(:supplier) { create(:supplier_enterprise) } From 63799b2cb12957db391421066b4531038899284e Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Fri, 4 May 2018 09:20:31 +1000 Subject: [PATCH 075/206] Remove outdated comment --- app/controllers/spree/admin/reports_controller_decorator.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index ee65299073..6d69938f74 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -59,7 +59,6 @@ Spree::Admin::ReportsController.class_eval do respond_with(@reports) end - # This action is short because we re-factored it like bosses def customers @report_types = report_types[:customers] @report_type = params[:report_type] From 8a4457e8e4ed41aa12471311aa6c2b57fcbfaea4 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Fri, 4 May 2018 09:24:44 +1000 Subject: [PATCH 076/206] Give better names --- .../spree/admin/reports_controller_spec.rb | 106 +++++++++--------- 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/spec/controllers/spree/admin/reports_controller_spec.rb b/spec/controllers/spree/admin/reports_controller_spec.rb index 4d2fb1d71e..7410c43287 100644 --- a/spec/controllers/spree/admin/reports_controller_spec.rb +++ b/spec/controllers/spree/admin/reports_controller_spec.rb @@ -3,55 +3,55 @@ require 'spec_helper' describe Spree::Admin::ReportsController, type: :controller do # Given two distributors and two suppliers - let(:ba) { create(:address) } - let(:sa) { create(:address) } - let(:si) { "pick up on thursday please" } - let(:c1) { create(:distributor_enterprise) } - let(:c2) { create(:distributor_enterprise) } - let(:s1) { create(:supplier_enterprise) } - let(:s2) { create(:supplier_enterprise) } - let(:s3) { create(:supplier_enterprise) } - let(:d1) { create(:distributor_enterprise) } - let(:d2) { create(:distributor_enterprise) } - let(:d3) { create(:distributor_enterprise) } - let(:p1) { create(:product, price: 12.34, distributors: [d1], supplier: s1) } - let(:p2) { create(:product, price: 23.45, distributors: [d2], supplier: s2) } - let(:p3) { create(:product, price: 34.56, distributors: [d3], supplier: s3) } + let(:bill_address) { create(:address) } + let(:ship_address) { create(:address) } + let(:instructions) { "pick up on thursday please" } + let(:coordinator1) { create(:distributor_enterprise) } + let(:coordinator2) { create(:distributor_enterprise) } + let(:supplier1) { create(:supplier_enterprise) } + let(:supplier2) { create(:supplier_enterprise) } + let(:supplier3) { create(:supplier_enterprise) } + let(:distributor1) { create(:distributor_enterprise) } + let(:distributor2) { create(:distributor_enterprise) } + let(:distributor3) { create(:distributor_enterprise) } + let(:product1) { create(:product, price: 12.34, distributors: [distributor1], supplier: supplier1) } + let(:product2) { create(:product, price: 23.45, distributors: [distributor2], supplier: supplier2) } + let(:product3) { create(:product, price: 34.56, distributors: [distributor3], supplier: supplier3) } # Given two order cycles with both distributors - let(:ocA) { create(:simple_order_cycle, coordinator: c1, distributors: [d1, d2], suppliers: [s1, s2, s3], variants: [p1.master, p3.master]) } - let(:ocB) { create(:simple_order_cycle, coordinator: c2, distributors: [d1, d2], suppliers: [s1, s2, s3], variants: [p2.master]) } + let(:ocA) { create(:simple_order_cycle, coordinator: coordinator1, distributors: [distributor1, distributor2], suppliers: [supplier1, supplier2, supplier3], variants: [product1.master, product3.master]) } + let(:ocB) { create(:simple_order_cycle, coordinator: coordinator2, distributors: [distributor1, distributor2], suppliers: [supplier1, supplier2, supplier3], variants: [product2.master]) } - # orderA1 can only be accessed by s1, s3 and d1 + # orderA1 can only be accessed by supplier1, supplier3 and distributor1 let(:orderA1) do - order = create(:order, distributor: d1, bill_address: ba, ship_address: sa, special_instructions: si, order_cycle: ocA) - order.line_items << create(:line_item, variant: p1.master) - order.line_items << create(:line_item, variant: p3.master) + order = create(:order, distributor: distributor1, bill_address: bill_address, ship_address: ship_address, special_instructions: instructions, order_cycle: ocA) + order.line_items << create(:line_item, variant: product1.master) + order.line_items << create(:line_item, variant: product3.master) order.finalize! order.save order end - # orderA2 can only be accessed by s2 and d2 + # orderA2 can only be accessed by supplier2 and distributor2 let(:orderA2) do - order = create(:order, distributor: d2, bill_address: ba, ship_address: sa, special_instructions: si, order_cycle: ocA) - order.line_items << create(:line_item, variant: p2.master) + order = create(:order, distributor: distributor2, bill_address: bill_address, ship_address: ship_address, special_instructions: instructions, order_cycle: ocA) + order.line_items << create(:line_item, variant: product2.master) order.finalize! order.save order end - # orderB1 can only be accessed by s1, s3 and d1 + # orderB1 can only be accessed by supplier1, supplier3 and distributor1 let(:orderB1) do - order = create(:order, distributor: d1, bill_address: ba, ship_address: sa, special_instructions: si, order_cycle: ocB) - order.line_items << create(:line_item, variant: p1.master) - order.line_items << create(:line_item, variant: p3.master) + order = create(:order, distributor: distributor1, bill_address: bill_address, ship_address: ship_address, special_instructions: instructions, order_cycle: ocB) + order.line_items << create(:line_item, variant: product1.master) + order.line_items << create(:line_item, variant: product3.master) order.finalize! order.save order end - # orderB2 can only be accessed by s2 and d2 + # orderB2 can only be accessed by supplier2 and distributor2 let(:orderB2) do - order = create(:order, distributor: d2, bill_address: ba, ship_address: sa, special_instructions: si, order_cycle: ocB) - order.line_items << create(:line_item, variant: p2.master) + order = create(:order, distributor: distributor2, bill_address: bill_address, ship_address: ship_address, special_instructions: instructions, order_cycle: ocB) + order.line_items << create(:line_item, variant: product2.master) order.finalize! order.save order @@ -62,9 +62,9 @@ describe Spree::Admin::ReportsController, type: :controller do let(:resulting_orders) { assigns(:report).table_items.map(&:order) } let(:resulting_products) { assigns(:report).table_items.map(&:product) } - # As manager of a coordinator (c1) + # As manager of a coordinator (coordinator1) context "Coordinator Enterprise User" do - before { login_as_enterprise_user [c1] } + before { login_as_enterprise_user [coordinator1] } describe 'Orders & Fulfillment' do it "shows all orders in order cycles I coordinate" do @@ -79,9 +79,9 @@ describe Spree::Admin::ReportsController, type: :controller do end end - # As a Distributor Enterprise user for d1 + # As a Distributor Enterprise user for distributor1 context "Distributor Enterprise User" do - before { login_as_enterprise_user [d1] } + before { login_as_enterprise_user [distributor1] } describe 'Orders and Distributors' do it "only shows orders that I have access to" do @@ -135,9 +135,9 @@ describe Spree::Admin::ReportsController, type: :controller do end end - # As a Supplier Enterprise user for s1 + # As a Supplier Enterprise user for supplier1 context "Supplier" do - before { login_as_enterprise_user [s1] } + before { login_as_enterprise_user [supplier1] } describe 'index' do it "loads reports relevant to producers" do @@ -153,14 +153,14 @@ describe Spree::Admin::ReportsController, type: :controller do context "where I have granted P-OC to the distributor" do before do [orderA1, orderA2] - create(:enterprise_relationship, parent: s1, child: d1, permissions_list: [:add_to_order_cycle]) + create(:enterprise_relationship, parent: supplier1, child: distributor1, permissions_list: [:add_to_order_cycle]) end it "only shows product line items that I am supplying" do spree_post :bulk_coop - expect(resulting_products).to include p1 - expect(resulting_products).not_to include p2, p3 + expect(resulting_products).to include product1 + expect(resulting_products).not_to include product2, product3 end end @@ -168,7 +168,7 @@ describe Spree::Admin::ReportsController, type: :controller do it "shows product line items that I am supplying" do spree_post :bulk_coop - expect(resulting_products).not_to include p1, p2, p3 + expect(resulting_products).not_to include product1, product2, product3 end end end @@ -176,15 +176,15 @@ describe Spree::Admin::ReportsController, type: :controller do describe 'Orders & Fulfillment' do context "where I have granted P-OC to the distributor" do before do - create(:enterprise_relationship, parent: s1, child: d1, permissions_list: [:add_to_order_cycle]) + create(:enterprise_relationship, parent: supplier1, child: distributor1, permissions_list: [:add_to_order_cycle]) end it "only shows product line items that I am supplying" do [orderA1, orderA2] spree_post :orders_and_fulfillment - expect(resulting_products).to include p1 - expect(resulting_products).not_to include p2, p3 + expect(resulting_products).to include product1 + expect(resulting_products).not_to include product2, product3 end it "only shows the selected order cycle" do @@ -201,7 +201,7 @@ describe Spree::Admin::ReportsController, type: :controller do [orderA1, orderA2] spree_post :orders_and_fulfillment - expect(resulting_products).not_to include p1, p2, p3 + expect(resulting_products).not_to include product1, product2, product3 end end end @@ -211,15 +211,15 @@ describe Spree::Admin::ReportsController, type: :controller do before { login_as_admin } it "should build distributors for the current user" do - [c1, c2, s1, d1, d2, d3] + [coordinator1, coordinator2, supplier1, distributor1, distributor2, distributor3] spree_get :products_and_inventory - expect(assigns(:distributors)).to match_array [c1, c2, d1, d2, d3] + expect(assigns(:distributors)).to match_array [coordinator1, coordinator2, distributor1, distributor2, distributor3] end it "builds suppliers for the current user" do - [s1, s2, s3, d1] + [supplier1, supplier2, supplier3, distributor1] spree_get :products_and_inventory - expect(assigns(:suppliers)).to match_array [s1, s2, s3] + expect(assigns(:suppliers)).to match_array [supplier1, supplier2, supplier3] end it "builds order cycles for the current user" do @@ -255,15 +255,15 @@ describe Spree::Admin::ReportsController, type: :controller do end it "should build distributors for the current user" do - [c1, c2, s1, d1, d2, d3] + [coordinator1, coordinator2, supplier1, distributor1, distributor2, distributor3] spree_get :customers - expect(assigns(:distributors)).to match_array [c1, c2, d1, d2, d3] + expect(assigns(:distributors)).to match_array [coordinator1, coordinator2, distributor1, distributor2, distributor3] end it "builds suppliers for the current user" do - [s1, s2, s3, d1] + [supplier1, supplier2, supplier3, distributor1] spree_get :customers - expect(assigns(:suppliers)).to match_array [s1, s2, s3] + expect(assigns(:suppliers)).to match_array [supplier1, supplier2, supplier3] end it "builds order cycles for the current user" do @@ -298,7 +298,7 @@ describe Spree::Admin::ReportsController, type: :controller do end it "shows report data" do - [c1] + [coordinator1] spree_post :users_and_enterprises expect(assigns(:report).table.empty?).to be false end From acee5da31bb8cd7ff2273b2b74b5b7455bd11318 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Fri, 4 May 2018 09:39:38 +1000 Subject: [PATCH 077/206] Remove unnecessary whitespace --- spec/features/admin/reports_spec.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/features/admin/reports_spec.rb b/spec/features/admin/reports_spec.rb index c6c6f63602..74260047f6 100644 --- a/spec/features/admin/reports_spec.rb +++ b/spec/features/admin/reports_spec.rb @@ -215,10 +215,10 @@ feature %q{ expect(page).to have_content "#{order1.number}" # And the totals and sales tax should be correct - expect(page).to have_content "1512.99" # items total - expect(page).to have_content "1500.45" # taxable items total - expect(page).to have_content "250.08" # sales tax - expect(page).to have_content "20.0" # enterprise fee tax + expect(page).to have_content "1512.99" # items total + expect(page).to have_content "1500.45" # taxable items total + expect(page).to have_content "250.08" # sales tax + expect(page).to have_content "20.0" # enterprise fee tax # And the shipping cost and tax should be correct expect(page).to have_content "100.55" # shipping cost From 8a1a540f3df504160bd96c1c96234eebece9800f Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Fri, 4 May 2018 10:17:22 +1000 Subject: [PATCH 078/206] Clarify creating objects for test context --- .../spree/admin/reports_controller_spec.rb | 117 +++++++++++------- 1 file changed, 70 insertions(+), 47 deletions(-) diff --git a/spec/controllers/spree/admin/reports_controller_spec.rb b/spec/controllers/spree/admin/reports_controller_spec.rb index 7410c43287..07e2c1ece6 100644 --- a/spec/controllers/spree/admin/reports_controller_spec.rb +++ b/spec/controllers/spree/admin/reports_controller_spec.rb @@ -64,13 +64,12 @@ describe Spree::Admin::ReportsController, type: :controller do # As manager of a coordinator (coordinator1) context "Coordinator Enterprise User" do + let!(:present_objects) { [orderA1, orderA2, orderB1, orderB2] } + before { login_as_enterprise_user [coordinator1] } describe 'Orders & Fulfillment' do it "shows all orders in order cycles I coordinate" do - # create test objects - [orderA1, orderA2, orderB1, orderB2] - spree_post :orders_and_fulfillment expect(resulting_orders).to include orderA1, orderA2 @@ -84,8 +83,9 @@ describe Spree::Admin::ReportsController, type: :controller do before { login_as_enterprise_user [distributor1] } describe 'Orders and Distributors' do + let!(:present_objects) { [orderA1, orderA2, orderB1, orderB2] } + it "only shows orders that I have access to" do - [orderA1, orderA2, orderB1, orderB2] spree_post :orders_and_distributors expect(assigns(:search).result).to include(orderA1, orderB1) @@ -95,8 +95,9 @@ describe Spree::Admin::ReportsController, type: :controller do end describe 'Bulk Coop' do + let!(:present_objects) { [orderA1, orderA2, orderB1, orderB2] } + it "only shows orders that I have access to" do - [orderA1, orderA2, orderB1, orderB2] spree_post :bulk_coop expect(resulting_orders).to include(orderA1, orderB1) @@ -106,8 +107,9 @@ describe Spree::Admin::ReportsController, type: :controller do end describe 'Payments' do + let!(:present_objects) { [orderA1, orderA2, orderB1, orderB2] } + it "only shows orders that I have access to" do - [orderA1, orderA2, orderB1, orderB2] spree_post :payments expect(resulting_orders_prelim).to include(orderA1, orderB1) @@ -117,20 +119,26 @@ describe Spree::Admin::ReportsController, type: :controller do end describe 'Orders & Fulfillment' do - it "only shows orders that I distribute" do - [orderA1, orderA2, orderB1, orderB2] - spree_post :orders_and_fulfillment + context "with four orders" do + let!(:present_objects) { [orderA1, orderA2, orderB1, orderB2] } - expect(resulting_orders).to include orderA1, orderB1 - expect(resulting_orders).not_to include orderA2, orderB2 + it "only shows orders that I distribute" do + spree_post :orders_and_fulfillment + + expect(resulting_orders).to include orderA1, orderB1 + expect(resulting_orders).not_to include orderA2, orderB2 + end end - it "only shows the selected order cycle" do - [orderA1, orderB1] - spree_post :orders_and_fulfillment, q: {order_cycle_id_in: [ocA.id.to_s]} + context "with two orders" do + let!(:present_objects) { [orderA1, orderB1] } - expect(resulting_orders).to include(orderA1) - expect(resulting_orders).not_to include(orderB1) + it "only shows the selected order cycle" do + spree_post :orders_and_fulfillment, q: {order_cycle_id_in: [ocA.id.to_s]} + + expect(resulting_orders).to include(orderA1) + expect(resulting_orders).not_to include(orderB1) + end end end end @@ -151,8 +159,9 @@ describe Spree::Admin::ReportsController, type: :controller do describe 'Bulk Coop' do context "where I have granted P-OC to the distributor" do + let!(:present_objects) { [orderA1, orderA2] } + before do - [orderA1, orderA2] create(:enterprise_relationship, parent: supplier1, child: distributor1, permissions_list: [:add_to_order_cycle]) end @@ -174,13 +183,14 @@ describe Spree::Admin::ReportsController, type: :controller do end describe 'Orders & Fulfillment' do + let!(:present_objects) { [orderA1, orderA2] } + context "where I have granted P-OC to the distributor" do before do create(:enterprise_relationship, parent: supplier1, child: distributor1, permissions_list: [:add_to_order_cycle]) end it "only shows product line items that I am supplying" do - [orderA1, orderA2] spree_post :orders_and_fulfillment expect(resulting_products).to include product1 @@ -188,7 +198,6 @@ describe Spree::Admin::ReportsController, type: :controller do end it "only shows the selected order cycle" do - [orderA1, orderB1] spree_post :orders_and_fulfillment, q: {order_cycle_id_eq: ocA.id} expect(resulting_orders_prelim).to include(orderA1) @@ -198,7 +207,6 @@ describe Spree::Admin::ReportsController, type: :controller do context "where I have not granted P-OC to the distributor" do it "does not show me line_items I supply" do - [orderA1, orderA2] spree_post :orders_and_fulfillment expect(resulting_products).not_to include product1, product2, product3 @@ -210,22 +218,29 @@ describe Spree::Admin::ReportsController, type: :controller do context "Products & Inventory" do before { login_as_admin } - it "should build distributors for the current user" do - [coordinator1, coordinator2, supplier1, distributor1, distributor2, distributor3] - spree_get :products_and_inventory - expect(assigns(:distributors)).to match_array [coordinator1, coordinator2, distributor1, distributor2, distributor3] + context "with distributors and suppliers" do + let(:distributors) { [coordinator1, distributor1, distributor2] } + let(:suppliers) { [supplier1, supplier2] } + let!(:present_objects) { [distributors, suppliers] } + + it "should build distributors for the current user" do + spree_get :products_and_inventory + expect(assigns(:distributors)).to match_array distributors + end + + it "builds suppliers for the current user" do + spree_get :products_and_inventory + expect(assigns(:suppliers)).to match_array suppliers + end end - it "builds suppliers for the current user" do - [supplier1, supplier2, supplier3, distributor1] - spree_get :products_and_inventory - expect(assigns(:suppliers)).to match_array [supplier1, supplier2, supplier3] - end + context "with order cycles" do + let!(:order_cycles) { [ocA, ocB] } - it "builds order cycles for the current user" do - [ocA, ocB] - spree_get :products_and_inventory - expect(assigns(:order_cycles)).to match_array [ocB, ocA] + it "builds order cycles for the current user" do + spree_get :products_and_inventory + expect(assigns(:order_cycles)).to match_array order_cycles + end end it "assigns report types" do @@ -254,22 +269,29 @@ describe Spree::Admin::ReportsController, type: :controller do ]) end - it "should build distributors for the current user" do - [coordinator1, coordinator2, supplier1, distributor1, distributor2, distributor3] - spree_get :customers - expect(assigns(:distributors)).to match_array [coordinator1, coordinator2, distributor1, distributor2, distributor3] + context "with distributors and suppliers" do + let(:distributors) { [coordinator1, distributor1, distributor2] } + let(:suppliers) { [supplier1, supplier2] } + let!(:present_objects) { [distributors, suppliers] } + + it "should build distributors for the current user" do + spree_get :customers + expect(assigns(:distributors)).to match_array distributors + end + + it "builds suppliers for the current user" do + spree_get :customers + expect(assigns(:suppliers)).to match_array suppliers + end end - it "builds suppliers for the current user" do - [supplier1, supplier2, supplier3, distributor1] - spree_get :customers - expect(assigns(:suppliers)).to match_array [supplier1, supplier2, supplier3] - end + context "with order cycles" do + let!(:order_cycles) { [ocA, ocB] } - it "builds order cycles for the current user" do - [ocA, ocB] - spree_get :customers - expect(assigns(:order_cycles)).to match_array [ocB, ocA] + it "builds order cycles for the current user" do + spree_get :customers + expect(assigns(:order_cycles)).to match_array order_cycles + end end it "assigns report types" do @@ -292,13 +314,14 @@ describe Spree::Admin::ReportsController, type: :controller do before { login_as_admin } describe "users_and_enterprises" do + let!(:present_objects) { [coordinator1] } + it "shows report search forms" do spree_get :users_and_enterprises expect(assigns(:report).table).to eq [] end it "shows report data" do - [coordinator1] spree_post :users_and_enterprises expect(assigns(:report).table.empty?).to be false end From 40b0a0bd5ab76213630e7dc471cdb2ede3ea8cfc Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Fri, 4 May 2018 12:44:51 +1000 Subject: [PATCH 079/206] Determine searching state by params, not request We may want to use GET for searching or POST to display a certain report type. --- .../admin/reports_controller_decorator.rb | 27 ++++++++++++++++++- .../spree/admin/reports_controller_spec.rb | 12 ++++----- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index 6d69938f74..5556488424 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -18,6 +18,7 @@ Spree::Admin::ReportsController.class_eval do include Spree::ReportsHelper + 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] @@ -220,10 +221,34 @@ Spree::Admin::ReportsController.class_eval do private + # 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? - request.post? + @searching end def render_report(header, table, create_csv, csv_file_name) diff --git a/spec/controllers/spree/admin/reports_controller_spec.rb b/spec/controllers/spree/admin/reports_controller_spec.rb index 07e2c1ece6..3c884967c9 100644 --- a/spec/controllers/spree/admin/reports_controller_spec.rb +++ b/spec/controllers/spree/admin/reports_controller_spec.rb @@ -70,7 +70,7 @@ describe Spree::Admin::ReportsController, type: :controller do describe 'Orders & Fulfillment' do it "shows all orders in order cycles I coordinate" do - spree_post :orders_and_fulfillment + spree_post :orders_and_fulfillment, {q: {}} expect(resulting_orders).to include orderA1, orderA2 expect(resulting_orders).not_to include orderB1, orderB2 @@ -98,7 +98,7 @@ describe Spree::Admin::ReportsController, type: :controller do let!(:present_objects) { [orderA1, orderA2, orderB1, orderB2] } it "only shows orders that I have access to" do - spree_post :bulk_coop + spree_post :bulk_coop, {q: {}} expect(resulting_orders).to include(orderA1, orderB1) expect(resulting_orders).not_to include(orderA2) @@ -123,7 +123,7 @@ describe Spree::Admin::ReportsController, type: :controller do let!(:present_objects) { [orderA1, orderA2, orderB1, orderB2] } it "only shows orders that I distribute" do - spree_post :orders_and_fulfillment + spree_post :orders_and_fulfillment, {q: {}} expect(resulting_orders).to include orderA1, orderB1 expect(resulting_orders).not_to include orderA2, orderB2 @@ -166,7 +166,7 @@ describe Spree::Admin::ReportsController, type: :controller do end it "only shows product line items that I am supplying" do - spree_post :bulk_coop + spree_post :bulk_coop, {q: {}} expect(resulting_products).to include product1 expect(resulting_products).not_to include product2, product3 @@ -191,7 +191,7 @@ describe Spree::Admin::ReportsController, type: :controller do end it "only shows product line items that I am supplying" do - spree_post :orders_and_fulfillment + spree_post :orders_and_fulfillment, {q: {}} expect(resulting_products).to include product1 expect(resulting_products).not_to include product2, product3 @@ -322,7 +322,7 @@ describe Spree::Admin::ReportsController, type: :controller do end it "shows report data" do - spree_post :users_and_enterprises + spree_post :users_and_enterprises, {q: {}} expect(assigns(:report).table.empty?).to be false end end From 0fa9ca653e197117c5ac513c6640539fd141bc34 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Fri, 4 May 2018 15:13:58 +1000 Subject: [PATCH 080/206] Move duplicate code into its own module --- lib/open_food_network/bulk_coop_report.rb | 19 ++---------- .../orders_and_fulfillments_report.rb | 22 +++----------- lib/open_food_network/packing_report.rb | 22 +++----------- lib/open_food_network/reports/line_items.rb | 30 +++++++++++++++++++ 4 files changed, 41 insertions(+), 52 deletions(-) create mode 100644 lib/open_food_network/reports/line_items.rb diff --git a/lib/open_food_network/bulk_coop_report.rb b/lib/open_food_network/bulk_coop_report.rb index 4ff4555e18..b06d517b6c 100644 --- a/lib/open_food_network/bulk_coop_report.rb +++ b/lib/open_food_network/bulk_coop_report.rb @@ -1,5 +1,6 @@ require 'open_food_network/reports/bulk_coop_supplier_report' require 'open_food_network/reports/bulk_coop_allocation_report' +require "open_food_network/reports/line_items" module OpenFoodNetwork class BulkCoopReport @@ -45,26 +46,12 @@ module OpenFoodNetwork end def search - permissions.visible_orders.complete.not_state(:canceled).search(params[:q]) + OpenFoodNetwork::Reports::LineItems.search_orders(permissions, params) end def table_items return [] unless @render_table - orders = search.result - - line_items = permissions.visible_line_items.merge(Spree::LineItem.where(order_id: orders)) - - line_items_with_hidden_details = - permissions.editable_line_items.empty? ? line_items : line_items.where('"spree_line_items"."id" NOT IN (?)', permissions.editable_line_items) - - line_items.select{ |li| line_items_with_hidden_details.include? li }.each do |line_item| - # 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 - line_item.order.bill_address.andand.assign_attributes(firstname: I18n.t('admin.reports.hidden'), lastname: "", phone: "", address1: "", address2: "", city: "", zipcode: "", state: nil) - line_item.order.ship_address.andand.assign_attributes(firstname: I18n.t('admin.reports.hidden'), lastname: "", phone: "", address1: "", address2: "", city: "", zipcode: "", state: nil) - line_item.order.assign_attributes(email: I18n.t('admin.reports.hidden')) - end - line_items + OpenFoodNetwork::Reports::LineItems.list(permissions, params) end def rules diff --git a/lib/open_food_network/orders_and_fulfillments_report.rb b/lib/open_food_network/orders_and_fulfillments_report.rb index 8d11f81548..b707cd590f 100644 --- a/lib/open_food_network/orders_and_fulfillments_report.rb +++ b/lib/open_food_network/orders_and_fulfillments_report.rb @@ -1,3 +1,5 @@ +require "open_food_network/reports/line_items" + include Spree::ReportsHelper module OpenFoodNetwork @@ -46,28 +48,12 @@ module OpenFoodNetwork end def search - permissions.visible_orders.complete.not_state(:canceled).search(params[:q]) + OpenFoodNetwork::Reports::LineItems.search_orders(permissions, params) end def table_items return [] unless @render_table - orders = search.result - - line_items = permissions.visible_line_items.merge(Spree::LineItem.where(order_id: orders)) - line_items = line_items.supplied_by_any(params[:supplier_id_in]) if params[:supplier_id_in].present? - - # If empty array is passed in, the where clause will return all line_items, which is bad - line_items_with_hidden_details = - permissions.editable_line_items.empty? ? line_items : line_items.where('"spree_line_items"."id" NOT IN (?)', permissions.editable_line_items) - - line_items.select{ |li| line_items_with_hidden_details.include? li }.each do |line_item| - # 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 - line_item.order.bill_address.andand.assign_attributes(firstname: I18n.t('admin.reports.hidden'), lastname: "", phone: "", address1: "", address2: "", city: "", zipcode: "", state: nil) - line_item.order.ship_address.andand.assign_attributes(firstname: I18n.t('admin.reports.hidden'), lastname: "", phone: "", address1: "", address2: "", city: "", zipcode: "", state: nil) - line_item.order.assign_attributes(email: I18n.t('admin.reports.hidden')) - end - line_items + OpenFoodNetwork::Reports::LineItems.list(permissions, params) end def rules diff --git a/lib/open_food_network/packing_report.rb b/lib/open_food_network/packing_report.rb index 4b3a9cbc64..45544dc26e 100644 --- a/lib/open_food_network/packing_report.rb +++ b/lib/open_food_network/packing_report.rb @@ -1,3 +1,5 @@ +require "open_food_network/reports/line_items" + module OpenFoodNetwork class PackingReport attr_reader :params @@ -36,28 +38,12 @@ module OpenFoodNetwork end def search - permissions.visible_orders.complete.not_state(:canceled).search(params[:q]) + OpenFoodNetwork::Reports::LineItems.search_orders(permissions, params) end def table_items return [] unless @render_table - - orders = search.result - - line_items = permissions.visible_line_items.merge(Spree::LineItem.where(order_id: orders)) - line_items = line_items.supplied_by_any(params[:supplier_id_in]) if params[:supplier_id_in].present? - - line_items_with_hidden_details = - permissions.editable_line_items.empty? ? line_items : line_items.where('"spree_line_items"."id" NOT IN (?)', permissions.editable_line_items) - - line_items.select{ |li| line_items_with_hidden_details.include? li }.each do |line_item| - # 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 - line_item.order.bill_address.andand.assign_attributes(firstname: I18n.t('admin.reports.hidden'), lastname: "", phone: "", address1: "", address2: "", city: "", zipcode: "", state: nil) - line_item.order.ship_address.andand.assign_attributes(firstname: I18n.t('admin.reports.hidden'), lastname: "", phone: "", address1: "", address2: "", city: "", zipcode: "", state: nil) - line_item.order.assign_attributes(email: I18n.t('admin.reports.hidden')) - end - line_items + OpenFoodNetwork::Reports::LineItems.list(permissions, params) end def rules diff --git a/lib/open_food_network/reports/line_items.rb b/lib/open_food_network/reports/line_items.rb new file mode 100644 index 0000000000..86350a95e3 --- /dev/null +++ b/lib/open_food_network/reports/line_items.rb @@ -0,0 +1,30 @@ +module OpenFoodNetwork + module Reports + # shared code to search and list line items + module LineItems + def self.search_orders(permissions, params) + permissions.visible_orders.complete.not_state(:canceled).search(params[:q]) + end + + def self.list(permissions, params) + orders = search_orders(permissions, params).result + + line_items = permissions.visible_line_items.merge(Spree::LineItem.where(order_id: orders)) + line_items = line_items.supplied_by_any(params[:supplier_id_in]) if params[:supplier_id_in].present? + + # If empty array is passed in, the where clause will return all line_items, which is bad + line_items_with_hidden_details = + permissions.editable_line_items.empty? ? line_items : line_items.where('"spree_line_items"."id" NOT IN (?)', permissions.editable_line_items) + + line_items.select{ |li| line_items_with_hidden_details.include? li }.each do |line_item| + # 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 + line_item.order.bill_address.andand.assign_attributes(firstname: I18n.t('admin.reports.hidden'), lastname: "", phone: "", address1: "", address2: "", city: "", zipcode: "", state: nil) + line_item.order.ship_address.andand.assign_attributes(firstname: I18n.t('admin.reports.hidden'), lastname: "", phone: "", address1: "", address2: "", city: "", zipcode: "", state: nil) + line_item.order.assign_attributes(email: I18n.t('admin.reports.hidden')) + end + line_items + end + end + end +end From 7f8f935017e98ed6263aa31eae60789922e1783f Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Tue, 8 May 2018 08:45:29 +1000 Subject: [PATCH 081/206] Shorten module referencing --- lib/open_food_network/bulk_coop_report.rb | 8 ++++---- lib/open_food_network/orders_and_fulfillments_report.rb | 4 ++-- lib/open_food_network/packing_report.rb | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/open_food_network/bulk_coop_report.rb b/lib/open_food_network/bulk_coop_report.rb index b06d517b6c..1e0cf5e69d 100644 --- a/lib/open_food_network/bulk_coop_report.rb +++ b/lib/open_food_network/bulk_coop_report.rb @@ -10,8 +10,8 @@ module OpenFoodNetwork @user = user @render_table = render_table - @supplier_report = OpenFoodNetwork::Reports::BulkCoopSupplierReport.new - @allocation_report = OpenFoodNetwork::Reports::BulkCoopAllocationReport.new + @supplier_report = Reports::BulkCoopSupplierReport.new + @allocation_report = Reports::BulkCoopAllocationReport.new end def header @@ -46,12 +46,12 @@ module OpenFoodNetwork end def search - OpenFoodNetwork::Reports::LineItems.search_orders(permissions, params) + Reports::LineItems.search_orders(permissions, params) end def table_items return [] unless @render_table - OpenFoodNetwork::Reports::LineItems.list(permissions, params) + Reports::LineItems.list(permissions, params) end def rules diff --git a/lib/open_food_network/orders_and_fulfillments_report.rb b/lib/open_food_network/orders_and_fulfillments_report.rb index b707cd590f..eabdbc6c5f 100644 --- a/lib/open_food_network/orders_and_fulfillments_report.rb +++ b/lib/open_food_network/orders_and_fulfillments_report.rb @@ -48,12 +48,12 @@ module OpenFoodNetwork end def search - OpenFoodNetwork::Reports::LineItems.search_orders(permissions, params) + Reports::LineItems.search_orders(permissions, params) end def table_items return [] unless @render_table - OpenFoodNetwork::Reports::LineItems.list(permissions, params) + Reports::LineItems.list(permissions, params) end def rules diff --git a/lib/open_food_network/packing_report.rb b/lib/open_food_network/packing_report.rb index 45544dc26e..6f29fa7bc9 100644 --- a/lib/open_food_network/packing_report.rb +++ b/lib/open_food_network/packing_report.rb @@ -38,12 +38,12 @@ module OpenFoodNetwork end def search - OpenFoodNetwork::Reports::LineItems.search_orders(permissions, params) + Reports::LineItems.search_orders(permissions, params) end def table_items return [] unless @render_table - OpenFoodNetwork::Reports::LineItems.list(permissions, params) + Reports::LineItems.list(permissions, params) end def rules From 57dd9845127e5e73e77a13bafaa84c8fce03d319 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 10 May 2018 10:24:21 +1000 Subject: [PATCH 082/206] Convert specs to RSpec 3.7.0 syntax with Transpec This conversion is done by Transpec 3.3.0 with the following command: transpec spec/lib/open_food_network/order_and_distributor_report_spec.rb * 2 conversions from: == expected to: eq(expected) * 2 conversions from: obj.should to: expect(obj).to For more details: https://github.com/yujinakayama/transpec#supported-conversions --- .../order_and_distributor_report_spec.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/lib/open_food_network/order_and_distributor_report_spec.rb b/spec/lib/open_food_network/order_and_distributor_report_spec.rb index 642263b259..b8ba6d97cd 100644 --- a/spec/lib/open_food_network/order_and_distributor_report_spec.rb +++ b/spec/lib/open_food_network/order_and_distributor_report_spec.rb @@ -26,11 +26,11 @@ module OpenFoodNetwork subject = OrderAndDistributorReport.new nil header = subject.header - header.should == ["Order date", "Order Id", + expect(header).to eq(["Order date", "Order Id", "Customer Name","Customer Email", "Customer Phone", "Customer City", "SKU", "Item name", "Variant", "Quantity", "Max Quantity", "Cost", "Shipping Cost", "Payment Method", - "Distributor", "Distributor address", "Distributor city", "Distributor postcode", "Shipping instructions"] + "Distributor", "Distributor address", "Distributor city", "Distributor postcode", "Shipping instructions"]) end it "should denormalise order and distributor details for display as csv" do @@ -38,11 +38,11 @@ module OpenFoodNetwork table = subject.send(:line_item_details, [@order]) - table[0].should == [@order.created_at, @order.id, + expect(table[0]).to eq([@order.created_at, @order.id, @bill_address.full_name, @order.email, @bill_address.phone, @bill_address.city, @line_item.product.sku, @line_item.product.name, @line_item.options_text, @line_item.quantity, @line_item.max_quantity, @line_item.price * @line_item.quantity, @line_item.distribution_fee, @payment_method.name, - @distributor.name, @distributor.address.address1, @distributor.address.city, @distributor.address.zipcode, @shipping_instructions ] + @distributor.name, @distributor.address.address1, @distributor.address.city, @distributor.address.zipcode, @shipping_instructions ]) end end end From d218a51d96965dc9b55edcca61dbf0eaeb2c0355 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 10 May 2018 11:39:00 +1000 Subject: [PATCH 083/206] Test public instead of private method Refactoring and styling the whole thing, possibly causing conflicts with other pull requests. --- .rubocop_todo.yml | 7 -- .../order_and_distributor_report_spec.rb | 81 +++++++++++-------- 2 files changed, 47 insertions(+), 41 deletions(-) diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index cf4dd57a72..4154cf8ac2 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -25,7 +25,6 @@ Layout/AlignArray: - 'lib/open_food_network/orders_and_fulfillments_report.rb' - 'lib/open_food_network/packing_report.rb' - 'spec/controllers/spree/orders_controller_spec.rb' - - 'spec/lib/open_food_network/order_and_distributor_report_spec.rb' - 'spec/lib/open_food_network/order_grouper_spec.rb' # Offense count: 127 @@ -234,7 +233,6 @@ Layout/EmptyLines: - 'spec/jobs/heartbeat_job_spec.rb' - 'spec/lib/open_food_network/enterprise_fee_calculator_spec.rb' - 'spec/lib/open_food_network/option_value_namer_spec.rb' - - 'spec/lib/open_food_network/order_and_distributor_report_spec.rb' - 'spec/lib/open_food_network/order_cycle_form_applicator_spec.rb' - 'spec/lib/open_food_network/order_cycle_management_report_spec.rb' - 'spec/lib/open_food_network/order_cycle_permissions_spec.rb' @@ -308,7 +306,6 @@ Layout/EmptyLinesAroundBlockBody: - 'spec/jobs/update_billable_periods_spec.rb' - 'spec/lib/open_food_network/group_buy_report_spec.rb' - 'spec/lib/open_food_network/lettuce_share_report_spec.rb' - - 'spec/lib/open_food_network/order_and_distributor_report_spec.rb' - 'spec/lib/open_food_network/order_grouper_spec.rb' - 'spec/lib/open_food_network/referer_parser_spec.rb' - 'spec/lib/open_food_network/user_balance_calculator_spec.rb' @@ -499,7 +496,6 @@ Layout/LeadingCommentSpace: - 'spec/features/admin/products_spec.rb' - 'spec/features/admin/reports_spec.rb' - 'spec/jobs/finalize_account_invoices_spec.rb' - - 'spec/lib/open_food_network/order_and_distributor_report_spec.rb' - 'spec/lib/open_food_network/order_grouper_spec.rb' - 'spec/lib/open_food_network/user_balance_calculator_spec.rb' - 'spec/models/enterprise_spec.rb' @@ -618,7 +614,6 @@ Layout/SpaceAfterComma: - 'spec/features/admin/variant_overrides_spec.rb' - 'spec/jobs/update_account_invoices_spec.rb' - 'spec/lib/open_food_network/group_buy_report_spec.rb' - - 'spec/lib/open_food_network/order_and_distributor_report_spec.rb' - 'spec/lib/open_food_network/subscription_summary_spec.rb' - 'spec/models/content_configuration_spec.rb' - 'spec/models/spree/line_item_spec.rb' @@ -761,7 +756,6 @@ Layout/SpaceInsideArrayLiteralBrackets: - 'spec/controllers/cart_controller_spec.rb' - 'spec/features/admin/reports_spec.rb' - 'spec/jobs/update_billable_periods_spec.rb' - - 'spec/lib/open_food_network/order_and_distributor_report_spec.rb' - 'spec/lib/open_food_network/order_grouper_spec.rb' - 'spec/lib/open_food_network/users_and_enterprises_report_spec.rb' @@ -2125,7 +2119,6 @@ Style/HashSyntax: - 'spec/jobs/subscription_placement_job_spec.rb' - 'spec/lib/open_food_network/group_buy_report_spec.rb' - 'spec/lib/open_food_network/lettuce_share_report_spec.rb' - - 'spec/lib/open_food_network/order_and_distributor_report_spec.rb' - 'spec/lib/open_food_network/order_cycle_form_applicator_spec.rb' - 'spec/lib/open_food_network/order_grouper_spec.rb' - 'spec/lib/open_food_network/tag_rule_applicator_spec.rb' diff --git a/spec/lib/open_food_network/order_and_distributor_report_spec.rb b/spec/lib/open_food_network/order_and_distributor_report_spec.rb index b8ba6d97cd..4cd03de91c 100644 --- a/spec/lib/open_food_network/order_and_distributor_report_spec.rb +++ b/spec/lib/open_food_network/order_and_distributor_report_spec.rb @@ -1,48 +1,61 @@ require 'spec_helper' - module OpenFoodNetwork describe OrderAndDistributorReport do - - describe "orders and distributors report" do - - before(:each) do - #normal completed order - @bill_address = create(:address) - @distributor_address = create(:address, :address1 => "distributor address", :city => 'The Shire', :zipcode => "1234") - @distributor = create(:distributor_enterprise, :address => @distributor_address) - product = create(:product) - product_distribution = create(:product_distribution, :product => product, :distributor => @distributor) - @shipping_instructions = "pick up on thursday please!" - @order = create(:order, :distributor => @distributor, :bill_address => @bill_address, :special_instructions => @shipping_instructions) - @payment_method = create(:payment_method, :distributors => [@distributor]) - payment = create(:payment, :payment_method => @payment_method, :order => @order ) - @order.payments << payment - @line_item = create(:line_item, :product => product, :order => @order) - @order.line_items << @line_item - end - - it "should return a header row describing the report" do + describe 'orders and distributors report' do + it 'should return a header row describing the report' do subject = OrderAndDistributorReport.new nil header = subject.header - expect(header).to eq(["Order date", "Order Id", - "Customer Name","Customer Email", "Customer Phone", "Customer City", - "SKU", "Item name", "Variant", "Quantity", "Max Quantity", "Cost", "Shipping Cost", - "Payment Method", - "Distributor", "Distributor address", "Distributor city", "Distributor postcode", "Shipping instructions"]) + expect(header).to eq(['Order date', 'Order Id', + 'Customer Name', 'Customer Email', 'Customer Phone', 'Customer City', + 'SKU', 'Item name', 'Variant', 'Quantity', 'Max Quantity', 'Cost', 'Shipping Cost', + 'Payment Method', + 'Distributor', 'Distributor address', 'Distributor city', 'Distributor postcode', 'Shipping instructions']) end - it "should denormalise order and distributor details for display as csv" do - subject = OrderAndDistributorReport.new create(:admin_user), {}, true + context 'with completed order' do + let(:bill_address) { create(:address) } + let(:distributor) { create(:distributor_enterprise) } + let(:product) { create(:product) } + let(:shipping_instructions) { 'pick up on thursday please!' } + let(:order) { create(:order, state: 'complete', completed_at: Time.zone.now, distributor: distributor, bill_address: bill_address, special_instructions: shipping_instructions) } + let(:payment_method) { create(:payment_method, distributors: [distributor]) } + let(:payment) { create(:payment, payment_method: payment_method, order: order) } + let(:line_item) { create(:line_item, product: product, order: order) } - table = subject.send(:line_item_details, [@order]) + before do + order.payments << payment + order.line_items << line_item + end - expect(table[0]).to eq([@order.created_at, @order.id, - @bill_address.full_name, @order.email, @bill_address.phone, @bill_address.city, - @line_item.product.sku, @line_item.product.name, @line_item.options_text, @line_item.quantity, @line_item.max_quantity, @line_item.price * @line_item.quantity, @line_item.distribution_fee, - @payment_method.name, - @distributor.name, @distributor.address.address1, @distributor.address.city, @distributor.address.zipcode, @shipping_instructions ]) + it 'should denormalise order and distributor details for display as csv' do + subject = OrderAndDistributorReport.new create(:admin_user), {}, true + + table = subject.table + + expect(table[0]).to eq([ + order.reload.created_at, + order.id, + bill_address.full_name, + order.email, + bill_address.phone, + bill_address.city, + line_item.product.sku, + line_item.product.name, + line_item.options_text, + line_item.quantity, + line_item.max_quantity, + line_item.price * line_item.quantity, + line_item.distribution_fee, + payment_method.name, + distributor.name, + distributor.address.address1, + distributor.address.city, + distributor.address.zipcode, + shipping_instructions + ]) + end end end end From b05b9747b82f682222866d3ffd02349526402176 Mon Sep 17 00:00:00 2001 From: Frank West Date: Fri, 18 May 2018 07:20:56 -0700 Subject: [PATCH 084/206] Fix enterprise description display formatting Removes `text-small` class from container and change container to `div` instead of `p` for semantic differentiation. --- .../javascripts/templates/partials/enterprise_details.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/templates/partials/enterprise_details.html.haml b/app/assets/javascripts/templates/partials/enterprise_details.html.haml index 35a47b3f11..609ac155b6 100644 --- a/app/assets/javascripts/templates/partials/enterprise_details.html.haml +++ b/app/assets/javascripts/templates/partials/enterprise_details.html.haml @@ -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'"} From f995a04ebfa2a3cd48b6a1ade0dcf67c1bb5db51 Mon Sep 17 00:00:00 2001 From: Frank West Date: Fri, 18 May 2018 14:05:28 -0700 Subject: [PATCH 085/206] Add phone number to shopfront contact info --- app/views/shopping_shared/_contact.html.haml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/views/shopping_shared/_contact.html.haml b/app/views/shopping_shared/_contact.html.haml index 405c958768..637ca5f530 100644 --- a/app/views/shopping_shared/_contact.html.haml +++ b/app/views/shopping_shared/_contact.html.haml @@ -18,11 +18,14 @@ = current_distributor.address.zipcode .small-12.large-4.columns - - if current_distributor.website || current_distributor.email_address + - if current_distributor.website || current_distributor.email_address || current_distributor.phone %div.center .header = t :shopping_contact_web %p + - unless current_distributor.phone.blank? + = current_distributor.phone + %br - unless current_distributor.website.blank? %a{href: "http://#{current_distributor.website}", target: "_blank" } = current_distributor.website From c2934d35705eb9c5a0d0175b2c3fcec3b324c809 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Mon, 21 May 2018 17:50:34 +0100 Subject: [PATCH 086/206] Ensure domain in SSL header matches request with or without www prefix --- app/controllers/application_controller.rb | 2 +- .../embedded_shopfronts_headers_spec.rb | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index a4bdc88b8f..d22e8a6ba4 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -60,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 diff --git a/spec/requests/embedded_shopfronts_headers_spec.rb b/spec/requests/embedded_shopfronts_headers_spec.rb index 8056946f23..9d2c1c523e 100644 --- a/spec/requests/embedded_shopfronts_headers_spec.rb +++ b/spec/requests/embedded_shopfronts_headers_spec.rb @@ -44,7 +44,7 @@ describe "setting response headers for embedded shopfronts", type: :request do context "with a valid whitelist" do before do Spree::Config[:embedded_shopfronts_whitelist] = "example.com external-site.com" - allow_any_instance_of(ActionDispatch::Request).to receive(:referer).and_return('http://www.external-site.com/shop?embedded_shopfront=true') + allow_any_instance_of(ActionDispatch::Request).to receive(:referer).and_return('http://external-site.com/shop?embedded_shopfront=true') end it "allows iframes on certain pages when enabled in configuration" do @@ -61,5 +61,20 @@ describe "setting response headers for embedded shopfronts", type: :request do expect(response.headers['Content-Security-Policy']).to eq "frame-ancestors 'none'" end end + + context "with www prefix" do + before do + Spree::Config[:embedded_shopfronts_whitelist] = "example.com external-site.com" + allow_any_instance_of(ActionDispatch::Request).to receive(:referer).and_return('http://www.external-site.com/shop?embedded_shopfront=true') + end + + it "matches the URL structure in the header" do + get shops_path + + expect(response.status).to be 200 + expect(response.headers['X-Frame-Options']).to be_nil + expect(response.headers['Content-Security-Policy']).to eq "frame-ancestors www.external-site.com" + end + end end end From 5a182e8e887476e51979c574cade9d5acec0dfd1 Mon Sep 17 00:00:00 2001 From: Pau Perez Date: Tue, 15 May 2018 17:27:28 +0200 Subject: [PATCH 087/206] Remove enterprises producers tab from dashboard --- app/models/enterprise.rb | 8 ---- .../admin/overview/_enterprises.html.haml | 2 - .../_enterprises_producers_tab.html.haml | 47 ------------------- .../overview/_enterprises_tabs.html.haml | 2 - config/locales/en.yml | 6 --- spec/features/admin/enterprise_user_spec.rb | 5 -- spec/models/enterprise_spec.rb | 10 ---- 7 files changed, 80 deletions(-) delete mode 100644 app/views/spree/admin/overview/_enterprises_producers_tab.html.haml diff --git a/app/models/enterprise.rb b/app/models/enterprise.rb index 82618e4e2e..52ffa73ad1 100644 --- a/app/models/enterprise.rb +++ b/app/models/enterprise.rb @@ -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 diff --git a/app/views/spree/admin/overview/_enterprises.html.haml b/app/views/spree/admin/overview/_enterprises.html.haml index fbf19fbd43..64394c71c7 100644 --- a/app/views/spree/admin/overview/_enterprises.html.haml +++ b/app/views/spree/admin/overview/_enterprises.html.haml @@ -5,7 +5,5 @@ = render 'enterprises_none' - else - = render 'enterprises_tabs' = render 'enterprises_hubs_tab' - = render 'enterprises_producers_tab' = render 'enterprises_footer' diff --git a/app/views/spree/admin/overview/_enterprises_producers_tab.html.haml b/app/views/spree/admin/overview/_enterprises_producers_tab.html.haml deleted file mode 100644 index 19cae1e6f8..0000000000 --- a/app/views/spree/admin/overview/_enterprises_producers_tab.html.haml +++ /dev/null @@ -1,47 +0,0 @@ -%div.producers_tab{ ng: { show: "activeTab == 'producers'"} } - %div.list-title.sixteen.columns.alpha - %span.five.columns.alpha - = t "spree_admin_enterprises_producers_name" - - if can? :admin, Spree::Product - %span.centered.three.columns - = t "spree_admin_enterprises_producers_total_products" - %span.centered.three.columns - = t "spree_admin_enterprises_producers_active_products" - - if can? :admin, OrderCycle - %span.centered.three.columns - = t "spree_admin_enterprises_producers_order_cycles" - %div.sixteen.columns.alpha.list - - @enterprises.is_primary_producer.each do |enterprise| - %a.sixteen.columns.alpha.list-item{ class: "#{cycle('odd','even')}", href: "#{main_app.edit_admin_enterprise_path(enterprise)}" } - - %span.five.columns.alpha - = enterprise.name - - %span.symbol.three.columns.centered - - if can? :admin, Spree::Product - %span.one.column.alpha   - %span.text-icon.one.column.centered{ class: "#{enterprise.supplied_products.not_deleted.any? ? "green" : "red" }" } - = enterprise.supplied_products.not_deleted.count - %span.one.column.omega   - - else -   - %span.symbol.three.columns.centered - - if can? :admin, Spree::Product - %span.one.column.alpha   - %span.text-icon.one.column.centered{ class: "#{enterprise.supplied_and_active_products_on_hand.any? ? "green" : "red" }" } - = enterprise.supplied_and_active_products_on_hand.count - %span.one.column.omega   - - else -   - - %span.symbol.three.columns.centered - - if can? :admin, OrderCycle - %span.one.column.alpha   - %span.text-icon.one.column.centered{ class: "#{enterprise.active_products_in_order_cycles.any? ? "green" : "orange" }" } - = enterprise.active_products_in_order_cycles.count - %span.one.column.omega   - - else -   - - %span.two.columns.omega.right - %span.icon-arrow-right diff --git a/app/views/spree/admin/overview/_enterprises_tabs.html.haml b/app/views/spree/admin/overview/_enterprises_tabs.html.haml index 5f8eb9e3c2..439aaa3758 100644 --- a/app/views/spree/admin/overview/_enterprises_tabs.html.haml +++ b/app/views/spree/admin/overview/_enterprises_tabs.html.haml @@ -1,5 +1,3 @@ %div.sixteen.columns.alpha.tabs %div.dashboard_tab.eight.columns.alpha.blue{ ng: { class: "{selected: activeTab == 'hubs'}", click: "activeTab = 'hubs'" } } = t "spree_admin_enterprises_tabs_hubs" - %div.dashboard_tab.eight.columns.omega.blue{ ng: { class: "{selected: activeTab == 'producers'}", click: "activeTab = 'producers'" } } - = t "spree_admin_enterprises_tabs_producers" diff --git a/config/locales/en.yml b/config/locales/en.yml index 968fd0a06b..285d710f1f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1935,13 +1935,7 @@ See the %{link} to find out more about %{sitename}'s features and to start using spree_admin_enterprises_fees: "Enterprise Fees" spree_admin_enterprises_none_create_a_new_enterprise: "CREATE A NEW ENTERPRISE" spree_admin_enterprises_none_text: "You don't have any enterprises yet" - spree_admin_enterprises_producers_name: "Name" - spree_admin_enterprises_producers_total_products: "Total Products" - spree_admin_enterprises_producers_active_products: "Active Products" - spree_admin_enterprises_producers_order_cycles: "Products in OCs" - spree_admin_enterprises_producers_order_cycles_title: "" spree_admin_enterprises_tabs_hubs: "HUBS" - spree_admin_enterprises_tabs_producers: "PRODUCERS" spree_admin_enterprises_producers_manage_products: "MANAGE PRODUCTS" spree_admin_enterprises_any_active_products_text: "You don't have any active products." spree_admin_enterprises_create_new_product: "CREATE A NEW PRODUCT" diff --git a/spec/features/admin/enterprise_user_spec.rb b/spec/features/admin/enterprise_user_spec.rb index 635b819471..dd24e465d8 100644 --- a/spec/features/admin/enterprise_user_spec.rb +++ b/spec/features/admin/enterprise_user_spec.rb @@ -88,11 +88,6 @@ feature %q{ end it "shows me enterprise product info but not payment methods, shipping methods or enterprise fees" do - # Producer product info - page.should have_selector '.producers_tab span', text: 'Total Products' - page.should have_selector '.producers_tab span', text: 'Active Products' - page.should_not have_selector '.producers_tab span', text: 'Products in OCs' - # Payment methods, shipping methods, enterprise fees page.should_not have_selector '.hubs_tab span', text: 'Payment Methods' page.should_not have_selector '.hubs_tab span', text: 'Shipping Methods' diff --git a/spec/models/enterprise_spec.rb b/spec/models/enterprise_spec.rb index 890c1a987d..8fc1b57a0b 100644 --- a/spec/models/enterprise_spec.rb +++ b/spec/models/enterprise_spec.rb @@ -551,16 +551,6 @@ describe Enterprise do end end - describe "supplied_and_active_products_on_hand" do - it "find only active products which are in stock" do - supplier = create(:supplier_enterprise) - inactive_product = create(:product, supplier: supplier, on_hand: 1, available_on: Date.tomorrow) - out_of_stock_product = create(:product, supplier: supplier, on_hand: 0, available_on: Date.yesterday) - p1 = create(:product, supplier: supplier, on_hand: 1, available_on: Date.yesterday) - supplier.supplied_and_active_products_on_hand.should == [p1] - end - end - describe "finding variants distributed by the enterprise" do it "finds master and other variants" do d = create(:distributor_enterprise) From 51f3542a9259734d832c3685f9c9661b5b88b192 Mon Sep 17 00:00:00 2001 From: Pau Perez Date: Tue, 15 May 2018 17:31:26 +0200 Subject: [PATCH 088/206] Remove no longer used Angular controller There are no tabs to keep track of now. --- .../controllers/enterprises_dashboard_controller.js.coffee | 2 -- app/views/spree/admin/overview/_enterprises.html.haml | 2 +- app/views/spree/admin/overview/_enterprises_hubs_tab.html.haml | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) delete mode 100644 app/assets/javascripts/admin/controllers/enterprises_dashboard_controller.js.coffee diff --git a/app/assets/javascripts/admin/controllers/enterprises_dashboard_controller.js.coffee b/app/assets/javascripts/admin/controllers/enterprises_dashboard_controller.js.coffee deleted file mode 100644 index 60315d30c1..0000000000 --- a/app/assets/javascripts/admin/controllers/enterprises_dashboard_controller.js.coffee +++ /dev/null @@ -1,2 +0,0 @@ -angular.module("ofn.admin").controller "enterprisesDashboardCtrl", ($scope) -> - $scope.activeTab = "hubs" diff --git a/app/views/spree/admin/overview/_enterprises.html.haml b/app/views/spree/admin/overview/_enterprises.html.haml index 64394c71c7..2ffea0a65c 100644 --- a/app/views/spree/admin/overview/_enterprises.html.haml +++ b/app/views/spree/admin/overview/_enterprises.html.haml @@ -1,4 +1,4 @@ -%div.dashboard_item.sixteen.columns.alpha#enterprises{ 'ng-controller' => "enterprisesDashboardCtrl" } +%div.dashboard_item.sixteen.columns.alpha#enterprises = render 'enterprises_header' - if @enterprises.empty? diff --git a/app/views/spree/admin/overview/_enterprises_hubs_tab.html.haml b/app/views/spree/admin/overview/_enterprises_hubs_tab.html.haml index dbaff71cfa..cc48b07abf 100644 --- a/app/views/spree/admin/overview/_enterprises_hubs_tab.html.haml +++ b/app/views/spree/admin/overview/_enterprises_hubs_tab.html.haml @@ -1,4 +1,4 @@ -%div.hubs_tab{ ng: { show: "activeTab == 'hubs'"} } +%div.hubs_tab %div.sixteen.columns.alpha.list-title %span.five.columns.alpha = t "spree_admin_enterprises_hubs_name" From 889199a525f8b38858d9530aa0f95bd4025d326e Mon Sep 17 00:00:00 2001 From: Pau Perez Date: Tue, 15 May 2018 18:00:30 +0200 Subject: [PATCH 089/206] Refactor Overview Controller to make it more clear Assigns meaningful names to the boolean conditions to make it easier to understand, breaks down the big and nested if/else and converts the specs to RSpec 3. Note the check `!spree_current_user.admin?` has been removed because in admin/base_controller_decorator.rb `#authorize_admin` is already called. --- .rubocop_todo.yml | 2 - .../admin/overview_controller_decorator.rb | 82 +++++++++++++++---- .../spree/admin/overview_controller_spec.rb | 31 ++++--- 3 files changed, 84 insertions(+), 31 deletions(-) diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 4154cf8ac2..3e49ab57d3 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -2149,7 +2149,6 @@ Style/IfInsideElse: Exclude: - 'app/controllers/admin/column_preferences_controller.rb' - 'app/controllers/admin/variant_overrides_controller.rb' - - 'app/controllers/spree/admin/overview_controller_decorator.rb' - 'app/controllers/spree/admin/products_controller_decorator.rb' # Offense count: 1 @@ -2435,7 +2434,6 @@ Style/RedundantSelf: Style/RegexpLiteral: Exclude: - 'app/controllers/admin/enterprises_controller.rb' - - 'app/controllers/spree/admin/overview_controller_decorator.rb' - 'app/helpers/groups_helper.rb' - 'app/helpers/html_helper.rb' - 'app/models/enterprise.rb' diff --git a/app/controllers/spree/admin/overview_controller_decorator.rb b/app/controllers/spree/admin/overview_controller_decorator.rb index ffd2569c1f..548631aaf4 100644 --- a/app/controllers/spree/admin/overview_controller_decorator.rb +++ b/app/controllers/spree/admin/overview_controller_decorator.rb @@ -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] + def managed_enterprises + spree_current_user.enterprises + end end diff --git a/spec/controllers/spree/admin/overview_controller_spec.rb b/spec/controllers/spree/admin/overview_controller_spec.rb index 7b024f10bf..e445932555 100644 --- a/spec/controllers/spree/admin/overview_controller_spec.rb +++ b/spec/controllers/spree/admin/overview_controller_spec.rb @@ -2,18 +2,20 @@ require 'spec_helper' describe Spree::Admin::OverviewController, type: :controller do include AuthenticationWorkflow - context "loading overview" do - let(:user) { create_enterprise_user(enterprise_limit: 2) } + describe "#index" do before do - controller.stub spree_current_user: user + allow(controller).to receive(:spree_current_user).and_return(user) end context "when user owns only one enterprise" do + let(:user) { create_enterprise_user } let!(:enterprise) { create(:distributor_enterprise, owner: user) } context "when the referer is not an admin page" do - before { @request.env['HTTP_REFERER'] = 'http://test.com/some_other_path' } + before do + @request.env['HTTP_REFERER'] = 'http://test.com/not_admin_path' + end context "and the enterprise has sells='unspecified'" do before do @@ -22,14 +24,15 @@ describe Spree::Admin::OverviewController, type: :controller do it "redirects to the welcome page for the enterprise" do spree_get :index - response.should redirect_to welcome_admin_enterprise_path(enterprise) + expect(response) + .to redirect_to welcome_admin_enterprise_path(enterprise) end end context "and the enterprise does not have sells='unspecified'" do it "renders the single enterprise dashboard" do spree_get :index - response.should render_template "single_enterprise_dashboard" + expect(response).to render_template :single_enterprise_dashboard end end end @@ -39,17 +42,21 @@ describe Spree::Admin::OverviewController, type: :controller do it "renders the single enterprise dashboard" do spree_get :index - response.should render_template "single_enterprise_dashboard" + expect(response).to render_template :single_enterprise_dashboard end end end context "when user owns multiple enterprises" do + let(:user) { create_enterprise_user(enterprise_limit: 2) } + let!(:enterprise1) { create(:distributor_enterprise, owner: user) } - let!(:enterprise2) { create(:distributor_enterprise, owner: user) } + before { create(:distributor_enterprise, owner: user) } context "when the referer is not an admin page" do - before { @request.env['HTTP_REFERER'] = 'http://test.com/some_other_path' } + before do + @request.env['HTTP_REFERER'] = 'http://test.com/not_admin_path' + end context "and at least one owned enterprise has sells='unspecified'" do before do @@ -58,14 +65,14 @@ describe Spree::Admin::OverviewController, type: :controller do it "redirects to the enterprises index" do spree_get :index - response.should redirect_to admin_enterprises_path + expect(response).to redirect_to admin_enterprises_path end end context "and no owned enterprises have sells='unspecified'" do it "renders the multiple enterprise dashboard" do spree_get :index - response.should render_template "multi_enterprise_dashboard" + expect(response).to render_template :multi_enterprise_dashboard end end end @@ -75,7 +82,7 @@ describe Spree::Admin::OverviewController, type: :controller do it "renders the multiple enterprise dashboard" do spree_get :index - response.should render_template "multi_enterprise_dashboard" + expect(response).to render_template :multi_enterprise_dashboard end end end From bd34f27aceebcb0938c680411950438dc16dfcda Mon Sep 17 00:00:00 2001 From: Pau Perez Date: Thu, 17 May 2018 13:14:25 +0200 Subject: [PATCH 090/206] Inline partials into enterprises view Now there are no tabs in the dashboard so is pointless to refer to them and to split in multiple partials. --- .../admin/overview/_enterprises.html.haml | 61 ++++++++++++++++++- .../overview/_enterprises_footer.html.haml | 3 - .../overview/_enterprises_hubs_tab.html.haml | 47 -------------- .../overview/_enterprises_none.html.haml | 8 --- .../overview/_enterprises_tabs.html.haml | 3 - 5 files changed, 58 insertions(+), 64 deletions(-) delete mode 100644 app/views/spree/admin/overview/_enterprises_footer.html.haml delete mode 100644 app/views/spree/admin/overview/_enterprises_hubs_tab.html.haml delete mode 100644 app/views/spree/admin/overview/_enterprises_none.html.haml delete mode 100644 app/views/spree/admin/overview/_enterprises_tabs.html.haml diff --git a/app/views/spree/admin/overview/_enterprises.html.haml b/app/views/spree/admin/overview/_enterprises.html.haml index 2ffea0a65c..7f8e9cce20 100644 --- a/app/views/spree/admin/overview/_enterprises.html.haml +++ b/app/views/spree/admin/overview/_enterprises.html.haml @@ -2,8 +2,63 @@ = render 'enterprises_header' - if @enterprises.empty? - = render 'enterprises_none' + %div.sixteen.columns.alpha.list-item.red + %span.text.fifteen.columns.alpha + = t "spree_admin_enterprises_none_text" + %span.one.columns.omega + %span.icon-remove-sign + %a.sixteen.columns.alpha.button.bottom.red{ href: "#{main_app.new_admin_enterprise_path}" } + = t "spree_admin_enterprises_none_create_a_new_enterprise" + %span.icon-arrow-right - else - = render 'enterprises_hubs_tab' - = render 'enterprises_footer' + %div.sixteen.columns.alpha.list-title + %span.five.columns.alpha + = t "spree_admin_enterprises_hubs_name" + - if can? :admin, Spree::PaymentMethod + %span.centered.three.columns + = t(:payment_methods) + - if can? :admin, Spree::ShippingMethod + %span.centered.three.columns + = t "spree_admin_enterprises_shipping_methods" + - if can? :admin, EnterpriseFee + %span.centered.three.columns + = t "spree_admin_enterprises_fees" + %div.sixteen.columns.alpha.list + - @enterprises.is_distributor.each do |enterprise| + %a.sixteen.columns.alpha.list-item{ class: "#{cycle('odd','even')}", href: "#{main_app.edit_admin_enterprise_path(enterprise)}" } + %span.five.columns.alpha + = enterprise.name + %span.symbol.three.columns.centered + - if can? :admin, Spree::PaymentMethod + - payment_method_count = enterprise.payment_methods.count + - if payment_method_count > 0 + %span.icon-ok-sign{ 'ofn-with-tip' => "#{pluralize payment_method_count, 'payment method'}" } + - else + %span.icon-remove-sign{ 'ofn-with-tip' => t('.has_no_payment_methods', enterprise: enterprise.name) } + - else +   + %span.symbol.three.columns.centered + - if can? :admin, Spree::ShippingMethod + - shipping_method_count = enterprise.shipping_methods.count + - if shipping_method_count > 0 + %span.icon-ok-sign{ 'ofn-with-tip' => "#{pluralize shipping_method_count, 'shipping method'}" } + - else + %span.icon-remove-sign{ 'ofn-with-tip' => t('.has_no_shipping_methods', enterprise: enterprise.name) } + - else +   + %span.symbol.three.columns.centered + - if can? :admin, EnterpriseFee + - fee_count = enterprise.enterprise_fees.count + - if fee_count > 0 + %span.icon-ok-sign{ 'ofn-with-tip' => "#{pluralize fee_count, 'fee'}" } + - else + %span.icon-warning-sign{ 'ofn-with-tip' => t('.has_no_enterprise_fees', enterprise: enterprise.name) } + - else +   + %span.two.columns.omega.right + %span.icon-arrow-right + + %a.sixteen.columns.alpha.button.bottom.blue{ href: "#{main_app.admin_enterprises_path}" } + = t "spree_admin_overview_enterprises_footer" + %span.icon-arrow-right diff --git a/app/views/spree/admin/overview/_enterprises_footer.html.haml b/app/views/spree/admin/overview/_enterprises_footer.html.haml deleted file mode 100644 index c704dfef64..0000000000 --- a/app/views/spree/admin/overview/_enterprises_footer.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -%a.sixteen.columns.alpha.button.bottom.blue{ href: "#{main_app.admin_enterprises_path}" } - = t "spree_admin_overview_enterprises_footer" - %span.icon-arrow-right diff --git a/app/views/spree/admin/overview/_enterprises_hubs_tab.html.haml b/app/views/spree/admin/overview/_enterprises_hubs_tab.html.haml deleted file mode 100644 index cc48b07abf..0000000000 --- a/app/views/spree/admin/overview/_enterprises_hubs_tab.html.haml +++ /dev/null @@ -1,47 +0,0 @@ -%div.hubs_tab - %div.sixteen.columns.alpha.list-title - %span.five.columns.alpha - = t "spree_admin_enterprises_hubs_name" - - if can? :admin, Spree::PaymentMethod - %span.centered.three.columns - = t(:payment_methods) - - if can? :admin, Spree::ShippingMethod - %span.centered.three.columns - = t "spree_admin_enterprises_shipping_methods" - - if can? :admin, EnterpriseFee - %span.centered.three.columns - = t "spree_admin_enterprises_fees" - %div.sixteen.columns.alpha.list - - @enterprises.is_distributor.each do |enterprise| - %a.sixteen.columns.alpha.list-item{ class: "#{cycle('odd','even')}", href: "#{main_app.edit_admin_enterprise_path(enterprise)}" } - %span.five.columns.alpha - = enterprise.name - %span.symbol.three.columns.centered - - if can? :admin, Spree::PaymentMethod - - payment_method_count = enterprise.payment_methods.count - - if payment_method_count > 0 - %span.icon-ok-sign{ 'ofn-with-tip' => "#{pluralize payment_method_count, 'payment method'}" } - - else - %span.icon-remove-sign{ 'ofn-with-tip' => t('.has_no_payment_methods', enterprise: enterprise.name) } - - else -   - %span.symbol.three.columns.centered - - if can? :admin, Spree::ShippingMethod - - shipping_method_count = enterprise.shipping_methods.count - - if shipping_method_count > 0 - %span.icon-ok-sign{ 'ofn-with-tip' => "#{pluralize shipping_method_count, 'shipping method'}" } - - else - %span.icon-remove-sign{ 'ofn-with-tip' => t('.has_no_shipping_methods', enterprise: enterprise.name) } - - else -   - %span.symbol.three.columns.centered - - if can? :admin, EnterpriseFee - - fee_count = enterprise.enterprise_fees.count - - if fee_count > 0 - %span.icon-ok-sign{ 'ofn-with-tip' => "#{pluralize fee_count, 'fee'}" } - - else - %span.icon-warning-sign{ 'ofn-with-tip' => t('.has_no_enterprise_fees', enterprise: enterprise.name) } - - else -   - %span.two.columns.omega.right - %span.icon-arrow-right diff --git a/app/views/spree/admin/overview/_enterprises_none.html.haml b/app/views/spree/admin/overview/_enterprises_none.html.haml deleted file mode 100644 index 72cd90df1a..0000000000 --- a/app/views/spree/admin/overview/_enterprises_none.html.haml +++ /dev/null @@ -1,8 +0,0 @@ -%div.sixteen.columns.alpha.list-item.red - %span.text.fifteen.columns.alpha - = t "spree_admin_enterprises_none_text" - %span.one.columns.omega - %span.icon-remove-sign -%a.sixteen.columns.alpha.button.bottom.red{ href: "#{main_app.new_admin_enterprise_path}" } - = t "spree_admin_enterprises_none_create_a_new_enterprise" - %span.icon-arrow-right diff --git a/app/views/spree/admin/overview/_enterprises_tabs.html.haml b/app/views/spree/admin/overview/_enterprises_tabs.html.haml deleted file mode 100644 index 439aaa3758..0000000000 --- a/app/views/spree/admin/overview/_enterprises_tabs.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -%div.sixteen.columns.alpha.tabs - %div.dashboard_tab.eight.columns.alpha.blue{ ng: { class: "{selected: activeTab == 'hubs'}", click: "activeTab = 'hubs'" } } - = t "spree_admin_enterprises_tabs_hubs" From 208e3bbadd25599c85adddafb2bd63f382be227c Mon Sep 17 00:00:00 2001 From: Pau Perez Date: Tue, 22 May 2018 10:09:24 +0200 Subject: [PATCH 091/206] Ensure non-distributors are listed in dashboard We want to show all enterprises in a single list regardless of their type. --- .../spree/admin/overview/_enterprises.html.haml | 2 +- spec/features/admin/overview_spec.rb | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/app/views/spree/admin/overview/_enterprises.html.haml b/app/views/spree/admin/overview/_enterprises.html.haml index 7f8e9cce20..5b7cfa056b 100644 --- a/app/views/spree/admin/overview/_enterprises.html.haml +++ b/app/views/spree/admin/overview/_enterprises.html.haml @@ -25,7 +25,7 @@ %span.centered.three.columns = t "spree_admin_enterprises_fees" %div.sixteen.columns.alpha.list - - @enterprises.is_distributor.each do |enterprise| + - @enterprises.each do |enterprise| %a.sixteen.columns.alpha.list-item{ class: "#{cycle('odd','even')}", href: "#{main_app.edit_admin_enterprise_path(enterprise)}" } %span.five.columns.alpha = enterprise.name diff --git a/spec/features/admin/overview_spec.rb b/spec/features/admin/overview_spec.rb index c1119a252e..530363929c 100644 --- a/spec/features/admin/overview_spec.rb +++ b/spec/features/admin/overview_spec.rb @@ -56,19 +56,24 @@ feature %q{ context "with multiple enterprises" do let(:d1) { create(:distributor_enterprise) } let(:d2) { create(:distributor_enterprise) } + let(:non_distributor_enterprise) { create(:enterprise, sells: 'none') } - before :each do + before do @enterprise_user.enterprise_roles.build(enterprise: d1).save @enterprise_user.enterprise_roles.build(enterprise: d2).save + @enterprise_user + .enterprise_roles.build(enterprise: non_distributor_enterprise).save end it "displays information about the enterprise" do visit '/admin' - page.should have_selector ".dashboard_item#enterprises h3", text: "My Enterprises" - page.should have_selector ".dashboard_item#products" - page.should have_selector ".dashboard_item#order_cycles" - page.should have_selector ".dashboard_item#enterprises .list-item", text: d1.name - page.should have_selector ".dashboard_item#enterprises .button.bottom", text: "MANAGE MY ENTERPRISES" + + expect(page).to have_selector ".dashboard_item#enterprises h3", text: "My Enterprises" + expect(page).to have_selector ".dashboard_item#products" + expect(page).to have_selector ".dashboard_item#order_cycles" + expect(page).to have_selector ".dashboard_item#enterprises .list-item", text: d1.name + expect(page).to have_selector ".dashboard_item#enterprises .list-item", text: non_distributor_enterprise.name + expect(page).to have_selector ".dashboard_item#enterprises .button.bottom", text: "MANAGE MY ENTERPRISES" end context "but no products or order cycles" do From 2a3772dba0b44a1d864c87f8f5564792cc98ee41 Mon Sep 17 00:00:00 2001 From: Pau Perez Date: Tue, 22 May 2018 16:50:27 +0200 Subject: [PATCH 092/206] Do not show icons for producer-enterprises Payment and shipping methods don't apply to them. --- .../admin/overview/_enterprise_row.html.haml | 33 +++++++++++++++++++ .../admin/overview/_enterprises.html.haml | 33 +------------------ 2 files changed, 34 insertions(+), 32 deletions(-) create mode 100644 app/views/spree/admin/overview/_enterprise_row.html.haml diff --git a/app/views/spree/admin/overview/_enterprise_row.html.haml b/app/views/spree/admin/overview/_enterprise_row.html.haml new file mode 100644 index 0000000000..682a50b65c --- /dev/null +++ b/app/views/spree/admin/overview/_enterprise_row.html.haml @@ -0,0 +1,33 @@ +%a.sixteen.columns.alpha.list-item{ class: "#{cycle('odd','even')}", href: "#{main_app.edit_admin_enterprise_path(enterprise)}" } + %span.five.columns.alpha + = enterprise.name + %span.symbol.three.columns.centered + - if can?(:admin, Spree::PaymentMethod) && enterprise.is_distributor + - payment_method_count = enterprise.payment_methods.count + - if payment_method_count > 0 + %span.icon-ok-sign{ 'ofn-with-tip' => "#{pluralize payment_method_count, 'payment method'}" } + - else + %span.icon-remove-sign{ 'ofn-with-tip' => t('.has_no_payment_methods', enterprise: enterprise.name) } + - else +   + %span.symbol.three.columns.centered + - if can?(:admin, Spree::ShippingMethod) && enterprise.is_distributor + - shipping_method_count = enterprise.shipping_methods.count + - if shipping_method_count > 0 + %span.icon-ok-sign{ 'ofn-with-tip' => "#{pluralize shipping_method_count, 'shipping method'}" } + - else + %span.icon-remove-sign{ 'ofn-with-tip' => t('.has_no_shipping_methods', enterprise: enterprise.name) } + - else +   + %span.symbol.three.columns.centered + - if can?(:admin, EnterpriseFee) && enterprise.is_distributor + - fee_count = enterprise.enterprise_fees.count + - if fee_count > 0 + %span.icon-ok-sign{ 'ofn-with-tip' => "#{pluralize fee_count, 'fee'}" } + - else + %span.icon-warning-sign{ 'ofn-with-tip' => t('.has_no_enterprise_fees', enterprise: enterprise.name) } + - else +   + %span.two.columns.omega.right + %span.icon-arrow-right + diff --git a/app/views/spree/admin/overview/_enterprises.html.haml b/app/views/spree/admin/overview/_enterprises.html.haml index 5b7cfa056b..1ffb68e8cb 100644 --- a/app/views/spree/admin/overview/_enterprises.html.haml +++ b/app/views/spree/admin/overview/_enterprises.html.haml @@ -26,38 +26,7 @@ = t "spree_admin_enterprises_fees" %div.sixteen.columns.alpha.list - @enterprises.each do |enterprise| - %a.sixteen.columns.alpha.list-item{ class: "#{cycle('odd','even')}", href: "#{main_app.edit_admin_enterprise_path(enterprise)}" } - %span.five.columns.alpha - = enterprise.name - %span.symbol.three.columns.centered - - if can? :admin, Spree::PaymentMethod - - payment_method_count = enterprise.payment_methods.count - - if payment_method_count > 0 - %span.icon-ok-sign{ 'ofn-with-tip' => "#{pluralize payment_method_count, 'payment method'}" } - - else - %span.icon-remove-sign{ 'ofn-with-tip' => t('.has_no_payment_methods', enterprise: enterprise.name) } - - else -   - %span.symbol.three.columns.centered - - if can? :admin, Spree::ShippingMethod - - shipping_method_count = enterprise.shipping_methods.count - - if shipping_method_count > 0 - %span.icon-ok-sign{ 'ofn-with-tip' => "#{pluralize shipping_method_count, 'shipping method'}" } - - else - %span.icon-remove-sign{ 'ofn-with-tip' => t('.has_no_shipping_methods', enterprise: enterprise.name) } - - else -   - %span.symbol.three.columns.centered - - if can? :admin, EnterpriseFee - - fee_count = enterprise.enterprise_fees.count - - if fee_count > 0 - %span.icon-ok-sign{ 'ofn-with-tip' => "#{pluralize fee_count, 'fee'}" } - - else - %span.icon-warning-sign{ 'ofn-with-tip' => t('.has_no_enterprise_fees', enterprise: enterprise.name) } - - else -   - %span.two.columns.omega.right - %span.icon-arrow-right + = render 'enterprise_row', { enterprise: enterprise } %a.sixteen.columns.alpha.button.bottom.blue{ href: "#{main_app.admin_enterprises_path}" } = t "spree_admin_overview_enterprises_footer" From 12232f552cc23ad8fee2083a397cc4c5c40ef5fd Mon Sep 17 00:00:00 2001 From: Luis Ramos Date: Tue, 22 May 2018 21:00:03 +0100 Subject: [PATCH 093/206] Added missing translation key for no results in admin orders page --- config/locales/en.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config/locales/en.yml b/config/locales/en.yml index 968fd0a06b..48de721556 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2243,6 +2243,7 @@ See the %{link} to find out more about %{sitename}'s features and to start using order_cycles_no_permission_to_coordinate_error: "None of your enterprises have permission to coordinate an order cycle" order_cycles_no_permission_to_create_error: "You don't have permission to create an order cycle coordinated by that enterprise" back_to_orders_list: "Back to order list" + no_orders_found: "No Orders Found" order_information: "Order Information" date_completed: "Date Completed" amount: "Amount" From a59d9cb67065345dc29f0d1ae549b5c10ea7c325 Mon Sep 17 00:00:00 2001 From: luisramos0 Date: Wed, 23 May 2018 13:12:56 +0100 Subject: [PATCH 094/206] Fixed broken translation keys in new order cycle screen --- app/views/admin/order_cycles/_name_and_timing_form.html.haml | 2 +- config/locales/en.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/admin/order_cycles/_name_and_timing_form.html.haml b/app/views/admin/order_cycles/_name_and_timing_form.html.haml index 11fc0a7a6a..1b2ff05c21 100644 --- a/app/views/admin/order_cycles/_name_and_timing_form.html.haml +++ b/app/views/admin/order_cycles/_name_and_timing_form.html.haml @@ -31,7 +31,7 @@ - if subscriptions_enabled? .row .two.columns.alpha - = f.label :schedule_ids, t('admin.order_cycles.schedules') + = f.label :schedule_ids, t('admin.order_cycles.index.schedules') .twelve.columns - if viewing_as_coordinator_of?(@order_cycle) %input.fullwidth.ofn-select2#schedule_ids{ name: 'order_cycle[schedule_ids]', diff --git a/config/locales/en.yml b/config/locales/en.yml index 968fd0a06b..dd5c131a7d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -806,7 +806,7 @@ en: name: Name orders_open: Orders open at coordinator: Coordinator - order_closes: Orders close + orders_close: Orders close row: suppliers: suppliers distributors: distributors From d821664ee3dffab88e5c9c9a7bc23223eee9d3d3 Mon Sep 17 00:00:00 2001 From: luisramos0 Date: Wed, 23 May 2018 17:32:38 +0100 Subject: [PATCH 095/206] Fixed issue #1913 with expand/collapse of list of producers of a shop --- app/views/shops/_fat.html.haml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/views/shops/_fat.html.haml b/app/views/shops/_fat.html.haml index beb882c4d4..66752acefd 100644 --- a/app/views/shops/_fat.html.haml +++ b/app/views/shops/_fat.html.haml @@ -33,17 +33,18 @@ %enterprise-modal %i.ofn-i_036-producers %span{"ng-bind" => "::enterprise.name"} + %li{"ng-repeat" => "enterprise in hub.producers.slice(7,hub.producers.length)", "class" => "additional-producer"} + %enterprise-modal + %i.ofn-i_036-producers + %span{"ng-bind" => "::enterprise.name"} %li{"data-is-link" => "true", "class" => "more-producers-link", "ng-show" => "::hub.producers.length>7"} - %a{"ng-click" => "toggleMoreProducers=!toggleMoreProducers"} + %a{"ng-click" => "toggleMoreProducers=!toggleMoreProducers; $event.stopPropagation()"} .more + %span{"ng-bind" => "::hub.producers.length-7"} = t :label_more .less = t :label_less - %li{"ng-repeat" => "enterprise in hub.producers.slice(7,hub.producers.length)", "class" => "additional-producer"} - %enterprise-modal - %i.ofn-i_036-producers - %span{"ng-bind" => "::enterprise.name"} + %div.show-for-medium-up{"ng-if" => "::hub.producers.length==0"}   From 17259b326920851a666c788f5447686e5f9d5b34 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Fri, 25 May 2018 17:28:54 +1000 Subject: [PATCH 096/206] Overwrite cached value for current_order_cycle when updating it This ensures that the correct order cycle is rendered in the json response --- app/controllers/shop_controller.rb | 1 + spec/controllers/shop_controller_spec.rb | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/controllers/shop_controller.rb b/app/controllers/shop_controller.rb index a32d5d0b85..8cac75eca2 100644 --- a/app/controllers/shop_controller.rb +++ b/app/controllers/shop_controller.rb @@ -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: "" diff --git a/spec/controllers/shop_controller_spec.rb b/spec/controllers/shop_controller_spec.rb index 52c0bd4174..12b63d2ff8 100644 --- a/spec/controllers/shop_controller_spec.rb +++ b/spec/controllers/shop_controller_spec.rb @@ -56,6 +56,20 @@ describe ShopController, type: :controller do spree_get :order_cycle response.body.should have_content oc1.id end + + context "when the order cycle has already been set" do + let(:oc1) { create(:simple_order_cycle, distributors: [distributor]) } + let(:oc2) { create(:simple_order_cycle, distributors: [distributor]) } + let(:order) { create(:order, order_cycle: oc1) } + + before { allow(controller).to receive(:current_order) { order } } + + it "returns the new order cycle details" do + spree_post :order_cycle, order_cycle_id: oc2.id + expect(response).to be_success + expect(response.body).to have_content oc2.id + end + end end it "should not allow the user to select an invalid order cycle" do @@ -143,12 +157,12 @@ describe ShopController, type: :controller do let!(:tag_rule) { create(:filter_products_tag_rule, enterprise: distributor, preferred_customer_tags: "member", - preferred_variant_tags: "members-only") + preferred_variant_tags: "members-only") } let!(:default_tag_rule) { create(:filter_products_tag_rule, enterprise: distributor, is_default: true, - preferred_variant_tags: "members-only") + preferred_variant_tags: "members-only") } let(:product1) { { "id" => 1, "name" => 'product 1', "variants" => [{ "id" => 4, "tag_list" => ["members-only"] }] } } let(:product2) { { "id" => 2, "name" => 'product 2', "variants" => [{ "id" => 5, "tag_list" => ["members-only"] }, {"id" => 9, "tag_list" => ["something"]}] } } From 533ae772344bb8e7eceb3b105e1c3412d76d3321 Mon Sep 17 00:00:00 2001 From: luisramos0 Date: Fri, 25 May 2018 23:46:44 +0100 Subject: [PATCH 097/206] Fixed missing translations on shops filter - delivery option --- .../darkswarm/directives/shipping_type_selector.js.coffee | 2 ++ .../javascripts/templates/shipping_type_selector.html.haml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/darkswarm/directives/shipping_type_selector.js.coffee b/app/assets/javascripts/darkswarm/directives/shipping_type_selector.js.coffee index 15fda75820..1f0196f9fe 100644 --- a/app/assets/javascripts/darkswarm/directives/shipping_type_selector.js.coffee +++ b/app/assets/javascripts/darkswarm/directives/shipping_type_selector.js.coffee @@ -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 = diff --git a/app/assets/javascripts/templates/shipping_type_selector.html.haml b/app/assets/javascripts/templates/shipping_type_selector.html.haml index 7774ab62c0..45f35911d4 100644 --- a/app/assets/javascripts/templates/shipping_type_selector.html.haml +++ b/app/assets/javascripts/templates/shipping_type_selector.html.haml @@ -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 }} \ No newline at end of file From fc2cc09ea5e6c123243dac27b0f5dff601ac518b Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Sat, 26 May 2018 17:51:11 +0100 Subject: [PATCH 098/206] checkout redirect excludes angular path variables --- app/assets/javascripts/darkswarm/services/checkout.js.coffee | 2 +- .../javascripts/darkswarm/services/navigation.js.coffee | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/darkswarm/services/checkout.js.coffee b/app/assets/javascripts/darkswarm/services/checkout.js.coffee index 204c35ac66..67306a7faf 100644 --- a/app/assets/javascripts/darkswarm/services/checkout.js.coffee +++ b/app/assets/javascripts/darkswarm/services/checkout.js.coffee @@ -13,7 +13,7 @@ Darkswarm.factory 'Checkout', ($injector, CurrentOrder, ShippingMethods, StripeE submit: => Loading.message = t 'submitting_order' $http.put('/checkout.json', {order: @preprocess()}).success (data, status)=> - Navigation.go data.path + Navigation.goWithoutHashFragments data.path .error (response, status)=> if response.path Navigation.go response.path diff --git a/app/assets/javascripts/darkswarm/services/navigation.js.coffee b/app/assets/javascripts/darkswarm/services/navigation.js.coffee index f511e29e0c..f445d20420 100644 --- a/app/assets/javascripts/darkswarm/services/navigation.js.coffee +++ b/app/assets/javascripts/darkswarm/services/navigation.js.coffee @@ -16,6 +16,10 @@ Darkswarm.factory 'Navigation', ($location, $window) -> else @navigate(path) + goWithoutHashFragments: (path) -> + # Redirects to specified path, without Angular hash fragments such as '#/login' + $window.location.href = $window.location.origin + path + go: (path)-> if path.match /^http/ $window.location.href = path From e362c2d8679219ad498e57369d1fd773c95b59ce Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Thu, 16 Mar 2017 13:58:19 +0000 Subject: [PATCH 099/206] PI timestamps --- .../admin/bulk_product_update.js.coffee | 4 +++- .../spree/admin/products_controller_decorator.rb | 16 ++++++++++++++++ app/models/spree/product_decorator.rb | 9 +++++++++ app/models/spree/variant_decorator.rb | 5 +---- app/serializers/api/admin/product_serializer.rb | 2 +- app/serializers/api/admin/variant_serializer.rb | 2 +- .../admin/products/bulk_edit/_filters.html.haml | 11 ++++++++--- .../admin/products/bulk_edit/_products.html.haml | 2 +- .../products/bulk_edit/_products_head.html.haml | 2 ++ .../bulk_edit/_products_product.html.haml | 2 ++ .../bulk_edit/_products_variant.html.haml | 2 ++ config/locales/en.yml | 1 + config/locales/en_GB.yml | 9 +++++++++ ...10231746_add_import_date_to_spree_variants.rb | 5 +++++ ...32401_add_import_date_to_variant_overrides.rb | 5 +++++ db/schema.rb | 2 ++ .../column_preference_defaults.rb | 3 ++- 17 files changed, 70 insertions(+), 12 deletions(-) create mode 100644 db/migrate/20170310231746_add_import_date_to_spree_variants.rb create mode 100644 db/migrate/20170314132401_add_import_date_to_variant_overrides.rb diff --git a/app/assets/javascripts/admin/bulk_product_update.js.coffee b/app/assets/javascripts/admin/bulk_product_update.js.coffee index 3e5f388a5f..d7fe743904 100644 --- a/app/assets/javascripts/admin/bulk_product_update.js.coffee +++ b/app/assets/javascripts/admin/bulk_product_update.js.coffee @@ -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 = "" $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 = -> @@ -91,6 +92,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) diff --git a/app/controllers/spree/admin/products_controller_decorator.rb b/app/controllers/spree/admin/products_controller_decorator.rb index 59c4cd3069..6061fe02b5 100644 --- a/app/controllers/spree/admin/products_controller_decorator.rb +++ b/app/controllers/spree/admin/products_controller_decorator.rb @@ -4,6 +4,7 @@ require 'open_food_network/referer_parser' Spree::Admin::ProductsController.class_eval do include OpenFoodNetwork::SpreeApiKeyLoader include OrderCyclesHelper + include EnterprisesHelper before_filter :load_form_data, :only => [:bulk_edit, :new, :create, :edit, :update] before_filter :load_spree_api_key, :only => [:bulk_edit, :variant_overrides] before_filter :strip_new_properties, only: [:create, :update] @@ -95,6 +96,21 @@ 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 = [{id: '0', name: ''}] + product_import_dates.map {|i| import_dates.push({id: i, name: i.to_formatted_s(:long)}) } + @import_dates = import_dates.to_json + end + + def product_import_dates + import_dates = Spree::Variant. + select('spree_variants.import_date'). + joins(:product). + where('spree_products.supplier_id IN (?) + AND spree_variants.is_master = false + AND spree_variants.import_date IS NOT NULL + AND spree_variants.deleted_at IS NULL', editable_enterprises.collect(&:id)) + + import_dates.uniq.collect(&:import_date).sort.reverse end def strip_new_properties diff --git a/app/models/spree/product_decorator.rb b/app/models/spree/product_decorator.rb index 5f2eaab5f6..be6f8b4a1b 100644 --- a/app/models/spree/product_decorator.rb +++ b/app/models/spree/product_decorator.rb @@ -174,6 +174,15 @@ Spree::Product.class_eval do order_cycle.variants_distributed_by(distributor).where(product_id: self) end + def import_date + # Get the most recent import_date of a product's variants + imports = [] + variants.each do |v| + imports.append(v) unless v.import_date.blank? + end + imports.sort_by(&:import_date).last.try(:import_date) + end + # Build a product distribution for each distributor def build_product_distributions_for_user user Enterprise.is_distributor.managed_by(user).each do |distributor| diff --git a/app/models/spree/variant_decorator.rb b/app/models/spree/variant_decorator.rb index 592c93b437..c0a90500cf 100644 --- a/app/models/spree/variant_decorator.rb +++ b/app/models/spree/variant_decorator.rb @@ -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,7 +29,6 @@ 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) @@ -117,7 +115,6 @@ Spree::Variant.class_eval do end end - private def update_weight_from_unit_value diff --git a/app/serializers/api/admin/product_serializer.rb b/app/serializers/api/admin/product_serializer.rb index dc88a03448..8af5701b94 100644 --- a/app/serializers/api/admin/product_serializer.rb +++ b/app/serializers/api/admin/product_serializer.rb @@ -1,7 +1,7 @@ class Api::Admin::ProductSerializer < ActiveModel::Serializer attributes :id, :name, :sku, :variant_unit, :variant_unit_scale, :variant_unit_name, :on_demand, :inherits_properties - attributes :on_hand, :price, :available_on, :permalink_live, :tax_category_id, :image_url, :thumb_url + attributes :on_hand, :price, :available_on, :permalink_live, :tax_category_id, :import_date, :image_url, :thumb_url has_one :supplier, key: :producer_id, embed: :id has_one :primary_taxon, key: :category_id, embed: :id diff --git a/app/serializers/api/admin/variant_serializer.rb b/app/serializers/api/admin/variant_serializer.rb index f27528e1c7..3fc22b98d4 100644 --- a/app/serializers/api/admin/variant_serializer.rb +++ b/app/serializers/api/admin/variant_serializer.rb @@ -1,6 +1,6 @@ class Api::Admin::VariantSerializer < ActiveModel::Serializer attributes :id, :options_text, :unit_value, :unit_description, :unit_to_display, :on_demand, :display_as, :display_name, :name_to_display, :sku - attributes :on_hand, :price + attributes :on_hand, :price, :import_date has_many :variant_overrides def on_hand diff --git a/app/views/spree/admin/products/bulk_edit/_filters.html.haml b/app/views/spree/admin/products/bulk_edit/_filters.html.haml index a0130615bb..3bbed52e05 100644 --- a/app/views/spree/admin/products/bulk_edit/_filters.html.haml +++ b/app/views/spree/admin/products/bulk_edit/_filters.html.haml @@ -1,16 +1,21 @@ .filters.sixteen.columns.alpha.omega - .quick_search.four.columns.alpha + .quick_search.three.columns.alpha %label{ :for => 'quick_filter' } %br %input.quick-search.fullwidth{ 'ng-model' => 'query', :name => "quick_filter", :type => 'text', 'placeholder' => t('admin.quick_search') } - .filter_select.four.columns + .filter_select.three.columns %label{ :for => 'producer_filter' }= t 'producer' %br %select.fullwidth{ :id => 'producer_filter', 'ofn-select2-min-search' => 5, 'ng-model' => 'producerFilter', 'ng-options' => 'producer.id as producer.name for producer in filterProducers' } - .filter_select.four.columns + .filter_select.three.columns %label{ :for => 'category_filter' }= t 'category' %br %select.fullwidth{ :id => 'category_filter', 'ofn-select2-min-search' => 5, 'ng-model' => 'categoryFilter', 'ng-options' => 'taxon.id as taxon.name for taxon in filterTaxons'} + .filter_select.three.columns + %label{ :for => 'import_filter' } Import Date + %br + %select.fullwidth{ :id => 'import_date_filter', 'ofn-select2-min-search' => 5, 'ng-model' => 'importDateFilter', 'ng-init' => "import_dates = #{@import_dates}", 'ng-options' => 'import.id as import.name for import in import_dates'} + %div{ :class => "one column" }   .filter_clear.three.columns.omega %label{ :for => 'clear_all_filters' } diff --git a/app/views/spree/admin/products/bulk_edit/_products.html.haml b/app/views/spree/admin/products/bulk_edit/_products.html.haml index 04dee1c2df..4072fd0220 100644 --- a/app/views/spree/admin/products/bulk_edit/_products.html.haml +++ b/app/views/spree/admin/products/bulk_edit/_products.html.haml @@ -8,7 +8,7 @@ = render 'spree/admin/products/bulk_edit/products_head' - %tbody{ 'ng-repeat' => 'product in filteredProducts = ( products | filter:query | producer: producerFilter | category: categoryFilter | limitTo:limit )', 'ng-class-even' => "'even'", 'ng-class-odd' => "'odd'" } + %tbody{ 'ng-repeat' => 'product in filteredProducts = ( products | filter:query | producer: producerFilter | category: categoryFilter | filter: (importDateFilter != 0) && {import_date: importDateFilter} | limitTo:limit )', 'ng-class-even' => "'even'", 'ng-class-odd' => "'odd'" } = render 'spree/admin/products/bulk_edit/products_product' = render 'spree/admin/products/bulk_edit/products_variant' diff --git a/app/views/spree/admin/products/bulk_edit/_products_head.html.haml b/app/views/spree/admin/products/bulk_edit/_products_head.html.haml index 1ed74174f1..e5aa68f38d 100644 --- a/app/views/spree/admin/products/bulk_edit/_products_head.html.haml +++ b/app/views/spree/admin/products/bulk_edit/_products_head.html.haml @@ -13,6 +13,7 @@ %col.tax_category{ ng: { show: 'columns.tax_category.visible' } } %col.inherits_properties{ ng: { show: 'columns.inherits_properties.visible' } } %col.available_on{ ng: { show: 'columns.available_on.visible' } } + %col.import_date{ ng: { show: 'columns.import_date.visible' } } %col.actions %col.actions %col.actions @@ -35,6 +36,7 @@ %th.tax_category{ 'ng-show' => 'columns.tax_category.visible' }=t('.tax_category') %th.inherits_properties{ 'ng-show' => 'columns.inherits_properties.visible' }=t('.inherits_properties?') %th.available_on{ 'ng-show' => 'columns.available_on.visible' }=t('.av_on') + %th.import_date{ 'ng-show' => 'columns.import_date.visible' }=t('.import_date') %th.actions %th.actions %th.actions diff --git a/app/views/spree/admin/products/bulk_edit/_products_product.html.haml b/app/views/spree/admin/products/bulk_edit/_products_product.html.haml index e947f2eba2..d365212568 100644 --- a/app/views/spree/admin/products/bulk_edit/_products_product.html.haml +++ b/app/views/spree/admin/products/bulk_edit/_products_product.html.haml @@ -34,6 +34,8 @@ %input{ 'ng-model' => 'product.inherits_properties', :name => 'inherits_properties', 'ofn-track-product' => 'inherits_properties', type: "checkbox" } %td.available_on{ 'ng-show' => 'columns.available_on.visible' } %input{ 'ng-model' => 'product.available_on', :name => 'available_on', 'ofn-track-product' => 'available_on', 'datetimepicker' => 'product.available_on', type: "text" } + %td.import_date{ 'ng-show' => 'columns.import_date.visible' } + %span {{(product.import_date | date:"MMMM dd, yyyy HH:mm") || ""}} %td.actions %a{ 'ng-click' => 'editWarn(product)', :class => "edit-product icon-edit no-text", 'ofn-with-tip' => t(:edit) } %td.actions diff --git a/app/views/spree/admin/products/bulk_edit/_products_variant.html.haml b/app/views/spree/admin/products/bulk_edit/_products_variant.html.haml index eb87039ff0..4cfcf7f8ae 100644 --- a/app/views/spree/admin/products/bulk_edit/_products_variant.html.haml +++ b/app/views/spree/admin/products/bulk_edit/_products_variant.html.haml @@ -23,6 +23,8 @@ %td{ 'ng-show' => 'columns.tax_category.visible' } %td{ 'ng-show' => 'columns.inherits_properties.visible' } %td{ 'ng-show' => 'columns.available_on.visible' } + %td{ 'ng-show' => 'columns.import_date.visible' } + %span {{variant.import_date | date:"MMMM dd, yyyy HH:mm"}} %td.actions %a{ 'ng-click' => 'editWarn(product,variant)', :class => "edit-variant icon-edit no-text", 'ng-show' => "variantSaved(variant)", 'ofn-with-tip' => t(:edit) } %td.actions diff --git a/config/locales/en.yml b/config/locales/en.yml index 285d710f1f..f8fd877c86 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -459,6 +459,7 @@ en: inherits_properties?: Inherits Properties? available_on: Available On av_on: "Av. On" + import_date: Imported upload_an_image: Upload an image product_search_keywords: Product Search Keywords product_search_tip: Type words to help search your products in the shops. Use space to separate each keyword. diff --git a/config/locales/en_GB.yml b/config/locales/en_GB.yml index e684fd9b03..6d073525c8 100644 --- a/config/locales/en_GB.yml +++ b/config/locales/en_GB.yml @@ -264,6 +264,15 @@ en_GB: manages: manages products: unit_name_placeholder: 'eg. bunches' + bulk_edit: + unit: Unit + display_as: Display As + category: Category + tax_category: Tax Category + inherits_properties?: Inherits Properties? + available_on: Available On + av_on: "Av. On" + import_date: Imported Search: Search properties: property_name: Property Name diff --git a/db/migrate/20170310231746_add_import_date_to_spree_variants.rb b/db/migrate/20170310231746_add_import_date_to_spree_variants.rb new file mode 100644 index 0000000000..d5acbf882e --- /dev/null +++ b/db/migrate/20170310231746_add_import_date_to_spree_variants.rb @@ -0,0 +1,5 @@ +class AddImportDateToSpreeVariants < ActiveRecord::Migration + def change + add_column :spree_variants, :import_date, :datetime + end +end diff --git a/db/migrate/20170314132401_add_import_date_to_variant_overrides.rb b/db/migrate/20170314132401_add_import_date_to_variant_overrides.rb new file mode 100644 index 0000000000..daef32ec61 --- /dev/null +++ b/db/migrate/20170314132401_add_import_date_to_variant_overrides.rb @@ -0,0 +1,5 @@ +class AddImportDateToVariantOverrides < ActiveRecord::Migration + def change + add_column :variant_overrides, :import_date, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index cdad7c2807..5b1433fc00 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1058,6 +1058,7 @@ ActiveRecord::Schema.define(:version => 20180316034336) do t.string "unit_description", :default => "" t.string "display_name" t.string "display_as" + t.datetime "import_date" end add_index "spree_variants", ["product_id"], :name => "index_variants_on_product_id" @@ -1176,6 +1177,7 @@ ActiveRecord::Schema.define(:version => 20180316034336) do t.string "sku" t.boolean "on_demand" t.datetime "permission_revoked_at" + t.datetime "import_date" end add_index "variant_overrides", ["variant_id", "hub_id"], :name => "index_variant_overrides_on_variant_id_and_hub_id" diff --git a/lib/open_food_network/column_preference_defaults.rb b/lib/open_food_network/column_preference_defaults.rb index ee28e4b2a5..409d9fe7bb 100644 --- a/lib/open_food_network/column_preference_defaults.rb +++ b/lib/open_food_network/column_preference_defaults.rb @@ -68,7 +68,8 @@ module OpenFoodNetwork category: { name: I18n.t("#{node}.category"), visible: false }, tax_category: { name: I18n.t("#{node}.tax_category"), visible: false }, inherits_properties: { name: I18n.t("#{node}.inherits_properties?"), visible: false }, - available_on: { name: I18n.t("#{node}.available_on"), visible: false } + available_on: { name: I18n.t("#{node}.available_on"), visible: false }, + import_date: { name: I18n.t("#{node}.import_date"), visible: false } } end From a2a65a8900265a729c9047f0cc6c17a7c3b15522 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Fri, 17 Mar 2017 21:28:06 +0000 Subject: [PATCH 100/206] PI inventories and overhaul --- .../directives/select2_no_search.js.coffee | 6 + .../controllers/import_feedback.js.coffee | 11 + .../filters/filter_entries.js.coffee | 36 ++ .../stylesheets/admin/product_import.css.scss | 24 + .../admin/product_import_controller.rb | 6 +- app/models/product_importer.rb | 422 ++++++++++++------ app/models/spreadsheet_entry.rb | 34 +- app/models/variant_override.rb | 2 + .../product_import/_entries_table.html.haml | 29 +- .../product_import/_errors_list.html.haml | 20 +- .../product_import/_import_options.html.haml | 25 +- .../product_import/_import_review.html.haml | 172 ++++--- .../_inventory_options_form.html.haml | 16 + .../_product_options_form.html.haml | 44 ++ .../product_import/_upload_form.html.haml | 24 +- .../admin/product_import/import.html.haml | 18 +- app/views/admin/product_import/save.html.haml | 37 +- spec/features/admin/product_import_spec.rb | 282 ++++++++++-- 18 files changed, 904 insertions(+), 304 deletions(-) create mode 100644 app/assets/javascripts/admin/directives/select2_no_search.js.coffee create mode 100644 app/assets/javascripts/admin/product_import/controllers/import_feedback.js.coffee create mode 100644 app/assets/javascripts/admin/product_import/filters/filter_entries.js.coffee create mode 100644 app/views/admin/product_import/_inventory_options_form.html.haml create mode 100644 app/views/admin/product_import/_product_options_form.html.haml diff --git a/app/assets/javascripts/admin/directives/select2_no_search.js.coffee b/app/assets/javascripts/admin/directives/select2_no_search.js.coffee new file mode 100644 index 0000000000..64ded4fcc8 --- /dev/null +++ b/app/assets/javascripts/admin/directives/select2_no_search.js.coffee @@ -0,0 +1,6 @@ +angular.module("ofn.admin").directive "select2NoSearch", ($timeout) -> + restrict: 'CA' + link: (scope, element, attrs) -> + $timeout -> + element.select2 + minimumResultsForSearch: Infinity \ No newline at end of file diff --git a/app/assets/javascripts/admin/product_import/controllers/import_feedback.js.coffee b/app/assets/javascripts/admin/product_import/controllers/import_feedback.js.coffee new file mode 100644 index 0000000000..3c9891dec0 --- /dev/null +++ b/app/assets/javascripts/admin/product_import/controllers/import_feedback.js.coffee @@ -0,0 +1,11 @@ +angular.module("ofn.admin").controller "ImportFeedbackCtrl", ($scope, productImportData) -> + $scope.entries = productImportData + + $scope.count = (items) -> + total = 0 + angular.forEach items, (item) -> + total++ + total + + $scope.attribute_invalid = (attribute, line_number) -> + $scope.entries[line_number]['errors'][attribute] != undefined \ No newline at end of file diff --git a/app/assets/javascripts/admin/product_import/filters/filter_entries.js.coffee b/app/assets/javascripts/admin/product_import/filters/filter_entries.js.coffee new file mode 100644 index 0000000000..7855b07058 --- /dev/null +++ b/app/assets/javascripts/admin/product_import/filters/filter_entries.js.coffee @@ -0,0 +1,36 @@ +angular.module("ofn.admin").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 != '') + filtered[line_number] = entry + if type == 'invalid' and (validates_as == '') + filtered[line_number] = entry + if type == 'create_product' and (validates_as == 'new_product' or validates_as == 'new_variant') + filtered[line_number] = entry + if type == 'update_product' and validates_as == 'existing_variant' + filtered[line_number] = entry + if type == 'create_inventory' and validates_as == 'new_inventory_item' + filtered[line_number] = entry + if type == 'update_inventory' and validates_as == 'existing_inventory_item' + filtered[line_number] = entry + + filtered + +angular.module("ofn.admin").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 diff --git a/app/assets/stylesheets/admin/product_import.css.scss b/app/assets/stylesheets/admin/product_import.css.scss index 907e8b1a5c..ae30cac119 100644 --- a/app/assets/stylesheets/admin/product_import.css.scss +++ b/app/assets/stylesheets/admin/product_import.css.scss @@ -166,6 +166,20 @@ table.import-settings { //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; + } } @@ -222,4 +236,14 @@ table.import-settings { font-size: 1.05em; margin-top: 0.4em; } +} + +form.product-import, div.post-save-results { + input[type="submit"] { + margin-right: 0.5em; + } + input[type="submit"], button, a.button { + min-width: 8em; + text-align: center; + } } \ No newline at end of file diff --git a/app/controllers/admin/product_import_controller.rb b/app/controllers/admin/product_import_controller.rb index 83d476bc6c..b808875aff 100644 --- a/app/controllers/admin/product_import_controller.rb +++ b/app/controllers/admin/product_import_controller.rb @@ -7,7 +7,8 @@ class Admin::ProductImportController < Spree::Admin::BaseController def import # Save uploaded file to tmp directory @filepath = save_uploaded_file(params[:file]) - @importer = ProductImporter.new(File.new(@filepath), editable_enterprises) + @importer = ProductImporter.new(File.new(@filepath), spree_current_user, params[:settings]) + @import_into = params[:settings][:import_into] check_file_errors @importer check_spreadsheet_has_data @importer @@ -17,8 +18,9 @@ class Admin::ProductImportController < Spree::Admin::BaseController end def save - @importer = ProductImporter.new(File.new(params[:filepath]), editable_enterprises, params[:settings]) + @importer = ProductImporter.new(File.new(params[:filepath]), spree_current_user, params[:settings]) @importer.save_all if @importer.has_valid_entries? + @import_into = params[:settings][:import_into] end private diff --git a/app/models/product_importer.rb b/app/models/product_importer.rb index 13fc0152da..712705d1ac 100644 --- a/app/models/product_importer.rb +++ b/app/models/product_importer.rb @@ -7,7 +7,7 @@ class ProductImporter attr_reader :total_supplier_products - def initialize(file, editable_enterprises, import_settings={}) + def initialize(file, current_user, import_settings={}) if file.is_a?(File) @file = file @sheet = open_spreadsheet @@ -22,13 +22,18 @@ class ProductImporter @products_created = 0 @variants_created = 0 @variants_updated = 0 + @inventory_created = 0 + @inventory_updated = 0 + @import_time = DateTime.now @import_settings = import_settings + + @current_user = current_user @editable_enterprises = {} - editable_enterprises.map { |e| @editable_enterprises[e.name] = e.id } + @inventory_permissions = {} @total_supplier_products = 0 - @products_to_reset = {} + @reset_counts = {} @updated_ids = [] init_product_importer if @sheet @@ -37,44 +42,45 @@ class ProductImporter 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 persisted? false #ActiveModel, not ActiveRecord end + def has_entries? + @entries.count > 0 + end + def has_valid_entries? - valid_count and valid_count > 0 + @entries.each do |entry| + return true unless entry.validates_as.blank? + end + false end def item_count @sheet ? @sheet.last_row - 1 : 0 end - def products_to_reset + def reset_counts # Return indexed data about existing product count, reset count, and updates count per supplier - @products_to_reset.each do |supplier_id, values| + @reset_counts.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] + @reset_counts[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 + @reset_counts end def suppliers_index @@ -83,19 +89,22 @@ class ProductImporter end def all_entries - invalid_entries.merge(products_to_create).merge(products_to_update).sort.to_h + @entries end - def invalid_entries - @invalid_entries + 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 products_to_create - @products_to_create.merge(@variants_to_create) - end - - def products_to_update - @variants_to_update + def table_headings + @entries.first.displayable_attributes.keys.map(&:humanize) if @entries.first end def products_created_count @@ -106,12 +115,20 @@ class ProductImporter @variants_updated end + def inventory_created_count + @inventory_created + end + + def inventory_updated_count + @inventory_updated + end + def products_reset_count @products_reset_count || 0 end def total_saved_count - @products_created + @variants_created + @variants_updated + @products_created + @variants_created + @variants_updated + @inventory_created + @inventory_updated end def save_all @@ -127,12 +144,18 @@ class ProductImporter @editable_enterprises.has_value?(Integer(supplier_id)) end + def inventory_permission?(supplier_id, producer_id) + @current_user.admin? or ( @inventory_permissions[supplier_id] and @inventory_permissions[supplier_id].include? producer_id ) + end + private def init_product_importer + init_permissions build_entries build_categories_index build_suppliers_index + build_producers_index if importing_into_inventory? validate_all end @@ -174,38 +197,103 @@ class ProductImporter 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) + if importing_into_inventory? + producer_validation(entry) + inventory_validation(entry) + else + category_validation(entry) + product_validation(entry) + end end - count_existing_products - delete_uploaded_file if item_count.zero? or valid_count.zero? + count_existing_items + delete_uploaded_file if item_count.zero? or !has_valid_entries? end - def count_existing_products + def importing_into_inventory? + @import_settings['import_into'] == 'inventories' + end + + def inventory_validation(entry) + # Find product with matching supplier and name + 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: 'did not match any products in the database') + return + end + + match.variants.each do |existing_variant| + if existing_variant.display_name == entry.display_name and existing_variant.unit_value == Float(entry.unit_value) + variant_override = create_inventory_item(entry, existing_variant) + validate_inventory_item(entry, variant_override) + return + end + end + + mark_as_invalid(entry, attribute: 'product', error: 'not found in database') + end + + def create_inventory_item(entry, existing_variant) + existing_variant_override = VariantOverride.where(variant_id: existing_variant.id, hub_id: entry.supplier_id).first + + if existing_variant_override + variant_override = existing_variant_override + else + variant_override = VariantOverride.new(variant_id: existing_variant.id, hub_id: entry.supplier_id) + end + + 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 validate_inventory_item(entry, variant_override) + if variant_override.valid? and !entry.has_errors? + mark_as_inventory_item(entry, variant_override) + else + mark_as_invalid(entry, product_validations: variant_override.errors) + end + end + + def mark_as_inventory_item(entry, variant_override) + if variant_override.id? + entry.is_a_valid('existing_inventory_item') + entry.product_object = variant_override + updates_count_per_supplier(entry.supplier_id) unless entry.has_errors? + else + entry.is_a_valid('new_inventory_item') + entry.product_object = variant_override + end + end + + def count_existing_items @suppliers_index.each do |supplier_name, supplier_id| - if supplier_id and permission_by_id?(supplier_id) + next unless supplier_id and permission_by_id?(supplier_id) + + if importing_into_inventory? + products_count = VariantOverride. + where('variant_overrides.hub_id IN (?)', supplier_id). + count + else 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] + if @reset_counts[supplier_id] + @reset_counts[supplier_id][:existing_products] = products_count + else + @reset_counts[supplier_id] = {existing_products: products_count} + end + + @total_supplier_products += products_count + end end def supplier_validation(entry) @@ -229,10 +317,35 @@ class ProductImporter entry.supplier_id = @suppliers_index[supplier_name] end + def producer_validation(entry) + producer_name = entry.producer + + if producer_name.blank? + mark_as_invalid(entry, attribute: "producer", error: "can't be blank") + return + end + + unless producer_exists?(producer_name) + mark_as_invalid(entry, attribute: "producer", error: "\"#{producer_name}\" not found in database") + return + end + + unless inventory_permission?(entry.supplier_id, @producers_index[producer_name]) + mark_as_invalid(entry, attribute: "producer", error: "\"#{producer_name}\": you do not have permission to create inventory for this producer") + return + end + + entry.producer_id = @producers_index[producer_name] + end + def supplier_exists?(supplier_name) @suppliers_index[supplier_name] end + def producer_exists?(producer_name) + @producers_index[producer_name] + end + def category_validation(entry) category_name = entry.category @@ -252,15 +365,9 @@ class ProductImporter @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 @@ -276,6 +383,17 @@ class ProductImporter @suppliers_index end + def build_producers_index + @producers_index = {} + @entries.each do |entry| + 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 build_categories_index @categories_index = {} @entries.each do |entry| @@ -288,70 +406,114 @@ class ProductImporter 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 + @entries.each do |entry| + if importing_into_inventory? + save_new_inventory_item entry if entry.is_a_valid? 'new_inventory_item' + save_existing_inventory_item entry if entry.is_a_valid? 'existing_inventory_item' 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) + save_new_product entry if entry.is_a_valid? 'new_product' + save_new_variant entry if entry.is_a_valid? 'new_variant' + save_existing_variant entry if entry.is_a_valid? 'existing_variant' end end self.errors.add(:importer, I18n.t(:product_importer_products_save_error)) if total_saved_count.zero? - reset_absent_products + reset_absent_items total_saved_count end - def reset_absent_products - return if total_saved_count.zero? + 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] and @already_created[entry.supplier_id][entry.name] + product_id = @already_created[entry.supplier_id][entry.name] + 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 + self.errors.add("Line #{line_number}:", product.errors.full_messages) + end + + @already_created[entry.supplier_id] = {entry.name => product.id} + 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? and new_item.save + @inventory_created += 1 + @updated_ids.push new_item.id + else + self.errors.add("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? and existing_item.save + @inventory_updated += 1 + @updated_ids.push existing_item.id + else + self.errors.add("Line #{line_number}:", existing_item.errors.full_messages) + end + end + + def save_new_variant(entry) + new_variant = entry.product_object + assign_defaults(new_variant, entry) + new_variant.import_date = @import_time + 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 + + def save_existing_variant(entry) + variant = entry.product_object + assign_defaults(variant, entry) + variant.import_date = @import_time + 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) + end + end + + def reset_absent_items + return if total_saved_count.zero? or @updated_ids.empty? 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 + return if enterprises_to_reset.empty? + + # For selected enterprises; set stock to zero for all products/inventory + # items that were not present in the uploaded spreadsheet + if importing_into_inventory? + @products_reset_count = VariantOverride. + where('variant_overrides.hub_id IN (?) + AND variant_overrides.id NOT IN (?)', enterprises_to_reset, @updated_ids). + update_all(count_on_hand: 0) + else @products_reset_count = Spree::Variant.joins(:product). where('spree_products.supplier_id IN (?) AND spree_variants.id NOT IN (?) @@ -362,12 +524,16 @@ class ProductImporter end def assign_defaults(object, entry) - @import_settings[entry['supplier_id'].to_s]['defaults'].each do |attribute, setting| + return unless @import_settings[entry.supplier_id.to_s] and @import_settings[entry.supplier_id.to_s]['defaults'] + + @import_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? or (attribute == 'on_hand' and entry['on_hand_nil']) + if object.send(attribute).blank? or ((attribute == 'on_hand' or attribute == 'count_on_hand') and entry.on_hand_nil) object.assign_attributes(attribute => setting['value']) end end @@ -375,16 +541,15 @@ class ProductImporter 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 + # Ensure attributes are copied to 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 set_update_status(entry) + 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 @@ -396,7 +561,7 @@ class ProductImporter # 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) + if existing_variant.display_name == entry.display_name and existing_variant.unit_value == Float(entry.unit_value) mark_as_existing_variant(entry, existing_variant) return end @@ -410,7 +575,7 @@ class ProductImporter 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) + entry.is_a_valid 'new_product' unless entry.has_errors? else mark_as_invalid(entry, product_validations: new_product.errors) end @@ -421,8 +586,8 @@ class ProductImporter 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) + entry.is_a_valid 'existing_variant' unless entry.has_errors? + updates_count_per_supplier(entry.supplier_id) unless entry.has_errors? else mark_as_invalid(entry, product_validations: existing_variant.errors) end @@ -434,23 +599,24 @@ class ProductImporter 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) + entry.is_a_valid 'new_variant' unless entry.has_errors? 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 + if @reset_counts[supplier_id] and @reset_counts[supplier_id][:updates_count] + @reset_counts[supplier_id][:updates_count] += 1 else - @products_to_reset[supplier_id] = {updates_count: 1} + @reset_counts[supplier_id] = {updates_count: 1} end end - def check_on_hand_nil(entry, variant) + def check_on_hand_nil(entry, object) if entry.on_hand.blank? - variant.on_hand = 0 + 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 diff --git a/app/models/spreadsheet_entry.rb b/app/models/spreadsheet_entry.rb index 1db6d43879..a52d9501ca 100644 --- a/app/models/spreadsheet_entry.rb +++ b/app/models/spreadsheet_entry.rb @@ -4,21 +4,26 @@ class SpreadsheetEntry include ActiveModel::Conversion include ActiveModel::Validations - attr_accessor :line_number, :valid, :product_object, :product_validations, :save_type, :on_hand_nil + attr_reader :validates_as - attr_accessor :id, :product_id, :supplier, :supplier_id, :name, :display_name, :sku, + attr_accessor :line_number, :valid, :product_object, :product_validations, :on_hand_nil, + :has_overrides + + 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, :on_demand, - :tax_category_id, :shipping_category_id, :description + :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) - @product_validations = {} + #@product_validations = {} + @validates_as = '' 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? + # Trying to assign unknown attribute... record this and give feedback or + # just continue to ignore silently? end end end @@ -27,8 +32,18 @@ class SpreadsheetEntry false #ActiveModel end + def is_a_valid?(type) + #@validates_as[type] + @validates_as == type + end + + def is_a_valid(type) + #@validates_as.push type + @validates_as = type + end + def has_errors? - self.errors.count > 0 or @product_validations.count > 0 + self.errors.count > 0 or @product_validations end def attributes @@ -50,7 +65,8 @@ class SpreadsheetEntry def invalid_attributes invalid_attrs = {} - @product_validations.messages.merge(self.errors.messages).each do |attr, message| + 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) @@ -63,6 +79,6 @@ class SpreadsheetEntry end def non_product_attributes - ['line_number', 'valid', 'errors', 'product_object', 'product_validations', 'save_type', 'on_hand_nil'] + ['line_number', 'valid', 'errors', 'product_object', 'product_validations', 'inventory_validations', 'validates_as', 'save_type', 'on_hand_nil', 'has_overrides'] end end diff --git a/app/models/variant_override.rb b/app/models/variant_override.rb index 453a93f7b4..a6a5dfe8ed 100644 --- a/app/models/variant_override.rb +++ b/app/models/variant_override.rb @@ -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' diff --git a/app/views/admin/product_import/_entries_table.html.haml b/app/views/admin/product_import/_entries_table.html.haml index cd293eea05..93d75f3fec 100644 --- a/app/views/admin/product_import/_entries_table.html.haml +++ b/app/views/admin/product_import/_entries_table.html.haml @@ -1,15 +1,14 @@ -- if entries && entries.count > 0 - %div.table-wrap - %table - %thead - %th - %th Line - - entries.values.first.displayable_attributes.each do |key, value| - %th= key - - entries.each do |line_number, entry| - %tr{class: ('error' if entry.has_errors?)} - %td - %i{class: (entry.has_errors? ? 'fa fa-warning warning' : 'fa fa-check-circle success')} - %td= line_number - - entry.displayable_attributes.each do |key, value| - %td{class: ('invalid' if entry.has_errors? and entry.invalid_attributes[key])}= value +%div.table-wrap + %table + %thead + %th + %th Line + - @importer.table_headings.each do |heading| + %th= heading + %tr{ng: {repeat: "(line_number, entry ) in entries | entriesFilterValid:'#{filter}' "}} + %td + %i{ng: {class: "{'fa fa-warning warning': (count(entry.errors) > 0), 'fa fa-check-circle success': (count(entry.errors) == 0)}"}} + %td + {{line_number}} + %td{ng: {repeat: "(attribute, value) in entry.attributes", class: "{'invalid': attribute_invalid(attribute, line_number)}"}} + {{value}} \ No newline at end of file diff --git a/app/views/admin/product_import/_errors_list.html.haml b/app/views/admin/product_import/_errors_list.html.haml index e7203a7ddd..8d23a526f1 100644 --- a/app/views/admin/product_import/_errors_list.html.haml +++ b/app/views/admin/product_import/_errors_list.html.haml @@ -1,11 +1,9 @@ -- @importer.invalid_entries.each do |line_number, entry| - %div.import-errors - %p.line - %strong - Item line #{line_number}: - %span= entry.name - - if entry.display_name - ( #{entry.display_name} ) - - entry.invalid_attributes.each do |attr, error| - %p.error -  -  #{error} \ No newline at end of file +%div.import-errors{ng: {controller: 'ImportFeedbackCtrl', repeat: "(line_number, entry ) in entries | entriesFilterValid:'invalid' "}} + %p.line + %strong + Item line {{line_number}}: + %span {{entry.attributes.name}} + %span{ng: {if: "entry.attributes.display_name"}} + ( {{entry.attributes.display_name}} ) + %p.error{ng: {repeat: "(attribute, error) in entry.errors"}} +  -  {{error}} diff --git a/app/views/admin/product_import/_import_options.html.haml b/app/views/admin/product_import/_import_options.html.haml index f5733c4f80..779ca4ee74 100644 --- a/app/views/admin/product_import/_import_options.html.haml +++ b/app/views/admin/product_import/_import_options.html.haml @@ -9,41 +9,38 @@ %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}"}} %div.header-icon.neutral %i.fa.fa-edit - -#%div.header-count - -# %strong.invalid-count= @importer.invalid_count %div.header-description = name %div.panel-content{ng: {hide: '!active'}} - = render 'options_form', supplier_id: supplier_id, name: name + = render 'product_options_form', supplier_id: supplier_id, name: name if @import_into == 'product_list' + = render 'inventory_options_form', supplier_id: supplier_id, name: name if @import_into == 'inventories' - elsif name and supplier_id %div.panel-section.import-settings{ng: {controller: 'DropdownPanelsCtrl'}} %div.panel-header %div.header-caret - -#%i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}"}} %div.header-icon.error %i.fa.fa-warning - -#%div.header-count - -# %strong.invalid-count= @importer.invalid_count %div.header-description = name %span.header-error= " - you do not have permission to manage this enterprise" - -#%div.panel-content{ng: {hide: '!active'}} - -# = render 'options_form', supplier_id: supplier_id, name: name - elsif name %div.panel-section.import-settings{ng: {controller: 'DropdownPanelsCtrl'}} %div.panel-header %div.header-caret - -#%i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}"}} %div.header-icon.error %i.fa.fa-warning - -#%div.header-count - -# %strong.invalid-count= @importer.invalid_count %div.header-description = name %span.header-error= " - enterprise could not be found in database" - -#%div.panel-content{ng: {hide: '!active'}} - -# = render 'options_form', supplier_id: supplier_id, name: name - + - else + %div.panel-section.import-settings{ng: {controller: 'DropdownPanelsCtrl'}} + %div.panel-header + %div.header-caret + %div.header-icon.error + %i.fa.fa-warning + %div.header-description + No name + %span.header-error= " - some products have blank supplier name" %br.panels.clearfix %br diff --git a/app/views/admin/product_import/_import_review.html.haml b/app/views/admin/product_import/_import_review.html.haml index 20cb48b769..057bd5a0de 100644 --- a/app/views/admin/product_import/_import_review.html.haml +++ b/app/views/admin/product_import/_import_review.html.haml @@ -1,83 +1,107 @@ %h5 Import validation overview %br --#%div.panel-section --# %div.panel-header --# %div.header-caret --# %div.header-icon.info --# %i.fa.fa-info-circle --# %div.header-count --# %strong.existing-count= @importer.total_supplier_products --# %div.header-description --# Existing products in referenced enterprise(s) --# -#%div.panel-content{ng: {hide: '!active'}} --# -# Content goes here +%div{ng: {controller: 'ImportFeedbackCtrl'}} -%div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "count = #{@importer.item_count}"}} - %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count}'}} - %div.header-caret - %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count == 0'}} - %div.header-icon.success - %i.fa.fa-info-circle.info - %div.header-count - %strong.item-count= @importer.item_count - %div.header-description - Entries found in imported file - %div.panel-content{ng: {hide: '!active || count == 0'}} - = render 'entries_table', entries: @importer.all_entries + %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "all.count = count((entries | entriesFilterValid:'all')) "}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && all.count}'}} + %div.header-caret + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'all.count == 0'}} + %div.header-icon.success + %i.fa.fa-info-circle.info + %div.header-count + %strong.item-count + {{all.count}} + %div.header-description + Entries found in imported file + %div.panel-content{ng: {hide: '!active || all.count == 0'}} + = render 'entries_table', entries: @importer.all_entries, filter: 'all' -%div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "count = #{@importer.invalid_count}"}} - %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count}'}} - %div.header-caret - %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count == 0'}} - %div.header-icon.warning - %i.fa.fa-warning - %div.header-count - %strong.invalid-count= @importer.invalid_count - %div.header-description - Items contain errors and will not be imported - %div.panel-content{ng: {hide: '!active || count == 0'}} - = render 'errors_list' - %br - = render 'entries_table', entries: @importer.invalid_entries + %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "invalid.count = count((entries | entriesFilterValid:'invalid')) ", hide: 'invalid.count == 0'}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && invalid.count}'}} + %div.header-caret + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'invalid.count == 0'}} + %div.header-icon.warning + %i.fa.fa-warning + %div.header-count + %strong.invalid-count + {{invalid.count}} + %div.header-description + Items contain errors and will not be imported + %div.panel-content{ng: {hide: '!active || invalid.count == 0'}} + = render 'errors_list' + %br + = render 'entries_table', entries: @importer.all_entries, filter: 'invalid' -%div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "count = #{@importer.products_create_count}"}} - %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count}'}} - %div.header-caret - %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count == 0'}} - %div.header-icon.success - %i.fa.fa-check-circle - %div.header-count - %strong.create-count= @importer.products_create_count - %div.header-description - Products will be created - %div.panel-content{ng: {hide: '!active || count == 0'}} - = render 'entries_table', entries: @importer.products_to_create + %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "create_product.count = count((entries | entriesFilterValid:'create_product')) ", hide: 'create_product.count == 0'}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && create_product.count}'}} + %div.header-caret + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'create_product.count == 0'}} + %div.header-icon.success + %i.fa.fa-check-circle + %div.header-count + %strong.create-count + {{create_product.count}} + %div.header-description + Products will be created + %div.panel-content{ng: {hide: '!active || create_product.count == 0'}} + = render 'entries_table', entries: @importer.all_entries, filter: 'create_product' -%div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "count = #{@importer.products_update_count}"}} - %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count}'}} - %div.header-caret - %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count == 0'}} - %div.header-icon.success - %i.fa.fa-check-circle - %div.header-count - %strong.update-count= @importer.products_update_count - %div.header-description - Products will be updated - %div.panel-content{ng: {hide: '!active || count == 0'}} - = render 'entries_table', entries: @importer.products_to_update + %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "update_product.count = count((entries | entriesFilterValid:'update_product')) ", hide: 'update_product.count == 0'}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && update_product.count}'}} + %div.header-caret + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'update_product.count == 0'}} + %div.header-icon.success + %i.fa.fa-check-circle + %div.header-count + %strong.update-count + {{update_product.count}} + %div.header-description + Products will be updated + %div.panel-content{ng: {hide: '!active || update_product.count == 0'}} + = render 'entries_table', entries: @importer.all_entries, filter: 'update_product' -%div.panel-section{ng: {controller: 'ImportOptionsFormCtrl', hide: 'resetTotal == 0'}} - %div.panel-header - %div.header-caret - %div.header-icon.info - %i.fa.fa-info-circle - %div.header-count - %strong.reset-count - {{resetTotal}} - %div.header-description - Existing products will have their stock reset to zero - -#%div.panel-content{ng: {hide: '!active'}} - -# Content goes here + %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "create_inventory.count = count((entries | entriesFilterValid:'create_inventory')) ", hide: 'create_inventory.count == 0'}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && create_inventory.count}'}} + %div.header-caret + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'create_inventory.count == 0'}} + %div.header-icon.success + %i.fa.fa-check-circle + %div.header-count + %strong.inv-create-count + {{create_inventory.count}} + %div.header-description + Inventory items will be created + %div.panel-content{ng: {hide: '!active || create_inventory.count == 0'}} + = render 'entries_table', entries: @importer.all_entries, filter: 'create_inventory' -%br.panels.clearfix \ No newline at end of file + %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "update_inventory.count = count((entries | entriesFilterValid:'update_inventory')) ", hide: 'update_inventory.count == 0'}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && update_inventory.count}'}} + %div.header-caret + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'update_inventory.count == 0'}} + %div.header-icon.success + %i.fa.fa-check-circle + %div.header-count + %strong.inv-update-count + {{update_inventory.count}} + %div.header-description + Inventory items will be updated + %div.panel-content{ng: {hide: '!active || update_inventory.count == 0'}} + = render 'entries_table', entries: @importer.all_entries, filter: 'update_inventory' + + %div.panel-section{ng: {controller: 'ImportOptionsFormCtrl', hide: 'resetTotal == 0'}} + %div.panel-header + %div.header-caret + %div.header-icon.info + %i.fa.fa-info-circle + %div.header-count + %strong.reset-count + {{resetTotal}} + %div.header-description + -if @import_into == 'inventories' + Existing inventory items will have their stock reset to zero + - else + Existing products will have their stock reset to zero + -#%div.panel-content{ng: {hide: '!active'}} + + %br.panels.clearfix \ No newline at end of file diff --git a/app/views/admin/product_import/_inventory_options_form.html.haml b/app/views/admin/product_import/_inventory_options_form.html.haml new file mode 100644 index 0000000000..1de29a6d02 --- /dev/null +++ b/app/views/admin/product_import/_inventory_options_form.html.haml @@ -0,0 +1,16 @@ +%table.import-settings{ng: {controller: 'ImportOptionsFormCtrl', init: "supplierId = #{supplier_id}; resetCount = #{@importer.reset_counts[supplier_id][:reset_count]}"}} + %tr + %td.description + Remove absent products? + %td + = check_box_tag "settings[#{supplier_id}][reset_all_absent]", 1, false, :'ng-model' => 'resetAbsent', :'ng-change' => 'toggleResetAbsent()' + %td + %tr + %td.description + Set default stock level + %td + = check_box_tag "settings[#{supplier_id}][defaults][count_on_hand][active]", 1, false, :'ng-model' => "count_on_hand_#{supplier_id}" + %td + = select_tag "settings[#{supplier_id}][defaults][count_on_hand][mode]", options_for_select({"Overwrite all" => :overwrite_all, "Overwrite if empty" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!count_on_hand_#{supplier_id}"} + %td + = number_field_tag "settings[#{supplier_id}][defaults][count_on_hand][value]", 0, 'ng-disabled' => "!count_on_hand_#{supplier_id}" \ No newline at end of file diff --git a/app/views/admin/product_import/_product_options_form.html.haml b/app/views/admin/product_import/_product_options_form.html.haml new file mode 100644 index 0000000000..3958af6f01 --- /dev/null +++ b/app/views/admin/product_import/_product_options_form.html.haml @@ -0,0 +1,44 @@ +%table.import-settings{ng: {controller: 'ImportOptionsFormCtrl', init: "supplierId = #{supplier_id}; resetCount = #{@importer.reset_counts[supplier_id][:reset_count]}"}} + %tr + %td.description + Remove absent products? + %td + = check_box_tag "settings[#{supplier_id}][reset_all_absent]", 1, false, :'ng-model' => 'resetAbsent', :'ng-change' => 'toggleResetAbsent()' + %td + %td + %tr + %td.description + Set default stock level + %td + = check_box_tag "settings[#{supplier_id}][defaults][on_hand][active]", 1, false, :'ng-model' => "on_hand_#{supplier_id}" + %td + = select_tag "settings[#{supplier_id}][defaults][on_hand][mode]", options_for_select({"Overwrite all" => :overwrite_all, "Overwrite if empty" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!on_hand_#{supplier_id}"} + %td + = number_field_tag "settings[#{supplier_id}][defaults][on_hand][value]", 0, 'ng-disabled' => "!on_hand_#{supplier_id}" + %tr + %td.description + Set default tax category + %td + = check_box_tag "settings[#{supplier_id}][defaults][tax_category_id][active]", 1, false, :'ng-model' => "tax_category_id_#{supplier_id}" + %td + = select_tag "settings[#{supplier_id}][defaults][tax_category_id][mode]", options_for_select({"Overwrite all" => :overwrite_all, "Overwrite if empty" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!tax_category_id_#{supplier_id}"} + %td + = select_tag "settings[#{supplier_id}][defaults][tax_category_id][value]", options_for_select(@tax_categories.map {|tc| [tc.name, tc.id]}), {prompt: 'None', class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!tax_category_id_#{supplier_id}"} + %tr + %td.description + Set default shipping category + %td + = check_box_tag "settings[#{supplier_id}][defaults][shipping_category_id][active]", 1, false, :'ng-model' => "shipping_category_id_#{supplier_id}" + %td + = select_tag "settings[#{supplier_id}][defaults][shipping_category_id][mode]", options_for_select({"Overwrite all" => :overwrite_all, "Overwrite if empty" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!shipping_category_id_#{supplier_id}"} + %td + = select_tag "settings[#{supplier_id}][defaults][shipping_category_id][value]", options_for_select(@shipping_categories.map {|sc| [sc.name, sc.id]}), {prompt: 'None', class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!shipping_category_id_#{supplier_id}"} + %tr + %td.description + Set default available date + %td + = check_box_tag "settings[#{supplier_id}][defaults][available_on][active]", 1, false, :'ng-model' => "available_on_#{supplier_id}" + %td + = select_tag "settings[#{supplier_id}][defaults][available_on][mode]", options_for_select({"Overwrite all" => :overwrite_all, "Overwrite if empty" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!available_on_#{supplier_id}"} + %td + = text_field_tag "settings[#{supplier_id}][defaults][available_on][value]", nil, {class: 'datepicker', placeholder: 'Today', 'ng-disabled' => "!available_on_#{supplier_id}"} diff --git a/app/views/admin/product_import/_upload_form.html.haml b/app/views/admin/product_import/_upload_form.html.haml index 46e05adfdb..ff246f96df 100644 --- a/app/views/admin/product_import/_upload_form.html.haml +++ b/app/views/admin/product_import/_upload_form.html.haml @@ -1,9 +1,17 @@ -%h5 Select a spreadsheet to upload -%br -= form_tag main_app.admin_product_import_path, multipart: true do - = file_field_tag :file +%div{ng: {app: 'ofn.admin'}} + + %h5 Select a spreadsheet to upload %br - %br - = submit_tag "Import" - %br - %br \ No newline at end of file + = form_tag main_app.admin_product_import_path, multipart: true, class: 'product-import' do + %label Spreadsheet + %br + = file_field_tag :file + %br + %br + %label Import into: + %br + = select_tag "settings[import_into]", options_for_select({"Product List" => :product_list, "Inventories" => :inventories}), {class: 'select2 select2-no-search'} + %br + %br + %br + = submit_tag "Import" diff --git a/app/views/admin/product_import/import.html.haml b/app/views/admin/product_import/import.html.haml index 74422c162f..dadb9b45b3 100644 --- a/app/views/admin/product_import/import.html.haml +++ b/app/views/admin/product_import/import.html.haml @@ -3,31 +3,35 @@ = render partial: 'spree/admin/shared/product_sub_menu' -= form_tag main_app.admin_product_import_save_path, {'ng-app' => 'ofn.admin'} do += form_tag main_app.admin_product_import_save_path, {class: 'product-import', 'ng-app' => 'ofn.admin'} do - - if @importer.invalid_count && !@importer.has_valid_entries? + - if !@importer.has_valid_entries? #and @importer.invalid_count %h5 No valid entries found %p There are no entries that can be saved %br + = render partial: "admin/json/injection_ams", locals: {ngModule: 'ofn.admin', name: 'productImportData', json: @importer.entries_json} + = render 'import_options' if @importer.has_valid_entries? - = render 'import_review' + = render 'import_review' if @importer.has_entries? - - if @importer.has_valid_entries? - - if @importer.invalid_count > 0 + %div{ng: {controller: 'ImportFeedbackCtrl', show: "count((entries | entriesFilterValid:'valid')) > 0"}} + %div{ng: {if: "count((entries | entriesFilterValid:'invalid')) > 0"}} %br %h5 Imported file contains some invalid entries %p Save valid entries for now and discard the others? - - else + %div{ng: {show: "count((entries | entriesFilterValid:'invalid')) == 0"}} + %br %h5 No errors detected! %p Save all imported products? %br = hidden_field_tag :filepath, @filepath + = hidden_field_tag "settings[import_into]", @import_into = submit_tag "Save" %a.button{href: main_app.admin_product_import_path} Cancel - - else + %div{ng: {controller: 'ImportFeedbackCtrl', show: "count((entries | entriesFilterValid:'valid')) == 0"}} %br %a.button{href: main_app.admin_product_import_path} Cancel diff --git a/app/views/admin/product_import/save.html.haml b/app/views/admin/product_import/save.html.haml index 06d11e5f35..1178617901 100644 --- a/app/views/admin/product_import/save.html.haml +++ b/app/views/admin/product_import/save.html.haml @@ -8,21 +8,38 @@ %div.post-save-results{ng: {app: 'ofn.admin'}} - %p - %i.fa{ng: {class: "{'fa-info-circle': #{@importer.products_created_count} == 0, 'fa-check-circle': #{@importer.products_created_count} != 0}"}} - %strong.created-count= @importer.products_created_count - Products created + - if @importer.products_created_count > 0 + %p + %i.fa{ng: {class: "{'fa-info-circle': #{@importer.products_created_count} == 0, 'fa-check-circle': #{@importer.products_created_count} != 0}"}} + %strong.created-count= @importer.products_created_count + Products created - %p - %i.fa{ng: {class: "{'fa-info-circle': #{@importer.products_updated_count} == 0, 'fa-check-circle': #{@importer.products_updated_count} != 0}"}} - %strong.updated-count= @importer.products_updated_count - Products updated + - if @importer.products_updated_count > 0 + %p + %i.fa{ng: {class: "{'fa-info-circle': #{@importer.products_updated_count} == 0, 'fa-check-circle': #{@importer.products_updated_count} != 0}"}} + %strong.updated-count= @importer.products_updated_count + Products updated + + - if @importer.inventory_created_count > 0 + %p + %i.fa{ng: {class: "{'fa-info-circle': #{@importer.inventory_created_count} == 0, 'fa-check-circle': #{@importer.inventory_created_count} != 0}"}} + %strong.inv-created-count= @importer.inventory_created_count + Inventory items updated + + - if @importer.inventory_updated_count > 0 + %p + %i.fa{ng: {class: "{'fa-info-circle': #{@importer.inventory_updated_count} == 0, 'fa-check-circle': #{@importer.inventory_updated_count} != 0}"}} + %strong.inv-updated-count= @importer.inventory_updated_count + Inventory items updated - if @importer.products_reset_count > 0 %p - %i.fa.fa-check-circle + %i.fa.fa-info-circle %strong.reset-count= @importer.products_reset_count - Products had stock level reset to zero + - if @import_into == 'inventories' + Inventory items had stock level reset to zero + - else + Products had stock level reset to zero %br diff --git a/spec/features/admin/product_import_spec.rb b/spec/features/admin/product_import_spec.rb index 33334de098..331b18c78b 100644 --- a/spec/features/admin/product_import_spec.rb +++ b/spec/features/admin/product_import_spec.rb @@ -7,20 +7,25 @@ feature "Product Import", js: true do let!(:admin) { create(:admin_user) } let!(:user) { create_enterprise_user } + let!(:user2) { create_enterprise_user } let!(:enterprise) { create(:supplier_enterprise, owner: user, name: "User Enterprise") } - let!(:enterprise2) { create(:supplier_enterprise, owner: admin, name: "Another Enterprise") } + let!(:enterprise2) { create(:distributor_enterprise, owner: user2, name: "Another Enterprise") } + let!(:relationship) { create(:enterprise_relationship, parent: enterprise, child: enterprise2, permissions_list: [:create_variant_overrides]) } + let!(:category) { create(:taxon, name: 'Vegetables') } let!(:category2) { create(:taxon, name: 'Cake') } let!(:tax_category) { create(:tax_category) } let!(:tax_category2) { create(:tax_category) } let!(:shipping_category) { create(:shipping_category) } + let!(:product) { create(:simple_product, supplier: enterprise2, name: 'Hypothetical Cake') } let!(:variant) { create(:variant, product_id: product.id, price: '8.50', on_hand: '100', unit_value: '500', display_name: 'Preexisting Banana') } let!(:product2) { create(:simple_product, supplier: enterprise, on_hand: '100', name: 'Beans', unit_value: '500') } - let!(:product3) { create(:simple_product, supplier: enterprise, on_hand: '100', name: 'Sprouts') } - let!(:product4) { create(:simple_product, supplier: enterprise, on_hand: '100', name: 'Cabbage') } - let!(:product5) { create(:simple_product, supplier: enterprise2, on_hand: '100', name: 'Lettuce') } - + let!(:product3) { create(:simple_product, supplier: enterprise, on_hand: '100', name: 'Sprouts', unit_value: '500') } + let!(:product4) { create(:simple_product, supplier: enterprise, on_hand: '100', name: 'Cabbage', unit_value: '500') } + let!(:product5) { create(:simple_product, supplier: enterprise2, on_hand: '100', name: 'Lettuce', unit_value: '500') } + let!(:variant_override) { create(:variant_override, variant_id: product4.variants.first.id, hub: enterprise2, count_on_hand: 42) } + let!(:variant_override2) { create(:variant_override, variant_id: product5.variants.first.id, hub: enterprise, count_on_hand: 96) } describe "when importing products from uploaded file" do before { quick_login_as_admin } @@ -41,18 +46,19 @@ feature "Product Import", js: true do click_button 'Import' expect(page).to have_selector '.item-count', text: "2" - expect(page).to have_selector '.invalid-count', text: "0" + expect(page).to_not have_selector '.invalid-count' expect(page).to have_selector '.create-count', text: "2" - expect(page).to have_selector '.update-count', text: "0" + expect(page).to_not have_selector '.update-count' click_button 'Save' expect(page).to have_selector '.created-count', text: '2' - expect(page).to have_selector '.updated-count', text: '0' + expect(page).to_not have_selector '.updated-count' potatoes = Spree::Product.find_by_name('Potatoes') potatoes.supplier.should == enterprise potatoes.on_hand.should == 6 potatoes.price.should == 6.50 + potatoes.variants.first.import_date.should be_within(1.minute).of DateTime.now end it "displays info about invalid entries but still allows saving of valid entries" do @@ -72,13 +78,11 @@ feature "Product Import", js: true do expect(page).to have_selector '.item-count', text: "2" expect(page).to have_selector '.invalid-count', text: "1" expect(page).to have_selector '.create-count', text: "1" - expect(page).to have_selector '.update-count', text: "0" expect(page).to have_selector 'input[type=submit][value="Save"]' click_button 'Save' expect(page).to have_selector '.created-count', text: '1' - expect(page).to have_selector '.updated-count', text: '0' Spree::Product.find_by_name('Bad Potatoes').should == nil carrots = Spree::Product.find_by_name('Good Carrots') @@ -103,8 +107,8 @@ feature "Product Import", js: true do expect(page).to have_selector '.item-count', text: "2" expect(page).to have_selector '.invalid-count', text: "2" - expect(page).to have_selector '.create-count', text: "0" - expect(page).to have_selector '.update-count', text: "0" + expect(page).to_not have_selector '.create-count' + expect(page).to_not have_selector '.update-count' expect(page).to_not have_selector 'input[type=submit][value="Save"]' end @@ -122,7 +126,7 @@ feature "Product Import", js: true do click_button 'Import' expect(page).to have_selector '.item-count', text: "2" - expect(page).to have_selector '.invalid-count', text: "0" + expect(page).to_not have_selector '.invalid-count' expect(page).to have_selector '.create-count', text: "1" expect(page).to have_selector '.update-count', text: "1" @@ -135,11 +139,13 @@ feature "Product Import", js: true do added_coffee.product.name.should == 'Hypothetical Cake' added_coffee.price.should == 3.50 added_coffee.on_hand.should == 6 + added_coffee.import_date.should be_within(1.minute).of DateTime.now updated_banana = Spree::Variant.find_by_display_name('Preexisting Banana') updated_banana.product.name.should == 'Hypothetical Cake' updated_banana.price.should == 5.50 updated_banana.on_hand.should == 5 + updated_banana.import_date.should be_within(1.minute).of DateTime.now end it "can add a new product and sub-variants of that product at the same time" do @@ -155,13 +161,11 @@ feature "Product Import", js: true do click_button 'Import' expect(page).to have_selector '.item-count', text: "2" - expect(page).to have_selector '.invalid-count', text: "0" + expect(page).to_not have_selector '.invalid-count' expect(page).to have_selector '.create-count', text: "2" - expect(page).to have_selector '.update-count', text: "0" click_button 'Save' expect(page).to have_selector '.created-count', text: '2' - expect(page).to have_selector '.updated-count', text: '0' small_bag = Spree::Variant.find_by_display_name('Small Bag') small_bag.product.name.should == 'Potatoes' @@ -173,6 +177,89 @@ feature "Product Import", js: true do big_bag.price.should == 5.50 big_bag.on_hand.should == 6 end + + it "records a timestamp on import that can be viewed and filtered under Bulk Edit Products" do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] + csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] + end + File.write('/tmp/test.csv', csv_data) + + visit main_app.admin_product_import_path + + expect(page).to have_content "Select a spreadsheet to upload" + attach_file 'file', '/tmp/test.csv' + click_button 'Import' + click_button 'Save' + + carrots = Spree::Product.find_by_name('Carrots') + carrots.variants.first.import_date.should be_within(1.minute).of DateTime.now + + visit 'admin/products/bulk_edit' + + wait_until { page.find("#p_#{carrots.id}").present? } + + expect(page).to have_field "product_name", with: carrots.name + find("div#columns-dropdown", :text => "COLUMNS").click + find("div#columns-dropdown div.menu div.menu_item", text: "Import").click + find("div#columns-dropdown", :text => "COLUMNS").click + + within "tr#p_#{carrots.id} td.import_date" do + expect(page).to have_content DateTime.now.year + end + + expect(page).to have_selector 'div#s2id_import_date_filter' + import_time = carrots.import_date.to_formatted_s(:long) + select import_time, from: "import_date_filter", visible: false + + expect(page).to have_field "product_name", with: carrots.name + expect(page).to_not have_field "product_name", with: product.name + expect(page).to_not have_field "product_name", with: product2.name + end + + it "can import items into inventory" do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "producer", "category", "on_hand", "price", "unit_value"] + csv << ["Beans", "Another Enterprise", "User Enterprise", "Vegetables", "5", "3.20", "500"] + csv << ["Sprouts", "Another Enterprise", "User Enterprise", "Vegetables", "6", "6.50", "500"] + csv << ["Cabbage", "Another Enterprise", "User Enterprise", "Vegetables", "2001", "1.50", "500"] + end + File.write('/tmp/test.csv', csv_data) + + visit main_app.admin_product_import_path + + attach_file 'file', '/tmp/test.csv' + select 'Inventories', from: "settings_import_into", visible: false + click_button 'Import' + + expect(page).to have_selector '.item-count', text: "3" + expect(page).to_not have_selector '.invalid-count' + expect(page).to_not have_selector '.create-count' + expect(page).to_not have_selector '.update-count' + expect(page).to have_selector '.inv-create-count', text: "2" + expect(page).to have_selector '.inv-update-count', text: "1" + + click_button 'Save' + + expect(page).to_not have_selector '.created-count' + expect(page).to_not have_selector '.updated-count' + expect(page).to have_selector '.inv-created-count', text: '2' + expect(page).to have_selector '.inv-updated-count', text: '1' + + + beans_override = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first + sprouts_override = VariantOverride.where(variant_id: product3.variants.first.id, hub_id: enterprise2.id).first + cabbage_override = VariantOverride.where(variant_id: product4.variants.first.id, hub_id: enterprise2.id).first + + Float(beans_override.price).should == 3.20 + beans_override.count_on_hand.should == 5 + + Float(sprouts_override.price).should == 6.50 + sprouts_override.count_on_hand.should == 6 + + Float(cabbage_override.price).should == 1.50 + cabbage_override.count_on_hand.should == 2001 + end end describe "when dealing with uploaded files" do @@ -206,17 +293,17 @@ feature "Product Import", js: true do attach_file 'file', '/tmp/test.csv' click_button 'Import' - expect(page).to have_selector '.create-count', text: "0" - expect(page).to have_selector '.update-count', text: "0" + expect(page).to_not have_selector '.create-count' + expect(page).to_not have_selector '.update-count' expect(page).to_not have_selector 'input[type=submit][value="Save"]' File.delete('/tmp/test.csv') end end describe "handling enterprise permissions" do - before { quick_login_as user } + after { File.delete('/tmp/test.csv') } - it "only allows import into enterprises the user is permitted to manage" do + it "only allows product import into enterprises the user is permitted to manage" do csv_data = CSV.generate do |csv| csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] csv << ["My Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] @@ -224,6 +311,7 @@ feature "Product Import", js: true do end File.write('/tmp/test.csv', csv_data) + quick_login_as user visit main_app.admin_product_import_path attach_file 'file', '/tmp/test.csv' @@ -232,24 +320,75 @@ feature "Product Import", js: true do expect(page).to have_selector '.item-count', text: "2" expect(page).to have_selector '.invalid-count', text: "1" expect(page).to have_selector '.create-count', text: "1" - expect(page).to have_selector '.update-count', text: "0" expect(page.body).to have_content 'you do not have permission' click_button 'Save' expect(page).to have_selector '.created-count', text: '1' - expect(page).to have_selector '.updated-count', text: '0' Spree::Product.find_by_name('My Carrots').should be_a Spree::Product Spree::Product.find_by_name('Your Potatoes').should == nil end + + it "allows creating inventories for producers that a user's hub has permission for" do + csv_data = CSV.generate do |csv| + csv << ["name", "producer", "supplier", "category", "on_hand", "price", "unit_value"] + csv << ["Beans", "User Enterprise", "Another Enterprise", "Vegetables", "777", "3.20", "500"] + end + File.write('/tmp/test.csv', csv_data) + + quick_login_as user2 + visit main_app.admin_product_import_path + + attach_file 'file', '/tmp/test.csv' + select 'Inventories', from: "settings_import_into", visible: false + click_button 'Import' + + expect(page).to have_selector '.item-count', text: "1" + expect(page).to_not have_selector '.invalid-count' + expect(page).to have_selector '.inv-create-count', text: "1" + + #expect(page.body).to have_content 'you do not have permission' + + click_button 'Save' + + expect(page).to have_selector '.inv-created-count', text: '1' + + beans = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first + beans.count_on_hand.should == 777 + end + + it "does not allow creating inventories for producers that a user's hubs don't have permission for" do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "category", "on_hand", "price", "unit_value"] + csv << ["Beans", "User Enterprise", "Vegetables", "5", "3.20", "500"] + csv << ["Sprouts", "User Enterprise", "Vegetables", "6", "6.50", "500"] + end + File.write('/tmp/test.csv', csv_data) + + quick_login_as user2 + visit main_app.admin_product_import_path + + attach_file 'file', '/tmp/test.csv' + select 'Inventories', from: "settings_import_into", visible: false + click_button 'Import' + + expect(page).to have_selector '.item-count', text: "2" + expect(page).to have_selector '.invalid-count', text: "2" + expect(page).to_not have_selector '.inv-create-count' + + expect(page.body).to have_content 'you do not have permission' + + + end end describe "applying settings and defaults on import" do before { quick_login_as_admin } + after { File.delete('/tmp/test.csv') } - it "can set all products for an enterprise that are not present in the uploaded file to zero stock" do + it "can reset all products for an enterprise that are not present in the uploaded file to zero stock" do csv_data = CSV.generate do |csv| csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] @@ -263,7 +402,7 @@ feature "Product Import", js: true do click_button 'Import' expect(page).to have_selector '.item-count', text: "2" - expect(page).to have_selector '.invalid-count', text: "0" + expect(page).to_not have_selector '.invalid-count' expect(page).to have_selector '.create-count', text: "1" expect(page).to have_selector '.update-count', text: "1" @@ -289,7 +428,50 @@ feature "Product Import", js: true do Spree::Product.find_by_name('Lettuce').on_hand.should == 100 # In different enterprise; unchanged end - it "overwrites fields with selected defaults" do + it "can reset all inventory items for an enterprise that are not present in the uploaded file to zero stock" do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "producer", "category", "on_hand", "price", "unit_value"] + csv << ["Beans", "Another Enterprise", "User Enterprise", "Vegetables", "6", "3.20", "500"] + csv << ["Sprouts", "Another Enterprise", "User Enterprise", "Vegetables", "7", "6.50", "500"] + end + File.write('/tmp/test.csv', csv_data) + + visit main_app.admin_product_import_path + + attach_file 'file', '/tmp/test.csv' + select 'Inventories', from: "settings_import_into", visible: false + click_button 'Import' + + expect(page).to have_selector '.item-count', text: "2" + expect(page).to_not have_selector '.invalid-count' + expect(page).to have_selector '.inv-create-count', text: "2" + + expect(page).to_not have_selector '.reset-count' + + within 'div.import-settings' do + find('div.header-description').click # Import settings tab + check "settings_#{enterprise2.id}_reset_all_absent" + end + + expect(page).to have_selector '.reset-count', text: "1" + + click_button 'Save' + + expect(page).to have_selector '.inv-created-count', text: '2' + expect(page).to have_selector '.reset-count', text: '1' + + beans = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first + sprouts = VariantOverride.where(variant_id: product3.variants.first.id, hub_id: enterprise2.id).first + cabbage = VariantOverride.where(variant_id: product4.variants.first.id, hub_id: enterprise2.id).first + lettuce = VariantOverride.where(variant_id: product5.variants.first.id, hub_id: enterprise.id).first + + beans.count_on_hand.should == 6 # Present in file, created + sprouts.count_on_hand.should == 7 # Present in file, created + cabbage.count_on_hand.should == 0 # In enterprise, not in file (reset) + lettuce.count_on_hand.should == 96 # In different enterprise; unchanged + end + + it "can overwrite fields with selected defaults when importing to product list" do csv_data = CSV.generate do |csv| csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale", "tax_category_id", "available_on"] csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1", tax_category.id, ""] @@ -307,18 +489,22 @@ feature "Product Import", js: true do expect(page).to have_selector "#settings_#{enterprise.id}_defaults_on_hand_mode", visible: false # Overwrite stock level of all items to 9000 + check "settings_#{enterprise.id}_defaults_on_hand_active" select 'Overwrite all', from: "settings_#{enterprise.id}_defaults_on_hand_mode", visible: false fill_in "settings_#{enterprise.id}_defaults_on_hand_value", with: '9000' # Overwrite default tax category, but only where field is empty + check "settings_#{enterprise.id}_defaults_tax_category_id_active" select 'Overwrite if empty', from: "settings_#{enterprise.id}_defaults_tax_category_id_mode", visible: false select tax_category2.name, from: "settings_#{enterprise.id}_defaults_tax_category_id_value", visible: false # Set default shipping category (field not present in file) + check "settings_#{enterprise.id}_defaults_shipping_category_id_active" select 'Overwrite all', from: "settings_#{enterprise.id}_defaults_shipping_category_id_mode", visible: false select shipping_category.name, from: "settings_#{enterprise.id}_defaults_shipping_category_id_value", visible: false # Set available_on date + check "settings_#{enterprise.id}_defaults_available_on_active" select 'Overwrite all', from: "settings_#{enterprise.id}_defaults_available_on_mode", visible: false find("input#settings_#{enterprise.id}_defaults_available_on_value").set '2020-01-01' end @@ -326,7 +512,6 @@ feature "Product Import", js: true do click_button 'Save' expect(page).to have_selector '.created-count', text: '2' - expect(page).to have_selector '.updated-count', text: '0' carrots = Spree::Product.find_by_name('Carrots') carrots.on_hand.should == 9000 @@ -340,5 +525,50 @@ feature "Product Import", js: true do potatoes.shipping_category_id.should == shipping_category.id potatoes.available_on.should be_within(1.day).of(Time.zone.local(2020, 1, 1)) end + + it "can overwrite fields with selected defaults when importing to inventory" do + csv_data = CSV.generate do |csv| + csv << ["name", "producer", "supplier", "category", "on_hand", "price", "unit_value"] + csv << ["Beans", "User Enterprise", "Another Enterprise", "Vegetables", "", "3.20", "500"] + csv << ["Sprouts", "User Enterprise", "Another Enterprise", "Vegetables", "7", "6.50", "500"] + csv << ["Cabbage", "User Enterprise", "Another Enterprise", "Vegetables", "", "1.50", "500"] + end + File.write('/tmp/test.csv', csv_data) + + visit main_app.admin_product_import_path + + attach_file 'file', '/tmp/test.csv' + select 'Inventories', from: "settings_import_into", visible: false + click_button 'Import' + + within 'div.import-settings' do + find('div.header-description').click # Import settings tab + check "settings_#{enterprise2.id}_defaults_count_on_hand_active" + select 'Overwrite if empty', from: "settings_#{enterprise2.id}_defaults_count_on_hand_mode", visible: false + fill_in "settings_#{enterprise2.id}_defaults_count_on_hand_value", with: '9000' + end + + expect(page).to have_selector '.item-count', text: "3" + expect(page).to_not have_selector '.invalid-count' + expect(page).to_not have_selector '.create-count' + expect(page).to_not have_selector '.update-count' + expect(page).to have_selector '.inv-create-count', text: "2" + expect(page).to have_selector '.inv-update-count', text: "1" + + click_button 'Save' + + expect(page).to_not have_selector '.created-count' + expect(page).to_not have_selector '.updated-count' + expect(page).to have_selector '.inv-created-count', text: '2' + expect(page).to have_selector '.inv-updated-count', text: '1' + + beans_override = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first + sprouts_override = VariantOverride.where(variant_id: product3.variants.first.id, hub_id: enterprise2.id).first + cabbage_override = VariantOverride.where(variant_id: product4.variants.first.id, hub_id: enterprise2.id).first + + beans_override.count_on_hand.should == 9000 + sprouts_override.count_on_hand.should == 7 + cabbage_override.count_on_hand.should == 9000 + end end end From 4e9744565542e0b74b0c3246a85e9db7d543c80c Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Thu, 16 Mar 2017 16:33:22 +0000 Subject: [PATCH 101/206] PI inventories timestamps --- app/models/product_importer.rb | 23 +++++++++++++++++++ .../api/admin/variant_override_serializer.rb | 2 +- .../variant_overrides/_products.html.haml | 2 ++ .../_products_product.html.haml | 1 + .../_products_variants.html.haml | 2 ++ config/locales/en.yml | 1 + config/locales/en_GB.yml | 1 + .../column_preference_defaults.rb | 3 ++- 8 files changed, 33 insertions(+), 2 deletions(-) diff --git a/app/models/product_importer.rb b/app/models/product_importer.rb index 712705d1ac..509ecabf7e 100644 --- a/app/models/product_importer.rb +++ b/app/models/product_importer.rb @@ -448,11 +448,33 @@ class ProductImporter @already_created[entry.supplier_id] = {entry.name => product.id} 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 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? and new_item.save + display_in_inventory(new_item, true) @inventory_created += 1 @updated_ids.push new_item.id else @@ -465,6 +487,7 @@ class ProductImporter assign_defaults(existing_item, entry) existing_item.import_date = @import_time if existing_item.valid? and existing_item.save + display_in_inventory(existing_item) @inventory_updated += 1 @updated_ids.push existing_item.id else diff --git a/app/serializers/api/admin/variant_override_serializer.rb b/app/serializers/api/admin/variant_override_serializer.rb index 68b86818a1..c63e77c068 100644 --- a/app/serializers/api/admin/variant_override_serializer.rb +++ b/app/serializers/api/admin/variant_override_serializer.rb @@ -1,6 +1,6 @@ class Api::Admin::VariantOverrideSerializer < ActiveModel::Serializer attributes :id, :hub_id, :variant_id, :sku, :price, :count_on_hand, :on_demand, :default_stock, :resettable - attributes :tag_list, :tags + attributes :tag_list, :tags, :import_date def tag_list object.tag_list.join(",") diff --git a/app/views/admin/variant_overrides/_products.html.haml b/app/views/admin/variant_overrides/_products.html.haml index 711c52f6cf..73848c5b6c 100644 --- a/app/views/admin/variant_overrides/_products.html.haml +++ b/app/views/admin/variant_overrides/_products.html.haml @@ -13,6 +13,7 @@ %col.inheritance{ width: "5%", ng: { show: 'columns.inheritance.visible' } } %col.tags{ width: "30%", ng: { show: 'columns.tags.visible' } } %col.visibility{ width: "10%", ng: { show: 'columns.visibility.visible' } } + %col.visibility{ width: "10%", ng: { show: 'columns.import_date.visible' } } %thead %tr{ ng: { controller: "ColumnsCtrl" } } %th.producer{ ng: { show: 'columns.producer.visible' } }=t('admin.producer') @@ -25,6 +26,7 @@ %th.inheritance{ ng: { show: 'columns.inheritance.visible' } }=t('admin.variant_overrides.index.inherit?') %th.tags{ ng: { show: 'columns.tags.visible' } }=t('admin.tags') %th.visibility{ ng: { show: 'columns.visibility.visible' } }=t('admin.variant_overrides.index.hide') + %th.import_date{ ng: { show: 'columns.import_date.visible' } }=t('admin.variant_overrides.index.import_date') %tbody{ ng: {repeat: 'product in filteredProducts = (products | hubPermissions:hubPermissions:hub_id | inventoryProducts:hub_id:views | attrFilter:{producer_id:producerFilter} | filter:query) | limitTo:productLimit' } } = render 'admin/variant_overrides/products_product' = render 'admin/variant_overrides/products_variants' diff --git a/app/views/admin/variant_overrides/_products_product.html.haml b/app/views/admin/variant_overrides/_products_product.html.haml index 0427333790..5f101e91a6 100644 --- a/app/views/admin/variant_overrides/_products_product.html.haml +++ b/app/views/admin/variant_overrides/_products_product.html.haml @@ -9,3 +9,4 @@ %td.inheritance{ ng: { show: 'columns.inheritance.visible' } } %td.tags{ ng: { show: 'columns.tags.visible' } } %td.visibility{ ng: { show: 'columns.visibility.visible' } } + %td.import_date{ ng: { show: 'columns.import_date.visible' } } diff --git a/app/views/admin/variant_overrides/_products_variants.html.haml b/app/views/admin/variant_overrides/_products_variants.html.haml index 94fc309798..7c530a1cb2 100644 --- a/app/views/admin/variant_overrides/_products_variants.html.haml +++ b/app/views/admin/variant_overrides/_products_variants.html.haml @@ -23,3 +23,5 @@ %td.visibility{ ng: { show: 'columns.visibility.visible' } } %button.icon-remove.hide.fullwidth{ :type => 'button', ng: { click: "setVisibility(hub_id,variant.id,false)" } } = t('admin.variant_overrides.index.hide') + %td.import_date{ ng: { show: 'columns.import_date.visible' } } + %span {{variantOverrides[hub_id][variant.id].import_date | date:"MMMM dd, yyyy HH:mm"}} \ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml index f8fd877c86..760d14c28d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -485,6 +485,7 @@ en: inherit?: Inherit? add: Add hide: Hide + import_date: Imported select_a_shop: Select A Shop review_now: Review Now new_products_alert_message: There are %{new_product_count} new products available to add to your inventory. diff --git a/config/locales/en_GB.yml b/config/locales/en_GB.yml index 6d073525c8..baa68f8d6d 100644 --- a/config/locales/en_GB.yml +++ b/config/locales/en_GB.yml @@ -289,6 +289,7 @@ en_GB: inherit?: Inherit? add: Add hide: Hide + import_date: Imported select_a_shop: Select A Shop review_now: Review Now new_products_alert_message: There are %{new_product_count} new products available to add to your inventory. diff --git a/lib/open_food_network/column_preference_defaults.rb b/lib/open_food_network/column_preference_defaults.rb index 409d9fe7bb..e45670869b 100644 --- a/lib/open_food_network/column_preference_defaults.rb +++ b/lib/open_food_network/column_preference_defaults.rb @@ -19,7 +19,8 @@ module OpenFoodNetwork reset: { name: I18n.t("#{node}.enable_reset?"), visible: false }, inheritance: { name: I18n.t("#{node}.inherit?"), visible: false }, tags: { name: I18n.t("admin.tags"), visible: false }, - visibility: { name: I18n.t("#{node}.hide"), visible: false } + visibility: { name: I18n.t("#{node}.hide"), visible: false }, + import_date: { name: I18n.t("#{node}.import_date"), visible: false } } end From ffbb67d480e18a83def5f6191a3141ab0dc9c45d Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Fri, 17 Mar 2017 19:31:19 +0000 Subject: [PATCH 102/206] PI inventories additions --- .../admin/bulk_product_update.js.coffee | 3 ++ .../variant_overrides_controller.js.coffee | 1 + .../filters/import_date_filter.js.coffee | 12 +++++ .../variant_overrides.js.coffee | 2 +- .../admin/variant_overrides_controller.rb | 5 ++ .../admin/products_controller_decorator.rb | 5 ++ app/models/product_importer.rb | 52 +++++++++++-------- app/views/admin/product_import/save.html.haml | 8 +++ .../variant_overrides/_filters.html.haml | 23 ++++---- .../variant_overrides/_products.html.haml | 2 +- .../products/bulk_edit/_filters.html.haml | 22 ++++---- spec/features/admin/product_import_spec.rb | 29 ++++++++--- 12 files changed, 114 insertions(+), 50 deletions(-) create mode 100644 app/assets/javascripts/admin/variant_overrides/filters/import_date_filter.js.coffee diff --git a/app/assets/javascripts/admin/bulk_product_update.js.coffee b/app/assets/javascripts/admin/bulk_product_update.js.coffee index d7fe743904..500b4547d4 100644 --- a/app/assets/javascripts/admin/bulk_product_update.js.coffee +++ b/app/assets/javascripts/admin/bulk_product_update.js.coffee @@ -53,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() diff --git a/app/assets/javascripts/admin/variant_overrides/controllers/variant_overrides_controller.js.coffee b/app/assets/javascripts/admin/variant_overrides/controllers/variant_overrides_controller.js.coffee index ac6c993fef..0fe051f496 100644 --- a/app/assets/javascripts/admin/variant_overrides/controllers/variant_overrides_controller.js.coffee +++ b/app/assets/javascripts/admin/variant_overrides/controllers/variant_overrides_controller.js.coffee @@ -25,6 +25,7 @@ angular.module("admin.variantOverrides").controller "AdminVariantOverridesCtrl", $scope.resetSelectFilters = -> $scope.producerFilter = 0 + $scope.importDateFilter = '0' $scope.query = '' $scope.resetSelectFilters() diff --git a/app/assets/javascripts/admin/variant_overrides/filters/import_date_filter.js.coffee b/app/assets/javascripts/admin/variant_overrides/filters/import_date_filter.js.coffee new file mode 100644 index 0000000000..4392d6b3f6 --- /dev/null +++ b/app/assets/javascripts/admin/variant_overrides/filters/import_date_filter.js.coffee @@ -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 == '' + + for variant in product.variants + for vo in variantOverrides + if vo.variant_id == variant.id and vo.import_date == date + return true + false + , true) \ No newline at end of file diff --git a/app/assets/javascripts/admin/variant_overrides/variant_overrides.js.coffee b/app/assets/javascripts/admin/variant_overrides/variant_overrides.js.coffee index 4d875cbd33..84e6537a64 100644 --- a/app/assets/javascripts/admin/variant_overrides/variant_overrides.js.coffee +++ b/app/assets/javascripts/admin/variant_overrides/variant_overrides.js.coffee @@ -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']) diff --git a/app/controllers/admin/variant_overrides_controller.rb b/app/controllers/admin/variant_overrides_controller.rb index 5aa1975e8d..cf978881a3 100644 --- a/app/controllers/admin/variant_overrides_controller.rb +++ b/app/controllers/admin/variant_overrides_controller.rb @@ -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,10 @@ module Admin variant_override_enterprises_per_hub @inventory_items = InventoryItem.where(enterprise_id: @hubs) + + import_dates = [{id: '0', name: 'All'}] + inventory_import_dates.map {|i| import_dates.push({id: i, name: i.to_formatted_s(:long)}) } + @import_dates = import_dates.to_json end def load_collection diff --git a/app/controllers/spree/admin/products_controller_decorator.rb b/app/controllers/spree/admin/products_controller_decorator.rb index 6061fe02b5..b6a71273f3 100644 --- a/app/controllers/spree/admin/products_controller_decorator.rb +++ b/app/controllers/spree/admin/products_controller_decorator.rb @@ -5,6 +5,7 @@ Spree::Admin::ProductsController.class_eval do include OpenFoodNetwork::SpreeApiKeyLoader include OrderCyclesHelper include EnterprisesHelper + before_filter :latest_import, only: [:bulk_edit] before_filter :load_form_data, :only => [:bulk_edit, :new, :create, :edit, :update] before_filter :load_spree_api_key, :only => [:bulk_edit, :variant_overrides] before_filter :strip_new_properties, only: [:create, :update] @@ -93,6 +94,10 @@ Spree::Admin::ProductsController.class_eval do private + def latest_import + @show_latest_import = params[:latest_import] || false + end + def load_form_data @producers = OpenFoodNetwork::Permissions.new(spree_current_user).managed_product_enterprises.is_primary_producer.by_name @taxons = Spree::Taxon.order(:name) diff --git a/app/models/product_importer.rb b/app/models/product_importer.rb index 509ecabf7e..549ef875cc 100644 --- a/app/models/product_importer.rb +++ b/app/models/product_importer.rb @@ -53,7 +53,7 @@ class ProductImporter end def persisted? - false #ActiveModel, not ActiveRecord + false # ActiveModel end def has_entries? @@ -188,7 +188,7 @@ class ProductImporter rows.each_with_index do |row, i| row_data = Hash[[headers, row].transpose] entry = SpreadsheetEntry.new(row_data) - entry.line_number = i+2 + entry.line_number = i + 2 @entries.push entry end @entries @@ -238,12 +238,7 @@ class ProductImporter def create_inventory_item(entry, existing_variant) existing_variant_override = VariantOverride.where(variant_id: existing_variant.id, hub_id: entry.supplier_id).first - if existing_variant_override - variant_override = existing_variant_override - else - variant_override = VariantOverride.new(variant_id: existing_variant.id, hub_id: entry.supplier_id) - end - + 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')) @@ -260,7 +255,7 @@ class ProductImporter end def mark_as_inventory_item(entry, variant_override) - if variant_override.id? + if variant_override.id entry.is_a_valid('existing_inventory_item') entry.product_object = variant_override updates_count_per_supplier(entry.supplier_id) unless entry.has_errors? @@ -279,7 +274,8 @@ class ProductImporter where('variant_overrides.hub_id IN (?)', supplier_id). count else - products_count = Spree::Variant.joins(:product). + 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). @@ -377,7 +373,7 @@ class ProductImporter @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) + Enterprise.find_by_name(supplier_name, select: 'id, name').try(:id) @suppliers_index[supplier_name] = supplier_id end @suppliers_index @@ -388,7 +384,7 @@ class ProductImporter @entries.each do |entry| producer_name = entry.producer producer_id = @producers_index[producer_name] || - Enterprise.find_by_name(producer_name, :select => 'id, name').try(:id) + Enterprise.find_by_name(producer_name, select: 'id, name').try(:id) @producers_index[producer_name] = producer_id end @producers_index @@ -437,6 +433,7 @@ class ProductImporter 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 @@ -473,6 +470,7 @@ class ProductImporter new_item = entry.product_object assign_defaults(new_item, entry) new_item.import_date = @import_time + if new_item.valid? and new_item.save display_in_inventory(new_item, true) @inventory_created += 1 @@ -486,6 +484,7 @@ class ProductImporter existing_item = entry.product_object assign_defaults(existing_item, entry) existing_item.import_date = @import_time + if existing_item.valid? and existing_item.save display_in_inventory(existing_item) @inventory_updated += 1 @@ -499,6 +498,7 @@ class ProductImporter new_variant = entry.product_object assign_defaults(new_variant, entry) new_variant.import_date = @import_time + if new_variant.valid? and new_variant.save @variants_created += 1 @updated_ids.push new_variant.id @@ -511,6 +511,7 @@ class ProductImporter variant = entry.product_object assign_defaults(variant, entry) variant.import_date = @import_time + if variant.valid? and variant.save @variants_updated += 1 @updated_ids.push variant.id @@ -584,7 +585,10 @@ class ProductImporter # 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 and existing_variant.unit_value == Float(entry.unit_value) + if existing_variant.display_name == entry.display_name \ + and existing_variant.unit_value == Float(entry.unit_value) \ + and existing_variant.deleted_at == nil + mark_as_existing_variant(entry, existing_variant) return end @@ -597,6 +601,7 @@ class ProductImporter def mark_as_new_product(entry) new_product = Spree::Product.new() new_product.assign_attributes(entry.attributes.except('id')) + if new_product.valid? entry.is_a_valid 'new_product' unless entry.has_errors? else @@ -607,6 +612,7 @@ class ProductImporter 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.is_a_valid 'existing_variant' unless entry.has_errors? @@ -620,6 +626,7 @@ class ProductImporter 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.is_a_valid 'new_variant' unless entry.has_errors? @@ -629,7 +636,8 @@ class ProductImporter end def updates_count_per_supplier(supplier_id) - if @reset_counts[supplier_id] and @reset_counts[supplier_id][:updates_count] + if @reset_counts[supplier_id] \ + and @reset_counts[supplier_id][:updates_count] @reset_counts[supplier_id][:updates_count] += 1 else @reset_counts[supplier_id] = {updates_count: 1} @@ -637,17 +645,17 @@ class ProductImporter end def check_on_hand_nil(entry, object) - if entry.on_hand.blank? - 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 + return unless entry.on_hand.blank? + + 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 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 + return unless @file.path == Rails.root.join('tmp', 'product_import').to_s + + File.delete(@file) end end diff --git a/app/views/admin/product_import/save.html.haml b/app/views/admin/product_import/save.html.haml index 1178617901..f60ada34ed 100644 --- a/app/views/admin/product_import/save.html.haml +++ b/app/views/admin/product_import/save.html.haml @@ -46,10 +46,18 @@ - if @importer.errors.count == 0 %p All #{@importer.total_saved_count} items saved successfully - else + %p #{@importer.total_saved_count} items saved successfully + %br %h5 Save errors - @importer.errors.full_messages.each do |error| %p.save-error  -  #{error} %br + - if @importer.total_saved_count > 0 + - if @import_into == 'inventories' + %a.button{href: main_app.admin_inventory_path} View Inventory + - else + %a.button{href: bulk_edit_admin_products_path + '?latest_import=true'} View Products + %a.button{href: main_app.admin_product_import_path} Back diff --git a/app/views/admin/variant_overrides/_filters.html.haml b/app/views/admin/variant_overrides/_filters.html.haml index b7a1c948ba..4126be9317 100644 --- a/app/views/admin/variant_overrides/_filters.html.haml +++ b/app/views/admin/variant_overrides/_filters.html.haml @@ -1,17 +1,22 @@ .filters.sixteen.columns.alpha.omega .filter.four.columns.alpha - %label{ :for => 'query', ng: {class: '{disabled: !hub_id}'} }=t('admin.quick_search') + %label{for: 'query', ng: {class: '{disabled: !hub_id}'} }=t('admin.quick_search') %br - %input.fullwidth{ :type => "text", :id => 'query', ng: { model: 'query', disabled: '!hub_id'} } - .two.columns   - .filter_select.four.columns - %label{ :for => 'hub_id', ng: { bind: "hub_id ? '#{t('admin.shop')}' : '#{t('admin.variant_overrides.index.select_a_shop')}'" } } + %input.fullwidth{type: "text", id: 'query', ng: {model: 'query', disabled: '!hub_id'} } + .one.columns   + .filter_select.three.columns + %label{for: 'hub_id', ng: {bind: "hub_id ? '#{t('admin.shop')}' : '#{t('admin.variant_overrides.index.select_a_shop')}'" } } %br - %select.select2.fullwidth#hub_id{ 'ng-model' => 'hub_id', name: 'hub_id', ng: { options: 'hub.id as hub.name for (id, hub) in hubs' } } - .filter_select.four.columns - %label{ :for => 'producer_filter', ng: {class: '{disabled: !hub_id}'} }=t('admin.producer') + %select.select2.fullwidth#hub_id{name: 'hub_id', ng: {model: 'hub_id', options: 'hub.id as hub.name for (id, hub) in hubs' } } + .filter_select.three.columns + %label{for: 'producer_filter', ng: {class: '{disabled: !hub_id}'} }=t('admin.producer') %br - %input.ofn-select2.fullwidth{ :id => 'producer_filter', type: 'number', data: 'producers', blank: "{id: 0, name: '#{t(:all)}'}", ng: { model: 'producerFilter', disabled: '!hub_id' } } + %input.ofn-select2.fullwidth{id: 'producer_filter', type: 'number', data: 'producers', blank: "{id: 0, name: '#{t(:all)}'}", ng: {model: 'producerFilter', disabled: '!hub_id' } } + .filter_select.three.columns + %label{ :for => 'import_date_filter', ng: {class: '{disabled: !hub_id}'} } #{t('admin.variant_overrides.index.import_date')} + %br + %select.fullwidth{id: 'import_date_filter', 'ofn-select2-min-search' => 5, ng: {model: 'importDateFilter', options: 'date.id as date.name for date in import_dates', disabled: '!hub_id', init: "import_dates = #{@import_dates}"} } + %options{value: '0', selected: 'selected'} #{t(:all)} -# .filter_select{ :class => "three columns" } -# %label{ :for => 'distributor_filter' }Hub -# %br diff --git a/app/views/admin/variant_overrides/_products.html.haml b/app/views/admin/variant_overrides/_products.html.haml index 73848c5b6c..1acbfd2924 100644 --- a/app/views/admin/variant_overrides/_products.html.haml +++ b/app/views/admin/variant_overrides/_products.html.haml @@ -27,6 +27,6 @@ %th.tags{ ng: { show: 'columns.tags.visible' } }=t('admin.tags') %th.visibility{ ng: { show: 'columns.visibility.visible' } }=t('admin.variant_overrides.index.hide') %th.import_date{ ng: { show: 'columns.import_date.visible' } }=t('admin.variant_overrides.index.import_date') - %tbody{ ng: {repeat: 'product in filteredProducts = (products | hubPermissions:hubPermissions:hub_id | inventoryProducts:hub_id:views | attrFilter:{producer_id:producerFilter} | filter:query) | limitTo:productLimit' } } + %tbody{ ng: {repeat: 'product in filteredProducts = (products | hubPermissions:hubPermissions:hub_id | inventoryProducts:hub_id:views | attrFilter:{producer_id:producerFilter} | importDate:hub_id:importDateFilter | filter:query) | limitTo:productLimit' } } = render 'admin/variant_overrides/products_product' = render 'admin/variant_overrides/products_variants' diff --git a/app/views/spree/admin/products/bulk_edit/_filters.html.haml b/app/views/spree/admin/products/bulk_edit/_filters.html.haml index 3bbed52e05..72458b3391 100644 --- a/app/views/spree/admin/products/bulk_edit/_filters.html.haml +++ b/app/views/spree/admin/products/bulk_edit/_filters.html.haml @@ -1,23 +1,25 @@ .filters.sixteen.columns.alpha.omega .quick_search.three.columns.alpha - %label{ :for => 'quick_filter' } + %label{ for: 'quick_filter' } %br - %input.quick-search.fullwidth{ 'ng-model' => 'query', :name => "quick_filter", :type => 'text', 'placeholder' => t('admin.quick_search') } + %input.quick-search.fullwidth{ ng: {model: 'query'}, name: "quick_filter", type: 'text', placeholder: t('admin.quick_search') } + .one.columns   .filter_select.three.columns - %label{ :for => 'producer_filter' }= t 'producer' + %label{ for: 'producer_filter' }= t 'producer' %br - %select.fullwidth{ :id => 'producer_filter', 'ofn-select2-min-search' => 5, 'ng-model' => 'producerFilter', 'ng-options' => 'producer.id as producer.name for producer in filterProducers' } + %select.fullwidth{ id: 'producer_filter', 'ofn-select2-min-search' => 5, ng: {model: 'producerFilter', options: 'producer.id as producer.name for producer in filterProducers'} } .filter_select.three.columns - %label{ :for => 'category_filter' }= t 'category' + %label{ for: 'category_filter' }= t 'category' %br - %select.fullwidth{ :id => 'category_filter', 'ofn-select2-min-search' => 5, 'ng-model' => 'categoryFilter', 'ng-options' => 'taxon.id as taxon.name for taxon in filterTaxons'} + %select.fullwidth{ id: 'category_filter', 'ofn-select2-min-search' => 5, ng: {model: 'categoryFilter', options: 'taxon.id as taxon.name for taxon in filterTaxons'} } .filter_select.three.columns - %label{ :for => 'import_filter' } Import Date + %label{ for: 'import_filter' } Import Date %br - %select.fullwidth{ :id => 'import_date_filter', 'ofn-select2-min-search' => 5, 'ng-model' => 'importDateFilter', 'ng-init' => "import_dates = #{@import_dates}", 'ng-options' => 'import.id as import.name for import in import_dates'} + %select.fullwidth{ id: 'import_date_filter', 'ofn-select2-min-search' => 5, ng: {model: 'importDateFilter', init: "importDates = #{@import_dates}; showLatestImport = #{@show_latest_import}"}} + %option{value: "{{date.id}}", ng: {repeat: "date in importDates track by date.id" }} + {{date.name}} - %div{ :class => "one column" }   .filter_clear.three.columns.omega - %label{ :for => 'clear_all_filters' } + %label{ for: 'clear_all_filters' } %br %input.fullwidth.red{ :type => 'button', :id => 'clear_all_filters', :value => t('admin.clear_filters'), 'ng-click' => "resetSelectFilters()" } diff --git a/spec/features/admin/product_import_spec.rb b/spec/features/admin/product_import_spec.rb index 331b18c78b..401abd872c 100644 --- a/spec/features/admin/product_import_spec.rb +++ b/spec/features/admin/product_import_spec.rb @@ -31,7 +31,7 @@ feature "Product Import", js: true do before { quick_login_as_admin } after { File.delete('/tmp/test.csv') } - it "validates entries and saves them if they are all valid" do + it "validates entries and saves them if they are all valid and allows viewing new items in Bulk Products" do csv_data = CSV.generate do |csv| csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] @@ -54,11 +54,19 @@ feature "Product Import", js: true do expect(page).to have_selector '.created-count', text: '2' expect(page).to_not have_selector '.updated-count' + carrots = Spree::Product.find_by_name('Carrots') potatoes = Spree::Product.find_by_name('Potatoes') potatoes.supplier.should == enterprise potatoes.on_hand.should == 6 potatoes.price.should == 6.50 potatoes.variants.first.import_date.should be_within(1.minute).of DateTime.now + + click_link 'View Products' + + expect(page).to have_content 'Bulk Edit Products' + wait_until { page.find("#p_#{potatoes.id}").present? } + expect(page).to have_field "product_name", with: carrots.name + expect(page).to have_field "product_name", with: potatoes.name end it "displays info about invalid entries but still allows saving of valid entries" do @@ -195,7 +203,7 @@ feature "Product Import", js: true do carrots = Spree::Product.find_by_name('Carrots') carrots.variants.first.import_date.should be_within(1.minute).of DateTime.now - visit 'admin/products/bulk_edit' + click_link 'View Products' wait_until { page.find("#p_#{carrots.id}").present? } @@ -246,7 +254,6 @@ feature "Product Import", js: true do expect(page).to have_selector '.inv-created-count', text: '2' expect(page).to have_selector '.inv-updated-count', text: '1' - beans_override = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first sprouts_override = VariantOverride.where(variant_id: product3.variants.first.id, hub_id: enterprise2.id).first cabbage_override = VariantOverride.where(variant_id: product4.variants.first.id, hub_id: enterprise2.id).first @@ -259,6 +266,17 @@ feature "Product Import", js: true do Float(cabbage_override.price).should == 1.50 cabbage_override.count_on_hand.should == 2001 + + click_link 'View Inventory' + expect(page).to have_content 'Inventory' + + select enterprise2.name, from: "hub_id", visible: false + + within '#variant-overrides' do + expect(page).to have_content 'Beans' + expect(page).to have_content 'Sprouts' + expect(page).to have_content 'Cabbage' + end end end @@ -349,8 +367,6 @@ feature "Product Import", js: true do expect(page).to_not have_selector '.invalid-count' expect(page).to have_selector '.inv-create-count', text: "1" - #expect(page.body).to have_content 'you do not have permission' - click_button 'Save' expect(page).to have_selector '.inv-created-count', text: '1' @@ -379,8 +395,7 @@ feature "Product Import", js: true do expect(page).to_not have_selector '.inv-create-count' expect(page.body).to have_content 'you do not have permission' - - + expect(page).to_not have_selector 'input[type=submit][value="Save"]' end end From e76a818fde46a5f6de3422d2b904dd7c5ed02e7b Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Fri, 17 Mar 2017 22:10:09 +0000 Subject: [PATCH 103/206] Update PI spec --- spec/models/product_importer_spec.rb | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/spec/models/product_importer_spec.rb b/spec/models/product_importer_spec.rb index e51bf91cea..5d8aef2ce5 100644 --- a/spec/models/product_importer_spec.rb +++ b/spec/models/product_importer_spec.rb @@ -22,12 +22,9 @@ describe ProductImporter do File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') - importer = ProductImporter.new(file, permissions.editable_enterprises) + importer = ProductImporter.new(file, admin) - expect(importer.valid_count).to eq(2) - expect(importer.invalid_count).to eq(0) + expect(importer.item_count).to eq(2) end end - - # Test handling of filetypes end From 6e2de0d6ac01d49598de231d5ca6d48e3f671fb9 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Sat, 18 Mar 2017 00:12:42 +0000 Subject: [PATCH 104/206] PI refactoring --- app/models/spree/product_decorator.rb | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/models/spree/product_decorator.rb b/app/models/spree/product_decorator.rb index be6f8b4a1b..b9d45c4039 100644 --- a/app/models/spree/product_decorator.rb +++ b/app/models/spree/product_decorator.rb @@ -174,13 +174,12 @@ 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 - # Get the most recent import_date of a product's variants - imports = [] - variants.each do |v| - imports.append(v) unless v.import_date.blank? - end - imports.sort_by(&:import_date).last.try(: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 From 684b493fb301e410d74510473362a3feed6d75cf Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Sat, 18 Mar 2017 13:48:45 +0000 Subject: [PATCH 105/206] PI translations --- app/models/product_importer.rb | 20 +++--- .../product_import/_entries_table.html.haml | 2 +- .../product_import/_errors_list.html.haml | 2 +- .../product_import/_import_options.html.haml | 10 +-- .../product_import/_import_review.html.haml | 18 ++--- .../_inventory_options_form.html.haml | 6 +- .../product_import/_options_form.html.haml | 35 ---------- .../_product_options_form.html.haml | 18 ++--- .../product_import/_upload_form.html.haml | 10 +-- .../admin/product_import/import.html.haml | 20 +++--- .../admin/product_import/index.html.haml | 4 +- app/views/admin/product_import/save.html.haml | 30 ++++---- config/locales/en.yml | 69 +++++++++++++++++++ config/locales/en_GB.yml | 68 ++++++++++++++++++ 14 files changed, 207 insertions(+), 105 deletions(-) delete mode 100644 app/views/admin/product_import/_options_form.html.haml diff --git a/app/models/product_importer.rb b/app/models/product_importer.rb index 549ef875cc..28ad0ffc29 100644 --- a/app/models/product_importer.rb +++ b/app/models/product_importer.rb @@ -220,7 +220,7 @@ class ProductImporter 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: 'did not match any products in the database') + mark_as_invalid(entry, attribute: 'name', error: I18n.t('admin.product_import.model.no_product')) return end @@ -232,7 +232,7 @@ class ProductImporter end end - mark_as_invalid(entry, attribute: 'product', error: 'not found in database') + mark_as_invalid(entry, attribute: 'product', error: I18n.t('admin.product_import.model.not_found')) end def create_inventory_item(entry, existing_variant) @@ -317,17 +317,17 @@ class ProductImporter producer_name = entry.producer if producer_name.blank? - mark_as_invalid(entry, attribute: "producer", error: "can't be blank") + mark_as_invalid(entry, attribute: "producer", error: I18n.t('admin.product_import.model.blank')) return end unless producer_exists?(producer_name) - mark_as_invalid(entry, attribute: "producer", error: "\"#{producer_name}\" not found in database") + 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, @producers_index[producer_name]) - mark_as_invalid(entry, attribute: "producer", error: "\"#{producer_name}\": you do not have permission to create inventory for this producer") + mark_as_invalid(entry, attribute: "producer", error: "\"#{producer_name}\": #{I18n.t('admin.product_import.model.inventory_no_permission')}") return end @@ -439,7 +439,7 @@ class ProductImporter @products_created += 1 @updated_ids.push product.variants.first.id else - self.errors.add("Line #{line_number}:", product.errors.full_messages) + self.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} @@ -476,7 +476,7 @@ class ProductImporter @inventory_created += 1 @updated_ids.push new_item.id else - self.errors.add("Line #{line_number}:", new_item.errors.full_messages) + self.errors.add("#{I18n.t('admin.product_import.model.line')} #{line_number}:", new_item.errors.full_messages) end end @@ -490,7 +490,7 @@ class ProductImporter @inventory_updated += 1 @updated_ids.push existing_item.id else - self.errors.add("Line #{line_number}:", existing_item.errors.full_messages) + self.errors.add("#{I18n.t('admin.product_import.model.line')} #{line_number}:", existing_item.errors.full_messages) end end @@ -503,7 +503,7 @@ class ProductImporter @variants_created += 1 @updated_ids.push new_variant.id else - self.errors.add("Line #{line_number}:", new_variant.errors.full_messages) + self.errors.add("#{I18n.t('admin.product_import.model.line')} #{line_number}:", new_variant.errors.full_messages) end end @@ -516,7 +516,7 @@ class ProductImporter @variants_updated += 1 @updated_ids.push variant.id else - self.errors.add("Line #{line_number}:", variant.errors.full_messages) + self.errors.add("#{I18n.t('admin.product_import.model.line')} #{line_number}:", variant.errors.full_messages) end end diff --git a/app/views/admin/product_import/_entries_table.html.haml b/app/views/admin/product_import/_entries_table.html.haml index 93d75f3fec..425cd04159 100644 --- a/app/views/admin/product_import/_entries_table.html.haml +++ b/app/views/admin/product_import/_entries_table.html.haml @@ -2,7 +2,7 @@ %table %thead %th - %th Line + %th #{t('admin.product_import.import.line')} - @importer.table_headings.each do |heading| %th= heading %tr{ng: {repeat: "(line_number, entry ) in entries | entriesFilterValid:'#{filter}' "}} diff --git a/app/views/admin/product_import/_errors_list.html.haml b/app/views/admin/product_import/_errors_list.html.haml index 8d23a526f1..ab907344d8 100644 --- a/app/views/admin/product_import/_errors_list.html.haml +++ b/app/views/admin/product_import/_errors_list.html.haml @@ -1,7 +1,7 @@ %div.import-errors{ng: {controller: 'ImportFeedbackCtrl', repeat: "(line_number, entry ) in entries | entriesFilterValid:'invalid' "}} %p.line %strong - Item line {{line_number}}: + #{t('admin.product_import.import.item_line')} {{line_number}}: %span {{entry.attributes.name}} %span{ng: {if: "entry.attributes.display_name"}} ( {{entry.attributes.display_name}} ) diff --git a/app/views/admin/product_import/_import_options.html.haml b/app/views/admin/product_import/_import_options.html.haml index 779ca4ee74..0ee8f44562 100644 --- a/app/views/admin/product_import/_import_options.html.haml +++ b/app/views/admin/product_import/_import_options.html.haml @@ -1,4 +1,4 @@ -%h5 Import options and defaults +%h5 #{t('admin.product_import.import.options_and_defaults')} %br - @importer.suppliers_index.each do |name, supplier_id| @@ -22,7 +22,7 @@ %i.fa.fa-warning %div.header-description = name - %span.header-error= " - you do not have permission to manage this enterprise" + %span.header-error= " - #{t('admin.product_import.import.no_permission')}" - elsif name %div.panel-section.import-settings{ng: {controller: 'DropdownPanelsCtrl'}} %div.panel-header @@ -31,7 +31,7 @@ %i.fa.fa-warning %div.header-description = name - %span.header-error= " - enterprise could not be found in database" + %span.header-error= " - #{t('admin.product_import.import.not_found')}" - else %div.panel-section.import-settings{ng: {controller: 'DropdownPanelsCtrl'}} %div.panel-header @@ -39,8 +39,8 @@ %div.header-icon.error %i.fa.fa-warning %div.header-description - No name - %span.header-error= " - some products have blank supplier name" + #{t('admin.product_import.import.no_name')} + %span.header-error= " - #{t('admin.product_import.import.blank_supplier')}" %br.panels.clearfix %br diff --git a/app/views/admin/product_import/_import_review.html.haml b/app/views/admin/product_import/_import_review.html.haml index 057bd5a0de..18f2238056 100644 --- a/app/views/admin/product_import/_import_review.html.haml +++ b/app/views/admin/product_import/_import_review.html.haml @@ -1,4 +1,4 @@ -%h5 Import validation overview +%h5 #{t('admin.product_import.import.validation_overview')} %br %div{ng: {controller: 'ImportFeedbackCtrl'}} @@ -13,7 +13,7 @@ %strong.item-count {{all.count}} %div.header-description - Entries found in imported file + #{t('admin.product_import.import.entries_found')} %div.panel-content{ng: {hide: '!active || all.count == 0'}} = render 'entries_table', entries: @importer.all_entries, filter: 'all' @@ -27,7 +27,7 @@ %strong.invalid-count {{invalid.count}} %div.header-description - Items contain errors and will not be imported + #{t('admin.product_import.import.entries_with_errors')} %div.panel-content{ng: {hide: '!active || invalid.count == 0'}} = render 'errors_list' %br @@ -43,7 +43,7 @@ %strong.create-count {{create_product.count}} %div.header-description - Products will be created + #{t('admin.product_import.import.products_to_create')} %div.panel-content{ng: {hide: '!active || create_product.count == 0'}} = render 'entries_table', entries: @importer.all_entries, filter: 'create_product' @@ -57,7 +57,7 @@ %strong.update-count {{update_product.count}} %div.header-description - Products will be updated + #{t('admin.product_import.import.products_to_update')} %div.panel-content{ng: {hide: '!active || update_product.count == 0'}} = render 'entries_table', entries: @importer.all_entries, filter: 'update_product' @@ -71,7 +71,7 @@ %strong.inv-create-count {{create_inventory.count}} %div.header-description - Inventory items will be created + #{t('admin.product_import.import.inventory_to_create')} %div.panel-content{ng: {hide: '!active || create_inventory.count == 0'}} = render 'entries_table', entries: @importer.all_entries, filter: 'create_inventory' @@ -85,7 +85,7 @@ %strong.inv-update-count {{update_inventory.count}} %div.header-description - Inventory items will be updated + #{t('admin.product_import.import.inventory_to_update')} %div.panel-content{ng: {hide: '!active || update_inventory.count == 0'}} = render 'entries_table', entries: @importer.all_entries, filter: 'update_inventory' @@ -99,9 +99,9 @@ {{resetTotal}} %div.header-description -if @import_into == 'inventories' - Existing inventory items will have their stock reset to zero + #{t('admin.product_import.import.inventory_to_reset')} - else - Existing products will have their stock reset to zero + #{t('admin.product_import.import.products_to_reset')} -#%div.panel-content{ng: {hide: '!active'}} %br.panels.clearfix \ No newline at end of file diff --git a/app/views/admin/product_import/_inventory_options_form.html.haml b/app/views/admin/product_import/_inventory_options_form.html.haml index 1de29a6d02..087dd5f54d 100644 --- a/app/views/admin/product_import/_inventory_options_form.html.haml +++ b/app/views/admin/product_import/_inventory_options_form.html.haml @@ -1,16 +1,16 @@ %table.import-settings{ng: {controller: 'ImportOptionsFormCtrl', init: "supplierId = #{supplier_id}; resetCount = #{@importer.reset_counts[supplier_id][:reset_count]}"}} %tr %td.description - Remove absent products? + #{t('admin.product_import.import.reset_absent?')} %td = check_box_tag "settings[#{supplier_id}][reset_all_absent]", 1, false, :'ng-model' => 'resetAbsent', :'ng-change' => 'toggleResetAbsent()' %td %tr %td.description - Set default stock level + #{t('admin.product_import.import.default_stock')} %td = check_box_tag "settings[#{supplier_id}][defaults][count_on_hand][active]", 1, false, :'ng-model' => "count_on_hand_#{supplier_id}" %td - = select_tag "settings[#{supplier_id}][defaults][count_on_hand][mode]", options_for_select({"Overwrite all" => :overwrite_all, "Overwrite if empty" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!count_on_hand_#{supplier_id}"} + = select_tag "settings[#{supplier_id}][defaults][count_on_hand][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!count_on_hand_#{supplier_id}"} %td = number_field_tag "settings[#{supplier_id}][defaults][count_on_hand][value]", 0, 'ng-disabled' => "!count_on_hand_#{supplier_id}" \ No newline at end of file diff --git a/app/views/admin/product_import/_options_form.html.haml b/app/views/admin/product_import/_options_form.html.haml deleted file mode 100644 index da0e2df055..0000000000 --- a/app/views/admin/product_import/_options_form.html.haml +++ /dev/null @@ -1,35 +0,0 @@ -%table.import-settings{ng: {controller: 'ImportOptionsFormCtrl', init: "supplierId = #{supplier_id}; resetCount = #{@importer.products_to_reset[supplier_id][:reset_count]}"}} - %tr - %td.description - Remove absent products? - %td - = check_box_tag "settings[#{supplier_id}][reset_all_absent]", 1, false, :'ng-model' => 'resetAbsent', :'ng-change' => 'toggleResetAbsent()' - %td - %tr - %td.description - Set default stock level - %td - = select_tag "settings[#{supplier_id}][defaults][on_hand][mode]", options_for_select({"Don't overwrite" => :overwrite_none, "Overwrite all" => :overwrite_all, "Overwrite if empty" => :overwrite_empty}), {class: 'select2 fullwidth'} - %td - = number_field_tag "settings[#{supplier_id}][defaults][on_hand][value]", 0 - %tr - %td.description - Set default tax category - %td - = select_tag "settings[#{supplier_id}][defaults][tax_category_id][mode]", options_for_select({"Don't overwrite" => :overwrite_none, "Overwrite all" => :overwrite_all, "Overwrite if empty" => :overwrite_empty}), {class: 'select2 fullwidth'} - %td - = select_tag "settings[#{supplier_id}][defaults][tax_category_id][value]", options_for_select(@tax_categories.map {|tc| [tc.name, tc.id]}), {prompt: 'None', class: 'select2 fullwidth'} - %tr - %td.description - Set default shipping category - %td - = select_tag "settings[#{supplier_id}][defaults][shipping_category_id][mode]", options_for_select({"Don't overwrite" => :overwrite_none, "Overwrite all" => :overwrite_all, "Overwrite if empty" => :overwrite_empty}), {class: 'select2 fullwidth'} - %td - = select_tag "settings[#{supplier_id}][defaults][shipping_category_id][value]", options_for_select(@shipping_categories.map {|sc| [sc.name, sc.id]}), {prompt: 'None', class: 'select2 fullwidth'} - %tr - %td.description - Set default available date - %td - = select_tag "settings[#{supplier_id}][defaults][available_on][mode]", options_for_select({"Don't overwrite" => :overwrite_none, "Overwrite all" => :overwrite_all, "Overwrite if empty" => :overwrite_empty}), {class: 'select2 fullwidth'} - %td - = text_field_tag "settings[#{supplier_id}][defaults][available_on][value]", nil, {class: 'datepicker', placeholder: 'Today'} diff --git a/app/views/admin/product_import/_product_options_form.html.haml b/app/views/admin/product_import/_product_options_form.html.haml index 3958af6f01..b7d0316322 100644 --- a/app/views/admin/product_import/_product_options_form.html.haml +++ b/app/views/admin/product_import/_product_options_form.html.haml @@ -1,44 +1,44 @@ %table.import-settings{ng: {controller: 'ImportOptionsFormCtrl', init: "supplierId = #{supplier_id}; resetCount = #{@importer.reset_counts[supplier_id][:reset_count]}"}} %tr %td.description - Remove absent products? + #{t('admin.product_import.import.reset_absent?')} %td = check_box_tag "settings[#{supplier_id}][reset_all_absent]", 1, false, :'ng-model' => 'resetAbsent', :'ng-change' => 'toggleResetAbsent()' %td %td %tr %td.description - Set default stock level + #{t('admin.product_import.import.default_stock')} %td = check_box_tag "settings[#{supplier_id}][defaults][on_hand][active]", 1, false, :'ng-model' => "on_hand_#{supplier_id}" %td - = select_tag "settings[#{supplier_id}][defaults][on_hand][mode]", options_for_select({"Overwrite all" => :overwrite_all, "Overwrite if empty" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!on_hand_#{supplier_id}"} + = select_tag "settings[#{supplier_id}][defaults][on_hand][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!on_hand_#{supplier_id}"} %td = number_field_tag "settings[#{supplier_id}][defaults][on_hand][value]", 0, 'ng-disabled' => "!on_hand_#{supplier_id}" %tr %td.description - Set default tax category + #{t('admin.product_import.import.default_tax_cat')} %td = check_box_tag "settings[#{supplier_id}][defaults][tax_category_id][active]", 1, false, :'ng-model' => "tax_category_id_#{supplier_id}" %td - = select_tag "settings[#{supplier_id}][defaults][tax_category_id][mode]", options_for_select({"Overwrite all" => :overwrite_all, "Overwrite if empty" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!tax_category_id_#{supplier_id}"} + = select_tag "settings[#{supplier_id}][defaults][tax_category_id][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!tax_category_id_#{supplier_id}"} %td = select_tag "settings[#{supplier_id}][defaults][tax_category_id][value]", options_for_select(@tax_categories.map {|tc| [tc.name, tc.id]}), {prompt: 'None', class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!tax_category_id_#{supplier_id}"} %tr %td.description - Set default shipping category + #{t('admin.product_import.import.default_shipping_cat')} %td = check_box_tag "settings[#{supplier_id}][defaults][shipping_category_id][active]", 1, false, :'ng-model' => "shipping_category_id_#{supplier_id}" %td - = select_tag "settings[#{supplier_id}][defaults][shipping_category_id][mode]", options_for_select({"Overwrite all" => :overwrite_all, "Overwrite if empty" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!shipping_category_id_#{supplier_id}"} + = select_tag "settings[#{supplier_id}][defaults][shipping_category_id][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!shipping_category_id_#{supplier_id}"} %td = select_tag "settings[#{supplier_id}][defaults][shipping_category_id][value]", options_for_select(@shipping_categories.map {|sc| [sc.name, sc.id]}), {prompt: 'None', class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!shipping_category_id_#{supplier_id}"} %tr %td.description - Set default available date + #{t('admin.product_import.import.default_available_date')} %td = check_box_tag "settings[#{supplier_id}][defaults][available_on][active]", 1, false, :'ng-model' => "available_on_#{supplier_id}" %td - = select_tag "settings[#{supplier_id}][defaults][available_on][mode]", options_for_select({"Overwrite all" => :overwrite_all, "Overwrite if empty" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!available_on_#{supplier_id}"} + = select_tag "settings[#{supplier_id}][defaults][available_on][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!available_on_#{supplier_id}"} %td = text_field_tag "settings[#{supplier_id}][defaults][available_on][value]", nil, {class: 'datepicker', placeholder: 'Today', 'ng-disabled' => "!available_on_#{supplier_id}"} diff --git a/app/views/admin/product_import/_upload_form.html.haml b/app/views/admin/product_import/_upload_form.html.haml index ff246f96df..ae5360598e 100644 --- a/app/views/admin/product_import/_upload_form.html.haml +++ b/app/views/admin/product_import/_upload_form.html.haml @@ -1,17 +1,17 @@ %div{ng: {app: 'ofn.admin'}} - %h5 Select a spreadsheet to upload + %h5 #{t('admin.product_import.index.select_file')} %br = form_tag main_app.admin_product_import_path, multipart: true, class: 'product-import' do - %label Spreadsheet + %label #{t('admin.product_import.index.spreadsheet')} %br = file_field_tag :file %br %br - %label Import into: + %label #{t('admin.product_import.index.import_into')} %br - = select_tag "settings[import_into]", options_for_select({"Product List" => :product_list, "Inventories" => :inventories}), {class: 'select2 select2-no-search'} + = select_tag "settings[import_into]", options_for_select({"#{t('admin.product_import.index.product_list')}" => :product_list, "#{t('admin.product_import.index.inventories')}" => :inventories}), {class: 'select2 select2-no-search'} %br %br %br - = submit_tag "Import" + = submit_tag "#{t('admin.product_import.index.import')}" diff --git a/app/views/admin/product_import/import.html.haml b/app/views/admin/product_import/import.html.haml index dadb9b45b3..2ed7a2d6b2 100644 --- a/app/views/admin/product_import/import.html.haml +++ b/app/views/admin/product_import/import.html.haml @@ -1,13 +1,13 @@ - content_for :page_title do - Product Import + #{t('admin.product_import.title')} = render partial: 'spree/admin/shared/product_sub_menu' = form_tag main_app.admin_product_import_save_path, {class: 'product-import', 'ng-app' => 'ofn.admin'} do - if !@importer.has_valid_entries? #and @importer.invalid_count - %h5 No valid entries found - %p There are no entries that can be saved + %h5 #{t('admin.product_import.import.no_valid_entries')} + %p #{t('admin.product_import.import.none_to_save')} %br = render partial: "admin/json/injection_ams", locals: {ngModule: 'ofn.admin', name: 'productImportData', json: @importer.entries_json} @@ -19,20 +19,20 @@ %div{ng: {controller: 'ImportFeedbackCtrl', show: "count((entries | entriesFilterValid:'valid')) > 0"}} %div{ng: {if: "count((entries | entriesFilterValid:'invalid')) > 0"}} %br - %h5 Imported file contains some invalid entries - %p Save valid entries for now and discard the others? + %h5 #{t('admin.product_import.import.some_invalid_entries')} + %p #{t('admin.product_import.import.save_valid?')} %div{ng: {show: "count((entries | entriesFilterValid:'invalid')) == 0"}} %br - %h5 No errors detected! - %p Save all imported products? + %h5 #{t('admin.product_import.import.no_errors')} + %p #{t('admin.product_import.import.save_all_imported?')} %br = hidden_field_tag :filepath, @filepath = hidden_field_tag "settings[import_into]", @import_into - = submit_tag "Save" - %a.button{href: main_app.admin_product_import_path} Cancel + = submit_tag t('admin.save') + %a.button{href: main_app.admin_product_import_path} #{t('admin.cancel')} %div{ng: {controller: 'ImportFeedbackCtrl', show: "count((entries | entriesFilterValid:'valid')) == 0"}} %br - %a.button{href: main_app.admin_product_import_path} Cancel + %a.button{href: main_app.admin_product_import_path} #{t('admin.cancel')} diff --git a/app/views/admin/product_import/index.html.haml b/app/views/admin/product_import/index.html.haml index c904b6a70f..fd17684ef0 100644 --- a/app/views/admin/product_import/index.html.haml +++ b/app/views/admin/product_import/index.html.haml @@ -1,6 +1,6 @@ - content_for :page_title do - Product Import + #{t('admin.product_import.title')} -= render :partial => 'spree/admin/shared/product_sub_menu' += render partial: 'spree/admin/shared/product_sub_menu' = render 'upload_form' diff --git a/app/views/admin/product_import/save.html.haml b/app/views/admin/product_import/save.html.haml index f60ada34ed..b3f76010d2 100644 --- a/app/views/admin/product_import/save.html.haml +++ b/app/views/admin/product_import/save.html.haml @@ -1,9 +1,9 @@ - content_for :page_title do - Product Import + #{t('admin.product_import.title')} -= render :partial => 'spree/admin/shared/product_sub_menu' += render partial: 'spree/admin/shared/product_sub_menu' -%h5 Import final results +%h5 #{t('admin.product_import.save.final_results')} %br %div.post-save-results{ng: {app: 'ofn.admin'}} @@ -12,43 +12,43 @@ %p %i.fa{ng: {class: "{'fa-info-circle': #{@importer.products_created_count} == 0, 'fa-check-circle': #{@importer.products_created_count} != 0}"}} %strong.created-count= @importer.products_created_count - Products created + #{t('admin.product_import.save.products_created')} - if @importer.products_updated_count > 0 %p %i.fa{ng: {class: "{'fa-info-circle': #{@importer.products_updated_count} == 0, 'fa-check-circle': #{@importer.products_updated_count} != 0}"}} %strong.updated-count= @importer.products_updated_count - Products updated + #{t('admin.product_import.save.products_updated')} - if @importer.inventory_created_count > 0 %p %i.fa{ng: {class: "{'fa-info-circle': #{@importer.inventory_created_count} == 0, 'fa-check-circle': #{@importer.inventory_created_count} != 0}"}} %strong.inv-created-count= @importer.inventory_created_count - Inventory items updated + #{t('admin.product_import.save.inventory_created')} - if @importer.inventory_updated_count > 0 %p %i.fa{ng: {class: "{'fa-info-circle': #{@importer.inventory_updated_count} == 0, 'fa-check-circle': #{@importer.inventory_updated_count} != 0}"}} %strong.inv-updated-count= @importer.inventory_updated_count - Inventory items updated + #{t('admin.product_import.save.inventory_updated')} - if @importer.products_reset_count > 0 %p %i.fa.fa-info-circle %strong.reset-count= @importer.products_reset_count - if @import_into == 'inventories' - Inventory items had stock level reset to zero + #{t('admin.product_import.save.inventory_reset')} - else - Products had stock level reset to zero + #{t('admin.product_import.save.products_reset')} %br - if @importer.errors.count == 0 - %p All #{@importer.total_saved_count} items saved successfully + %p #{t('admin.product_import.save.all_saved', { num: "#{@importer.total_saved_count}" })} - else - %p #{@importer.total_saved_count} items saved successfully + %p #{t('admin.product_import.save.total_saved', { num: "#{@importer.total_saved_count}" })} %br - %h5 Save errors + %h5 #{t('admin.product_import.save.save_errors')} - @importer.errors.full_messages.each do |error| %p.save-error  -  #{error} @@ -56,8 +56,8 @@ %br - if @importer.total_saved_count > 0 - if @import_into == 'inventories' - %a.button{href: main_app.admin_inventory_path} View Inventory + %a.button{href: main_app.admin_inventory_path} #{t('admin.product_import.save.view_inventory')} - else - %a.button{href: bulk_edit_admin_products_path + '?latest_import=true'} View Products + %a.button{href: bulk_edit_admin_products_path + '?latest_import=true'} #{t('admin.product_import.save.view_products')} - %a.button{href: main_app.admin_product_import_path} Back + %a.button{href: main_app.admin_product_import_path} #{t('admin.back')} diff --git a/config/locales/en.yml b/config/locales/en.yml index 760d14c28d..3959453aae 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -305,6 +305,9 @@ en: form_invalid: "Form contains missing or invalid fields" clear_filters: Clear Filters clear: Clear + save: Save + cancel: Cancel + back: Back show_more: Show more show_n_more: Show %{num} more choose: "Choose..." @@ -475,6 +478,72 @@ en: group_buy_options: "Group Buy Options" back_to_products_list: "Back to products list" + product_import: + title: Product Import + file_not_found: File not found or could not be opened + no_data: No data found in spreadsheet + confirm_reset: "This will set stock level to zero on all products for this \n enterprise that are not present in the uploaded file" + model: + no_file: "error: no file uploaded" + could_not_process: "could not process file: invalid filetype" + no_product: did not match any products in the database + not_found: not found in database + blank: can't be blank + products_no_permission: you do not have permission to manage products for this enterprise + inventory_no_permission: you do not have permission to create inventory for this producer + none_saved: did not save any products successfully + line: Line + index: + select_file: Select a spreadsheet to upload + spreadsheet: Spreadsheet + import_into: "Import into:" + product_list: Product list + inventories: Inventories + import: Import + import: + no_valid_entries: No valid entries found + none_to_save: There are no entries that can be saved + some_invalid_entries: Imported file contains some invalid entries + save_valid?: Save valid entries for now and discard the others? + no_errors: No errors detected! + save_all_imported?: Save all imported products? + options_and_defaults: Import options and defaults + no_permission: you do not have permission to manage this enterprise + not_found: enterprise could not be found in database + no_name: No name + blank_supplier: some products have blank supplier name + reset_absent?: Reset absent products? + overwrite_all: Overwrite all + overwrite_empty: Overwrite if empty + default_stock: Set default stock level + default_tax_cat: Set default tax category + default_shipping_cat: Set default shipping category + default_available_date: Set default available date + validation_overview: Import validation overview + entries_found: Entries found in imported file + entries_with_errors: Items contain errors and will not be imported + products_to_create: Products will be created + products_to_update: Products will be updated + inventory_to_create: Inventory items will be created + inventory_to_update: Inventory items will be updated + products_to_reset: Existing products will have their stock reset to zero + inventory_to_reset: Existing inventory items will have their stock reset to zero + line: Line + item_line: Item line + save: + final_results: Import final results + products_created: Products created + products_updated: Products updated + inventory_created: Inventory items created + inventory_updated: Inventory items updated + products_reset: Products had stock level reset to zero + inventory_reset: Inventory items had stock level reset to zero + all_saved: "All %{num} items saved successfully" + total_saved: "%{num} items saved successfully" + save_errors: Save errors + view_products: View Products + view_inventory: View Inventory + variant_overrides: loading_flash: loading_inventory: LOADING INVENTORY diff --git a/config/locales/en_GB.yml b/config/locales/en_GB.yml index baa68f8d6d..703a303253 100644 --- a/config/locales/en_GB.yml +++ b/config/locales/en_GB.yml @@ -137,6 +137,9 @@ en_GB: form_invalid: "Form contains missing or invalid fields" clear_filters: Clear Filters clear: Clear + save: Save + cancel: Cancel + back: Back columns: Columns actions: Actions viewing: "Viewing: %{current_view_name}" @@ -279,6 +282,71 @@ en_GB: inherited_property: Inherited Property variants: to_order_tip: "Items made to order do not have a set stock level, such as loaves of bread made fresh to order." + product_import: + title: Product Import + file_not_found: File not found or could not be opened + no_data: No data found in spreadsheet + confirm_reset: "This will set stock level to zero on all products for this \n enterprise that are not present in the uploaded file" + model: + no_file: "error: no file uploaded" + could_not_process: "could not process file: invalid filetype" + no_product: did not match any products in the database + not_found: not found in database + blank: can't be blank + products_no_permission: you do not have permission to manage products for this enterprise + inventory_no_permission: you do not have permission to create inventory for this producer + none_saved: did not save any products successfully + line: Line + index: + select_file: Select a spreadsheet to upload + spreadsheet: Spreadsheet + import_into: "Import into:" + product_list: Product list + inventories: Inventories + import: Import + import: + no_valid_entries: No valid entries found + none_to_save: There are no entries that can be saved + some_invalid_entries: Imported file contains some invalid entries + save_valid?: Save valid entries for now and discard the others? + no_errors: No errors detected! + save_all_imported?: Save all imported products? + options_and_defaults: Import options and defaults + no_permission: you do not have permission to manage this enterprise + not_found: enterprise could not be found in database + no_name: No name + blank_supplier: some products have blank supplier name + reset_absent?: Reset absent products? + overwrite_all: Overwrite all + overwrite_empty: Overwrite if empty + default_stock: Set default stock level + default_tax_cat: Set default tax category + default_shipping_cat: Set default shipping category + default_available_date: Set default available date + validation_overview: Import validation overview + entries_found: Entries found in imported file + entries_with_errors: Items contain errors and will not be imported + products_to_create: Products will be created + products_to_update: Products will be updated + inventory_to_create: Inventory items will be created + inventory_to_update: Inventory items will be updated + products_to_reset: Existing products will have their stock reset to zero + inventory_to_reset: Existing inventory items will have their stock reset to zero + line: Line + item_line: Item line + save: + final_results: Import final results + products_created: Products created + products_updated: Products updated + inventory_created: Inventory items created + inventory_updated: Inventory items updated + products_reset: Products had stock level reset to zero + inventory_reset: Inventory items had stock level reset to zero + all_saved: "All %{num} items saved successfully" + total_saved: "%{num} items saved successfully" + save_errors: Save errors + view_products: View Products + view_inventory: View Inventory variant_overrides: loading_flash: loading_inventory: LOADING INVENTORY From 3a650dd8b3260fecfff96762ec75296557e6c24d Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Tue, 11 Apr 2017 23:17:01 +0100 Subject: [PATCH 106/206] Add roo-xls gem for Excel support --- Gemfile | 1 + Gemfile.lock | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/Gemfile b/Gemfile index 3c8451f8fc..9a2811f88b 100644 --- a/Gemfile +++ b/Gemfile @@ -74,6 +74,7 @@ gem 'wkhtmltopdf-binary' gem 'foreigner' gem 'immigrant' gem 'roo', '~> 2.7.0' +gem 'roo-xls' gem 'whenever', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 3bd3cb2763..f92e6a837d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -596,6 +596,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) @@ -626,6 +630,7 @@ GEM rainbow (>= 2.2.2, < 4.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.1) safe_yaml (1.0.4) @@ -643,6 +648,8 @@ GEM activesupport (>= 3.0.0) spinjs-rails (1.3) rails (>= 3.1) + spreadsheet (1.1.4) + ruby-ole (>= 1.0) sprockets (2.2.3) hike (~> 1.2) multi_json (~> 1.0) @@ -775,6 +782,7 @@ DEPENDENCIES representative_view roadie-rails (~> 1.0.3) roo (~> 2.7.0) + roo-xls rspec-rails (>= 3.5.2) rspec-retry rubocop (>= 0.49.1) From b3c906b3a478fa665c987cb991c55568a5e03f3e Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Fri, 21 Apr 2017 20:11:12 +0100 Subject: [PATCH 107/206] Product Import v3 with asynchronous processing Fixed spec Quick spec tweak --- .../controllers/import_feedback.js.coffee | 5 +- .../import_form_controller.js.coffee | 159 ++++++ .../controllers/import_options_form.js.coffee | 31 +- .../services/product_import_service.js.coffee | 6 + .../stylesheets/admin/product_import.css.scss | 59 +- .../admin/product_import_controller.rb | 54 +- .../admin/variant_overrides_controller.rb | 4 +- .../admin/products_controller_decorator.rb | 4 +- app/models/product_importer.rb | 91 ++- app/models/spree/ability_decorator.rb | 2 +- .../product_import/_entries_table.html.haml | 2 +- .../product_import/_errors_list.html.haml | 2 +- .../product_import/_import_review.html.haml | 72 +-- .../_inventory_options_form.html.haml | 13 +- .../_product_options_form.html.haml | 28 +- .../product_import/_save_results.html.haml | 60 ++ .../product_import/_upload_form.html.haml | 2 +- .../admin/product_import/import.html.haml | 68 ++- config/locales/en.yml | 10 +- config/routes.rb | 5 +- spec/features/admin/product_import_spec.rb | 396 ++----------- spec/models/product_importer_spec.rb | 525 +++++++++++++++++- 22 files changed, 1124 insertions(+), 474 deletions(-) create mode 100644 app/assets/javascripts/admin/product_import/controllers/import_form_controller.js.coffee create mode 100644 app/views/admin/product_import/_save_results.html.haml diff --git a/app/assets/javascripts/admin/product_import/controllers/import_feedback.js.coffee b/app/assets/javascripts/admin/product_import/controllers/import_feedback.js.coffee index 3c9891dec0..990d3598bf 100644 --- a/app/assets/javascripts/admin/product_import/controllers/import_feedback.js.coffee +++ b/app/assets/javascripts/admin/product_import/controllers/import_feedback.js.coffee @@ -1,5 +1,4 @@ -angular.module("ofn.admin").controller "ImportFeedbackCtrl", ($scope, productImportData) -> - $scope.entries = productImportData +angular.module("ofn.admin").controller "ImportFeedbackCtrl", ($scope) -> $scope.count = (items) -> total = 0 @@ -8,4 +7,4 @@ angular.module("ofn.admin").controller "ImportFeedbackCtrl", ($scope, productImp total $scope.attribute_invalid = (attribute, line_number) -> - $scope.entries[line_number]['errors'][attribute] != undefined \ No newline at end of file + $scope.entries[line_number]['errors'][attribute] != undefined diff --git a/app/assets/javascripts/admin/product_import/controllers/import_form_controller.js.coffee b/app/assets/javascripts/admin/product_import/controllers/import_form_controller.js.coffee new file mode 100644 index 0000000000..c2de32cac3 --- /dev/null +++ b/app/assets/javascripts/admin/product_import/controllers/import_form_controller.js.coffee @@ -0,0 +1,159 @@ +angular.module("ofn.admin").controller "ImportFormCtrl", ($scope, $http, $filter, ProductImportService, $timeout) -> + + $scope.entries = {} + $scope.update_counts = {} + $scope.reset_counts = {} + + #$scope.import_options = {} + + $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 = '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) -> + $http( + url: $scope.import_url + method: 'POST' + data: + 'start': start + 'end': end + 'filepath': $scope.filepath + 'import_into': $scope.import_into + ).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) -> + console.log('Error: '+status) + ) + + $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 + 'import_into': $scope.import_into, + '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) -> + console.log('Error: '+status) + ) + + $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 + 'import_into': $scope.import_into, + '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.log('Error: '+status) + ) + + $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' diff --git a/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee b/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee index 21c08b9ae1..41a1c318fa 100644 --- a/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee +++ b/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee @@ -1,12 +1,31 @@ angular.module("ofn.admin").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] = { + 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' + } - if confirmed or !$scope.resetAbsent - ProductImportService.updateResetAbsent($scope.supplierId, $scope.resetCount, $scope.resetAbsent) - else - $scope.resetAbsent = false + $scope.$watch 'settings', (updated) -> + ProductImportService.updateSettings(updated) + , true + + $scope.toggleResetAbsent = (id) -> + resetAbsent = $scope.settings[id]['reset_all_absent'] + confirmed = confirm t('js.product_import.confirmation') if resetAbsent + + if confirmed or !resetAbsent + ProductImportService.updateResetAbsent($scope.supplierId, $scope.reset_counts[$scope.supplierId], resetAbsent) $scope.resetTotal = ProductImportService.resetTotal diff --git a/app/assets/javascripts/admin/product_import/services/product_import_service.js.coffee b/app/assets/javascripts/admin/product_import/services/product_import_service.js.coffee index 330e7b6cad..51b57c8735 100644 --- a/app/assets/javascripts/admin/product_import/services/product_import_service.js.coffee +++ b/app/assets/javascripts/admin/product_import/services/product_import_service.js.coffee @@ -2,6 +2,7 @@ angular.module("ofn.admin").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 \ No newline at end of file diff --git a/app/assets/stylesheets/admin/product_import.css.scss b/app/assets/stylesheets/admin/product_import.css.scss index ae30cac119..ebd58fc468 100644 --- a/app/assets/stylesheets/admin/product_import.css.scss +++ b/app/assets/stylesheets/admin/product_import.css.scss @@ -238,7 +238,7 @@ table.import-settings { } } -form.product-import, div.post-save-results { +form.product-import, div.post-save-results, div.import-wrapper { input[type="submit"] { margin-right: 0.5em; } @@ -246,4 +246,59 @@ form.product-import, div.post-save-results { min-width: 8em; text-align: center; } -} \ No newline at end of file +} + +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 { + + } + + 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; + } +} diff --git a/app/controllers/admin/product_import_controller.rb b/app/controllers/admin/product_import_controller.rb index b808875aff..08868cbaaf 100644 --- a/app/controllers/admin/product_import_controller.rb +++ b/app/controllers/admin/product_import_controller.rb @@ -2,12 +2,13 @@ require 'roo' class Admin::ProductImportController < Spree::Admin::BaseController - before_filter :validate_upload_presence, except: :index + before_filter :validate_upload_presence, except: [:index, :process_data] def import # Save uploaded file to tmp directory @filepath = save_uploaded_file(params[:file]) @importer = ProductImporter.new(File.new(@filepath), spree_current_user, params[:settings]) + @original_filename = params[:file].try(:original_filename) @import_into = params[:settings][:import_into] check_file_errors @importer @@ -17,10 +18,53 @@ class Admin::ProductImportController < Spree::Admin::BaseController @shipping_categories = Spree::ShippingCategory.order('name ASC') end - def save - @importer = ProductImporter.new(File.new(params[:filepath]), spree_current_user, params[:settings]) - @importer.save_all if @importer.has_valid_entries? - @import_into = params[:settings][:import_into] + # def save + # @importer = ProductImporter.new(File.new(params[:filepath]), spree_current_user, params[:settings]) + # @importer.save_all if @importer.has_valid_entries? + # @import_into = params[:settings][:import_into] + # end + + def process_data + @importer = ProductImporter.new(File.new(params[:filepath]), spree_current_user, {start: params[:start], end: params[:end], import_into: params[:import_into]}) + + @importer.validate_entries + + import_results = { + entries: @importer.entries_json, + reset_counts: @importer.reset_counts + } + + render json: import_results + end + + def save_data + @importer = ProductImporter.new(File.new(params[:filepath]), spree_current_user, {start: params[:start], end: params[:end], import_into: params[:import_into], settings: params[:settings]}) + + @importer.save_entries + + save_results = { + results: { + products_created: @importer.products_created_count, + products_updated: @importer.products_updated_count, + inventory_created: @importer.inventory_created_count, + inventory_updated: @importer.inventory_updated_count, + products_reset: @importer.products_reset_count, + }, + updated_ids: @importer.updated_ids, + errors: @importer.errors.full_messages + } + + render json: save_results + end + + def reset_absent_products + @importer = 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.has_key?(:enterprises_to_reset) and params.has_key?(:updated_ids) + @importer.reset_absent(params[:updated_ids]) + end + + render json: @importer.products_reset_count end private diff --git a/app/controllers/admin/variant_overrides_controller.rb b/app/controllers/admin/variant_overrides_controller.rb index cf978881a3..072a1f1eeb 100644 --- a/app/controllers/admin/variant_overrides_controller.rb +++ b/app/controllers/admin/variant_overrides_controller.rb @@ -58,8 +58,8 @@ module Admin @inventory_items = InventoryItem.where(enterprise_id: @hubs) import_dates = [{id: '0', name: 'All'}] - inventory_import_dates.map {|i| import_dates.push({id: i, name: i.to_formatted_s(:long)}) } - @import_dates = import_dates.to_json + inventory_import_dates.map {|i| import_dates.push({id: i.to_date, name: i.to_date.to_formatted_s(:long)}) } + @import_dates = import_dates.uniq.to_json end def load_collection diff --git a/app/controllers/spree/admin/products_controller_decorator.rb b/app/controllers/spree/admin/products_controller_decorator.rb index b6a71273f3..bd6257d2a0 100644 --- a/app/controllers/spree/admin/products_controller_decorator.rb +++ b/app/controllers/spree/admin/products_controller_decorator.rb @@ -102,8 +102,8 @@ Spree::Admin::ProductsController.class_eval do @producers = OpenFoodNetwork::Permissions.new(spree_current_user).managed_product_enterprises.is_primary_producer.by_name @taxons = Spree::Taxon.order(:name) import_dates = [{id: '0', name: ''}] - product_import_dates.map {|i| import_dates.push({id: i, name: i.to_formatted_s(:long)}) } - @import_dates = import_dates.to_json + product_import_dates.map {|i| import_dates.push({id: i.to_date, name: i.to_date.to_formatted_s(:long)}) } + @import_dates = import_dates.uniq.to_json end def product_import_dates diff --git a/app/models/product_importer.rb b/app/models/product_importer.rb index 28ad0ffc29..1eb178cfdd 100644 --- a/app/models/product_importer.rb +++ b/app/models/product_importer.rb @@ -5,7 +5,7 @@ class ProductImporter include ActiveModel::Conversion include ActiveModel::Validations - attr_reader :total_supplier_products + attr_reader :total_supplier_products, :supplier_products, :updated_ids def initialize(file, current_user, import_settings={}) if file.is_a?(File) @@ -33,6 +33,7 @@ class ProductImporter @inventory_permissions = {} @total_supplier_products = 0 + @supplier_products = {} @reset_counts = {} @updated_ids = [] @@ -42,16 +43,6 @@ class ProductImporter 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 persisted? false # ActiveModel end @@ -148,15 +139,55 @@ class ProductImporter @current_user.admin? or ( @inventory_permissions[supplier_id] and @inventory_permissions[supplier_id].include? producer_id ) end + def validate_entries + @entries.each do |entry| + supplier_validation(entry) + + if importing_into_inventory? + producer_validation(entry) + inventory_validation(entry) + else + category_validation(entry) + product_validation(entry) + end + end + end + + def save_entries + validate_entries + save_all_valid + end + + def reset_absent(updated_ids) + @products_created = updated_ids.count + @updated_ids = updated_ids + reset_absent_items + end + private def init_product_importer init_permissions - build_entries + if @import_settings.has_key?(:start) and @import_settings.has_key?(:end) + build_entries_in_range + else + build_entries + end build_categories_index build_suppliers_index build_producers_index if importing_into_inventory? - validate_all + #validate_all + count_existing_items unless @import_settings.has_key?(:start) + 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 @@ -184,6 +215,21 @@ class ProductImporter 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 + return if @sheet.last_row == line_number # TODO: test + end + end + def build_entries rows.each_with_index do |row, i| row_data = Hash[[headers, row].transpose] @@ -212,7 +258,7 @@ class ProductImporter end def importing_into_inventory? - @import_settings['import_into'] == 'inventories' + @import_settings[:import_into] == 'inventories' end def inventory_validation(entry) @@ -282,12 +328,7 @@ class ProductImporter count end - if @reset_counts[supplier_id] - @reset_counts[supplier_id][:existing_products] = products_count - else - @reset_counts[supplier_id] = {existing_products: products_count} - end - + @supplier_products[supplier_id] = products_count @total_supplier_products += products_count end end @@ -415,7 +456,7 @@ class ProductImporter self.errors.add(:importer, I18n.t(:product_importer_products_save_error)) if total_saved_count.zero? - reset_absent_items + reset_absent_items unless @import_settings.has_key?(:start) total_saved_count end @@ -521,10 +562,10 @@ class ProductImporter end def reset_absent_items - return if total_saved_count.zero? or @updated_ids.empty? + return if total_saved_count.zero? or @updated_ids.empty? or !@import_settings.has_key?('settings') enterprises_to_reset = [] - @import_settings.each do |enterprise_id, settings| + @import_settings['settings'].each do |enterprise_id, settings| enterprises_to_reset.push enterprise_id if settings['reset_all_absent'] and permission_by_id?(enterprise_id) end @@ -548,9 +589,9 @@ class ProductImporter end def assign_defaults(object, entry) - return unless @import_settings[entry.supplier_id.to_s] and @import_settings[entry.supplier_id.to_s]['defaults'] + return unless @import_settings.has_key?(:settings) and @import_settings[:settings][entry.supplier_id.to_s] and @import_settings[:settings][entry.supplier_id.to_s]['defaults'] - @import_settings[entry.supplier_id.to_s]['defaults'].each do |attribute, setting| + @import_settings[:settings][entry.supplier_id.to_s]['defaults'].each do |attribute, setting| next unless setting['active'] case setting['mode'] diff --git a/app/models/spree/ability_decorator.rb b/app/models/spree/ability_decorator.rb index 0c9492b4cf..b12124b067 100644 --- a/app/models/spree/ability_decorator.rb +++ b/app/models/spree/ability_decorator.rb @@ -177,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, :import, :save, :save_data, :process_data, :reset_absent_products], 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 diff --git a/app/views/admin/product_import/_entries_table.html.haml b/app/views/admin/product_import/_entries_table.html.haml index 425cd04159..4d19f19b21 100644 --- a/app/views/admin/product_import/_entries_table.html.haml +++ b/app/views/admin/product_import/_entries_table.html.haml @@ -5,7 +5,7 @@ %th #{t('admin.product_import.import.line')} - @importer.table_headings.each do |heading| %th= heading - %tr{ng: {repeat: "(line_number, entry ) in entries | entriesFilterValid:'#{filter}' "}} + %tr{ng: {repeat: "(line_number, entry) in (entries | entriesFilterValid:'#{entries}')"}} %td %i{ng: {class: "{'fa fa-warning warning': (count(entry.errors) > 0), 'fa fa-check-circle success': (count(entry.errors) == 0)}"}} %td diff --git a/app/views/admin/product_import/_errors_list.html.haml b/app/views/admin/product_import/_errors_list.html.haml index ab907344d8..260ad46196 100644 --- a/app/views/admin/product_import/_errors_list.html.haml +++ b/app/views/admin/product_import/_errors_list.html.haml @@ -1,4 +1,4 @@ -%div.import-errors{ng: {controller: 'ImportFeedbackCtrl', repeat: "(line_number, entry ) in entries | entriesFilterValid:'invalid' "}} +%div.import-errors{ng: {controller: 'ImportFeedbackCtrl', repeat: "(line_number, entry ) in (entries | entriesFilterValid:'invalid')"}} %p.line %strong #{t('admin.product_import.import.item_line')} {{line_number}}: diff --git a/app/views/admin/product_import/_import_review.html.haml b/app/views/admin/product_import/_import_review.html.haml index 18f2238056..0e65bc6135 100644 --- a/app/views/admin/product_import/_import_review.html.haml +++ b/app/views/admin/product_import/_import_review.html.haml @@ -3,91 +3,91 @@ %div{ng: {controller: 'ImportFeedbackCtrl'}} - %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "all.count = count((entries | entriesFilterValid:'all')) "}} - %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && all.count}'}} + %div.panel-section{ng: {controller: 'DropdownPanelsCtrl'}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count((entries | entriesFilterValid:"all"))}'}} %div.header-caret - %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'all.count == 0'}} + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count((entries | entriesFilterValid:"all")) == 0'}} %div.header-icon.success %i.fa.fa-info-circle.info %div.header-count %strong.item-count - {{all.count}} + {{count((entries | entriesFilterValid:"all"))}} %div.header-description #{t('admin.product_import.import.entries_found')} - %div.panel-content{ng: {hide: '!active || all.count == 0'}} - = render 'entries_table', entries: @importer.all_entries, filter: 'all' + %div.panel-content{ng: {hide: '!active || count((entries | entriesFilterValid:"all")) == 0'}} + = render 'entries_table', entries: 'all' - %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "invalid.count = count((entries | entriesFilterValid:'invalid')) ", hide: 'invalid.count == 0'}} - %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && invalid.count}'}} + %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', hide: 'count((entries | entriesFilterValid:"invalid")) == 0'}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count((entries | entriesFilterValid:"invalid"))}'}} %div.header-caret - %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'invalid.count == 0'}} + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count((entries | entriesFilterValid:"invalid")) == 0'}} %div.header-icon.warning %i.fa.fa-warning %div.header-count %strong.invalid-count - {{invalid.count}} + {{count((entries | entriesFilterValid:"invalid"))}} %div.header-description #{t('admin.product_import.import.entries_with_errors')} - %div.panel-content{ng: {hide: '!active || invalid.count == 0'}} + %div.panel-content{ng: {hide: '!active || count((entries | entriesFilterValid:"invalid")) == 0'}} = render 'errors_list' %br - = render 'entries_table', entries: @importer.all_entries, filter: 'invalid' + = render 'entries_table', entries: 'invalid' - %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "create_product.count = count((entries | entriesFilterValid:'create_product')) ", hide: 'create_product.count == 0'}} - %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && create_product.count}'}} + %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', hide: 'count((entries | entriesFilterValid:"create_product")) == 0'}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count((entries | entriesFilterValid:"create_product"))}'}} %div.header-caret - %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'create_product.count == 0'}} + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count((entries | entriesFilterValid:"create_product")) == 0'}} %div.header-icon.success %i.fa.fa-check-circle %div.header-count %strong.create-count - {{create_product.count}} + {{count((entries | entriesFilterValid:"create_product"))}} %div.header-description #{t('admin.product_import.import.products_to_create')} - %div.panel-content{ng: {hide: '!active || create_product.count == 0'}} - = render 'entries_table', entries: @importer.all_entries, filter: 'create_product' + %div.panel-content{ng: {hide: '!active || count((entries | entriesFilterValid:"create_product")) == 0'}} + = render 'entries_table', entries: 'create_product' - %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "update_product.count = count((entries | entriesFilterValid:'update_product')) ", hide: 'update_product.count == 0'}} - %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && update_product.count}'}} + %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', hide: 'count((entries | entriesFilterValid:"update_product")) == 0'}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count((entries | entriesFilterValid:"update_product"))}'}} %div.header-caret - %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'update_product.count == 0'}} + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count((entries | entriesFilterValid:"update_product")) == 0'}} %div.header-icon.success %i.fa.fa-check-circle %div.header-count %strong.update-count - {{update_product.count}} + {{count((entries | entriesFilterValid:"update_product"))}} %div.header-description #{t('admin.product_import.import.products_to_update')} - %div.panel-content{ng: {hide: '!active || update_product.count == 0'}} - = render 'entries_table', entries: @importer.all_entries, filter: 'update_product' + %div.panel-content{ng: {hide: '!active || count((entries | entriesFilterValid:"update_product")) == 0'}} + = render 'entries_table', entries: 'update_product' - %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "create_inventory.count = count((entries | entriesFilterValid:'create_inventory')) ", hide: 'create_inventory.count == 0'}} - %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && create_inventory.count}'}} + %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', hide: 'count((entries | entriesFilterValid:"create_inventory")) == 0'}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count((entries | entriesFilterValid:"create_inventory"))}'}} %div.header-caret - %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'create_inventory.count == 0'}} + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count((entries | entriesFilterValid:"create_inventory")) == 0'}} %div.header-icon.success %i.fa.fa-check-circle %div.header-count %strong.inv-create-count - {{create_inventory.count}} + {{count((entries | entriesFilterValid:"create_inventory"))}} %div.header-description #{t('admin.product_import.import.inventory_to_create')} - %div.panel-content{ng: {hide: '!active || create_inventory.count == 0'}} - = render 'entries_table', entries: @importer.all_entries, filter: 'create_inventory' + %div.panel-content{ng: {hide: '!active || count((entries | entriesFilterValid:"create_inventory")) == 0'}} + = render 'entries_table', entries: 'create_inventory' - %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "update_inventory.count = count((entries | entriesFilterValid:'update_inventory')) ", hide: 'update_inventory.count == 0'}} - %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && update_inventory.count}'}} + %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', hide: 'count((entries | entriesFilterValid:"update_inventory")) == 0'}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count((entries | entriesFilterValid:"update_inventory"))}'}} %div.header-caret - %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'update_inventory.count == 0'}} + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count((entries | entriesFilterValid:"update_inventory")) == 0'}} %div.header-icon.success %i.fa.fa-check-circle %div.header-count %strong.inv-update-count - {{update_inventory.count}} + {{count((entries | entriesFilterValid:"update_inventory"))}} %div.header-description #{t('admin.product_import.import.inventory_to_update')} - %div.panel-content{ng: {hide: '!active || update_inventory.count == 0'}} - = render 'entries_table', entries: @importer.all_entries, filter: 'update_inventory' + %div.panel-content{ng: {hide: '!active || count((entries | entriesFilterValid:"update_inventory")) == 0'}} + = render 'entries_table', entries: 'update_inventory' %div.panel-section{ng: {controller: 'ImportOptionsFormCtrl', hide: 'resetTotal == 0'}} %div.panel-header diff --git a/app/views/admin/product_import/_inventory_options_form.html.haml b/app/views/admin/product_import/_inventory_options_form.html.haml index 087dd5f54d..57974c016b 100644 --- a/app/views/admin/product_import/_inventory_options_form.html.haml +++ b/app/views/admin/product_import/_inventory_options_form.html.haml @@ -1,16 +1,19 @@ -%table.import-settings{ng: {controller: 'ImportOptionsFormCtrl', init: "supplierId = #{supplier_id}; resetCount = #{@importer.reset_counts[supplier_id][:reset_count]}"}} +%table.import-settings{ng: {controller: 'ImportOptionsFormCtrl', init: "supplierId = #{supplier_id}; initForm()"}} %tr %td.description #{t('admin.product_import.import.reset_absent?')} %td - = check_box_tag "settings[#{supplier_id}][reset_all_absent]", 1, false, :'ng-model' => 'resetAbsent', :'ng-change' => 'toggleResetAbsent()' + = check_box_tag "settings[#{supplier_id}][reset_all_absent]", 1, false, 'ng-model' => "settings[#{supplier_id}]['reset_all_absent']", 'ng-change' => "toggleResetAbsent('#{supplier_id}')" + %td + %td %td %tr %td.description #{t('admin.product_import.import.default_stock')} %td - = check_box_tag "settings[#{supplier_id}][defaults][count_on_hand][active]", 1, false, :'ng-model' => "count_on_hand_#{supplier_id}" + = check_box_tag "settings[#{supplier_id}][defaults][count_on_hand][active]", 1, false, 'ng-model' => "settings[#{supplier_id}]['defaults']['count_on_hand']['active']" %td - = select_tag "settings[#{supplier_id}][defaults][count_on_hand][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!count_on_hand_#{supplier_id}"} %td - = number_field_tag "settings[#{supplier_id}][defaults][count_on_hand][value]", 0, 'ng-disabled' => "!count_on_hand_#{supplier_id}" \ No newline at end of file + = select_tag "settings[#{supplier_id}][defaults][count_on_hand][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['count_on_hand']['mode']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['count_on_hand']['active']"} + %td + = number_field_tag "settings[#{supplier_id}][defaults][count_on_hand][value]", 0, 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['count_on_hand']['active']", 'ng-model' => "settings[#{supplier_id}]['defaults']['count_on_hand']['value']" diff --git a/app/views/admin/product_import/_product_options_form.html.haml b/app/views/admin/product_import/_product_options_form.html.haml index b7d0316322..f1bbeb392c 100644 --- a/app/views/admin/product_import/_product_options_form.html.haml +++ b/app/views/admin/product_import/_product_options_form.html.haml @@ -1,44 +1,44 @@ -%table.import-settings{ng: {controller: 'ImportOptionsFormCtrl', init: "supplierId = #{supplier_id}; resetCount = #{@importer.reset_counts[supplier_id][:reset_count]}"}} +%table.import-settings{ng: {controller: 'ImportOptionsFormCtrl', init: "supplierId = #{supplier_id}; initForm()"}} %tr %td.description #{t('admin.product_import.import.reset_absent?')} %td - = check_box_tag "settings[#{supplier_id}][reset_all_absent]", 1, false, :'ng-model' => 'resetAbsent', :'ng-change' => 'toggleResetAbsent()' + = check_box_tag "settings[#{supplier_id}][reset_all_absent]", 1, false, :'ng-model' => "settings[#{supplier_id}]['reset_all_absent']", :'ng-change' => "toggleResetAbsent('#{supplier_id}')" %td %td %tr %td.description #{t('admin.product_import.import.default_stock')} %td - = check_box_tag "settings[#{supplier_id}][defaults][on_hand][active]", 1, false, :'ng-model' => "on_hand_#{supplier_id}" + = check_box_tag "settings[#{supplier_id}][defaults][on_hand][active]", 1, false, 'ng-model' => "settings[#{supplier_id}]['defaults']['on_hand']['active']" %td - = select_tag "settings[#{supplier_id}][defaults][on_hand][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!on_hand_#{supplier_id}"} + = select_tag "settings[#{supplier_id}][defaults][on_hand][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['on_hand']['mode']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['on_hand']['active']"} %td - = number_field_tag "settings[#{supplier_id}][defaults][on_hand][value]", 0, 'ng-disabled' => "!on_hand_#{supplier_id}" + = number_field_tag "settings[#{supplier_id}][defaults][on_hand][value]", 0, 'ng-model' => "settings[#{supplier_id}]['defaults']['on_hand']['value']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['on_hand']['active']" %tr %td.description #{t('admin.product_import.import.default_tax_cat')} %td - = check_box_tag "settings[#{supplier_id}][defaults][tax_category_id][active]", 1, false, :'ng-model' => "tax_category_id_#{supplier_id}" + = check_box_tag "settings[#{supplier_id}][defaults][tax_category_id][active]", 1, false, 'ng-model' => "settings[#{supplier_id}]['defaults']['tax_category_id']['active']" %td - = select_tag "settings[#{supplier_id}][defaults][tax_category_id][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!tax_category_id_#{supplier_id}"} + = select_tag "settings[#{supplier_id}][defaults][tax_category_id][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['tax_category_id']['mode']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['tax_category_id']['active']"} %td - = select_tag "settings[#{supplier_id}][defaults][tax_category_id][value]", options_for_select(@tax_categories.map {|tc| [tc.name, tc.id]}), {prompt: 'None', class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!tax_category_id_#{supplier_id}"} + = select_tag "settings[#{supplier_id}][defaults][tax_category_id][value]", options_for_select(@tax_categories.map {|tc| [tc.name, tc.id]}), {prompt: 'None', class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['tax_category_id']['value']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['tax_category_id']['active']"} %tr %td.description #{t('admin.product_import.import.default_shipping_cat')} %td - = check_box_tag "settings[#{supplier_id}][defaults][shipping_category_id][active]", 1, false, :'ng-model' => "shipping_category_id_#{supplier_id}" + = check_box_tag "settings[#{supplier_id}][defaults][shipping_category_id][active]", 1, false, 'ng-model' => "settings[#{supplier_id}]['defaults']['shipping_category_id']['active']" %td - = select_tag "settings[#{supplier_id}][defaults][shipping_category_id][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!shipping_category_id_#{supplier_id}"} + = select_tag "settings[#{supplier_id}][defaults][shipping_category_id][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['shipping_category_id']['mode']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['shipping_category_id']['active']"} %td - = select_tag "settings[#{supplier_id}][defaults][shipping_category_id][value]", options_for_select(@shipping_categories.map {|sc| [sc.name, sc.id]}), {prompt: 'None', class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!shipping_category_id_#{supplier_id}"} + = select_tag "settings[#{supplier_id}][defaults][shipping_category_id][value]", options_for_select(@shipping_categories.map {|sc| [sc.name, sc.id]}), {prompt: 'None', class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['shipping_category_id']['value']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['shipping_category_id']['active']"} %tr %td.description #{t('admin.product_import.import.default_available_date')} %td - = check_box_tag "settings[#{supplier_id}][defaults][available_on][active]", 1, false, :'ng-model' => "available_on_#{supplier_id}" + = check_box_tag "settings[#{supplier_id}][defaults][available_on][active]", 1, false, 'ng-model' => "settings[#{supplier_id}]['defaults']['available_on']['active']" %td - = select_tag "settings[#{supplier_id}][defaults][available_on][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!available_on_#{supplier_id}"} + = select_tag "settings[#{supplier_id}][defaults][available_on][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['available_on']['mode']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['available_on']['active']"} %td - = text_field_tag "settings[#{supplier_id}][defaults][available_on][value]", nil, {class: 'datepicker', placeholder: 'Today', 'ng-disabled' => "!available_on_#{supplier_id}"} + = text_field_tag "settings[#{supplier_id}][defaults][available_on][value]", nil, {class: 'datepicker', placeholder: 'Today', 'ng-model' => "settings[#{supplier_id}]['defaults']['available_on']['value']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['available_on']['active']"} diff --git a/app/views/admin/product_import/_save_results.html.haml b/app/views/admin/product_import/_save_results.html.haml new file mode 100644 index 0000000000..190b301f48 --- /dev/null +++ b/app/views/admin/product_import/_save_results.html.haml @@ -0,0 +1,60 @@ + +%h5 #{t('admin.product_import.save.final_results')} +%br + +%div.post-save-results + + %p{ng: {show: 'updates.products_created'}} + %i.fa{ng: {class: "{'fa-info-circle': updates.products_created == 0, 'fa-check-circle': updates.products_created > 0}"}} + %strong.created-count + {{updates.products_created}} + #{t('admin.product_import.save.products_created')} + + %p{ng: {show: 'updates.products_updated'}} + %i.fa{ng: {class: "{'fa-info-circle': updates.products_updated == 0, 'fa-check-circle': updates.products_updated > 0}"}} + %strong.updated-count + {{updates.products_updated}} + #{t('admin.product_import.save.products_updated')} + + %p{ng: {show: 'updates.inventory_created'}} + %i.fa{ng: {class: "{'fa-info-circle': updates.inventory_created == 0, 'fa-check-circle': updates.inventory_created > 0}"}} + %strong.inv-created-count + {{updates.inventory_created}} + #{t('admin.product_import.save.inventory_created')} + + %p{ng: {show: 'updates.inventory_updated'}} + %i.fa{ng: {class: "{'fa-info-circle': updates.inventory_updated == 0, 'fa-check-circle': updates.inventory_updated > 0}"}} + %strong.inv-updated-count + {{updates.inventory_updated}} + #{t('admin.product_import.save.inventory_updated')} + + %p{ng: {show: 'updates.products_reset'}} + %i.fa.fa-info-circle + %strong.reset-count + {{updates.products_reset}} + - if @import_into == 'inventories' + #{t('admin.product_import.save.inventory_reset')} + - else + #{t('admin.product_import.save.products_reset')} + + %br + + %p{ng: {show: 'update_errors.length == 0'}} + #{t('admin.product_import.save.all_saved')} + + %div{ng: {show: 'update_errors.length > 0'}} + %p {{updated_total}} #{t('admin.product_import.save.some_saved')} + %br + %h5 #{t('admin.product_import.save.save_errors')} + + %p.save-error{ng: {repeat: 'error in update_errors'}} +  -  {{error}} + + %br + %div{ng: {show: 'updated_total > 0'}} + - if @import_into == 'inventories' + %a.button.view{href: main_app.admin_inventory_path} #{t('admin.product_import.save.view_inventory')} + - else + %a.button.view{href: bulk_edit_admin_products_path + '?latest_import=true'} #{t('admin.product_import.save.view_products')} + + %a.button{href: main_app.admin_product_import_path} #{t('admin.back')} diff --git a/app/views/admin/product_import/_upload_form.html.haml b/app/views/admin/product_import/_upload_form.html.haml index ae5360598e..4b752a2548 100644 --- a/app/views/admin/product_import/_upload_form.html.haml +++ b/app/views/admin/product_import/_upload_form.html.haml @@ -14,4 +14,4 @@ %br %br %br - = submit_tag "#{t('admin.product_import.index.import')}" + = submit_tag "#{t('admin.product_import.index.upload')}" diff --git a/app/views/admin/product_import/import.html.haml b/app/views/admin/product_import/import.html.haml index 2ed7a2d6b2..d62ef3c37f 100644 --- a/app/views/admin/product_import/import.html.haml +++ b/app/views/admin/product_import/import.html.haml @@ -3,36 +3,62 @@ = render partial: 'spree/admin/shared/product_sub_menu' -= form_tag main_app.admin_product_import_save_path, {class: 'product-import', 'ng-app' => 'ofn.admin'} do +.import-wrapper{ng: {app: 'ofn.admin', controller: 'ImportFormCtrl', init: "supplier_product_counts = #{@importer.supplier_products.to_json}"}} - - if !@importer.has_valid_entries? #and @importer.invalid_count + - if @importer.item_count == 0 #and @importer.invalid_count %h5 #{t('admin.product_import.import.no_valid_entries')} %p #{t('admin.product_import.import.none_to_save')} %br + - else + .progress-interface{ng: {show: 'step == "import"'}} + %span.filename + #{@original_filename} + %span.percentage + ({{percentage}}) + .progress-bar + %span.progress-track{class: 'ng-binding', style: "width:{{percentage}}"} + %button.start_import{ng: {click: 'start()', disabled: 'started', init: "item_count = #{@importer.item_count}; import_url = '#{main_app.admin_product_import_process_async_path}'; filepath = '#{@filepath}'; import_into = '#{@import_into}'"}} + #{t('admin.product_import.index.import')} + %button.review{ng: {click: 'viewResults()', disabled: '!finished'}} + #{t('admin.product_import.import.review')} - = render partial: "admin/json/injection_ams", locals: {ngModule: 'ofn.admin', name: 'productImportData', json: @importer.entries_json} + = form_tag false, {class: 'product-import', name: 'importForm', 'ng-show' => 'step == "results"'} do - = render 'import_options' if @importer.has_valid_entries? + = render 'import_options' if @importer.table_headings - = render 'import_review' if @importer.has_entries? + = render 'import_review' if @importer.table_headings - %div{ng: {controller: 'ImportFeedbackCtrl', show: "count((entries | entriesFilterValid:'valid')) > 0"}} - %div{ng: {if: "count((entries | entriesFilterValid:'invalid')) > 0"}} + %div{ng: {controller: 'ImportFeedbackCtrl', show: 'count((entries | entriesFilterValid:"valid")) > 0'}} + %div{ng: {if: 'count((entries | entriesFilterValid:"invalid")) > 0'}} + %br + %h5 #{t('admin.product_import.import.some_invalid_entries')} + %p #{t('admin.product_import.import.save_valid?')} + %div{ng: {show: 'count((entries | entriesFilterValid:"invalid")) == 0'}} + %br + %h5 #{t('admin.product_import.import.no_errors')} + %p #{t('admin.product_import.import.save_all_imported?')} %br - %h5 #{t('admin.product_import.import.some_invalid_entries')} - %p #{t('admin.product_import.import.save_valid?')} - %div{ng: {show: "count((entries | entriesFilterValid:'invalid')) == 0"}} + = hidden_field_tag :filepath, @filepath + = hidden_field_tag "settings[import_into]", @import_into + + %a.button{href: '', ng: {click: 'acceptResults()'}} + #{t('admin.product_import.import.proceed')} + + %a.button{href: main_app.admin_product_import_path} #{t('admin.cancel')} + + %div{ng: {controller: 'ImportFeedbackCtrl', show: 'count((entries | entriesFilterValid:"valid")) == 0'}} %br - %h5 #{t('admin.product_import.import.no_errors')} - %p #{t('admin.product_import.import.save_all_imported?')} - %br - = hidden_field_tag :filepath, @filepath - = hidden_field_tag "settings[import_into]", @import_into - = submit_tag t('admin.save') - %a.button{href: main_app.admin_product_import_path} #{t('admin.cancel')} - - %div{ng: {controller: 'ImportFeedbackCtrl', show: "count((entries | entriesFilterValid:'valid')) == 0"}} - %br - %a.button{href: main_app.admin_product_import_path} #{t('admin.cancel')} + %a.button{href: main_app.admin_product_import_path} #{t('admin.cancel')} + .progress-interface{ng: {show: 'step == "save"'}} + %span.filename + #{t('admin.product_import.import.save_imported')} ({{percentage}}) + .progress-bar{} + %span.progress-track{ng: {style: "{'width':percentage}"}} + %button.start_save{ng: {click: 'start()', disabled: 'started', init: "item_count = #{@importer.item_count}; save_url = '#{main_app.admin_product_import_save_async_path}'; reset_url = '#{main_app.admin_product_import_reset_async_path}'; filepath = '#{@filepath}'; import_into = '#{@import_into}'"}} + #{t('admin.product_import.import.save')} + %button.view_results{ng: {click: 'finalResults()', disabled: '!finished'}} + #{t('admin.product_import.import.results')} + .save-results{ng: {show: 'step == "complete"'}} + = render 'save_results' diff --git a/config/locales/en.yml b/config/locales/en.yml index 3959453aae..60d6c08ab6 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -500,7 +500,13 @@ en: product_list: Product list inventories: Inventories import: Import + upload: Upload import: + review: Review + proceed: Proceed + save: Save + results: Results + save_imported: Save imported products no_valid_entries: No valid entries found none_to_save: There are no entries that can be saved some_invalid_entries: Imported file contains some invalid entries @@ -538,8 +544,8 @@ en: inventory_updated: Inventory items updated products_reset: Products had stock level reset to zero inventory_reset: Inventory items had stock level reset to zero - all_saved: "All %{num} items saved successfully" - total_saved: "%{num} items saved successfully" + all_saved: "All items saved successfully" + some_saved: "items saved successfully" save_errors: Save errors view_products: View Products view_inventory: View Inventory diff --git a/config/routes.rb b/config/routes.rb index fb4b652bac..58e74ae5d0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -135,7 +135,10 @@ Openfoodnetwork::Application.routes.draw do get '/product_import', to: 'product_import#index' post '/product_import', to: 'product_import#import' - post '/product_import/save', to: 'product_import#save', as: 'product_import_save' + post '/product_import/process_data', to: 'product_import#process_data', as: 'product_import_process_async' + post '/product_import/save_data', to: 'product_import#save_data', as: 'product_import_save_async' + post '/product_import/reset_absent', to: 'product_import#reset_absent_products', as: 'product_import_reset_async' + #post '/product_import/save', to: 'product_import#save', as: 'product_import_save' resources :variant_overrides do post :bulk_update, on: :collection diff --git a/spec/features/admin/product_import_spec.rb b/spec/features/admin/product_import_spec.rb index 401abd872c..8ec649f7e1 100644 --- a/spec/features/admin/product_import_spec.rb +++ b/spec/features/admin/product_import_spec.rb @@ -43,14 +43,31 @@ feature "Product Import", js: true do expect(page).to have_content "Select a spreadsheet to upload" attach_file 'file', '/tmp/test.csv' + click_button 'Upload' + + expect(page).to have_selector 'button.start_import' + expect(page).to have_selector "button.review[disabled='disabled']" + click_button 'Import' + wait_until { page.find("button.review:not([disabled='disabled'])").present? } + click_button 'Review' expect(page).to have_selector '.item-count', text: "2" expect(page).to_not have_selector '.invalid-count' expect(page).to have_selector '.create-count', text: "2" expect(page).to_not have_selector '.update-count' + click_link 'Proceed' + + expect(page).to have_selector 'button.start_save' + expect(page).to have_selector "button.view_results[disabled='disabled']" + + sleep 0.5 click_button 'Save' + wait_until { page.find("button.view_results:not([disabled='disabled'])").present? } + + click_button 'Results' + expect(page).to have_selector '.created-count', text: '2' expect(page).to_not have_selector '.updated-count' @@ -61,6 +78,8 @@ feature "Product Import", js: true do potatoes.price.should == 6.50 potatoes.variants.first.import_date.should be_within(1.minute).of DateTime.now + wait_until { page.find("a.button.view").present? } + click_link 'View Products' expect(page).to have_content 'Bulk Edit Products' @@ -69,36 +88,6 @@ feature "Product Import", js: true do expect(page).to have_field "product_name", with: potatoes.name end - it "displays info about invalid entries but still allows saving of valid entries" do - csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] - csv << ["Good Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] - csv << ["Bad Potatoes", "", "Vegetables", "6", "6.50", "1000", "", "1000"] - end - File.write('/tmp/test.csv', csv_data) - - visit main_app.admin_product_import_path - - expect(page).to have_content "Select a spreadsheet to upload" - attach_file('file', '/tmp/test.csv') - click_button 'Import' - - expect(page).to have_selector '.item-count', text: "2" - expect(page).to have_selector '.invalid-count', text: "1" - expect(page).to have_selector '.create-count', text: "1" - - expect(page).to have_selector 'input[type=submit][value="Save"]' - click_button 'Save' - - expect(page).to have_selector '.created-count', text: '1' - - Spree::Product.find_by_name('Bad Potatoes').should == nil - carrots = Spree::Product.find_by_name('Good Carrots') - carrots.supplier.should == enterprise - carrots.on_hand.should == 5 - carrots.price.should == 3.20 - end - it "displays info about invalid entries but no save button if all items are invalid" do csv_data = CSV.generate do |csv| csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] @@ -111,7 +100,11 @@ feature "Product Import", js: true do expect(page).to have_content "Select a spreadsheet to upload" attach_file 'file', '/tmp/test.csv' + click_button 'Upload' + click_button 'Import' + wait_until { page.find("button.review:not([disabled='disabled'])").present? } + click_button 'Review' expect(page).to have_selector '.item-count', text: "2" expect(page).to have_selector '.invalid-count', text: "2" @@ -121,75 +114,11 @@ feature "Product Import", js: true do expect(page).to_not have_selector 'input[type=submit][value="Save"]' end - it "can add new variants to existing products and update price and stock level of existing products" do - csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale", "display_name"] - csv << ["Hypothetical Cake", "Another Enterprise", "Cake", "5", "5.50", "500", "weight", "1", "Preexisting Banana"] - csv << ["Hypothetical Cake", "Another Enterprise", "Cake", "6", "3.50", "500", "weight", "1", "Emergent Coffee"] - end - File.write('/tmp/test.csv', csv_data) - - visit main_app.admin_product_import_path - attach_file 'file', '/tmp/test.csv' - click_button 'Import' - - expect(page).to have_selector '.item-count', text: "2" - expect(page).to_not have_selector '.invalid-count' - expect(page).to have_selector '.create-count', text: "1" - expect(page).to have_selector '.update-count', text: "1" - - click_button 'Save' - - expect(page).to have_selector '.created-count', text: '1' - expect(page).to have_selector '.updated-count', text: '1' - - added_coffee = Spree::Variant.find_by_display_name('Emergent Coffee') - added_coffee.product.name.should == 'Hypothetical Cake' - added_coffee.price.should == 3.50 - added_coffee.on_hand.should == 6 - added_coffee.import_date.should be_within(1.minute).of DateTime.now - - updated_banana = Spree::Variant.find_by_display_name('Preexisting Banana') - updated_banana.product.name.should == 'Hypothetical Cake' - updated_banana.price.should == 5.50 - updated_banana.on_hand.should == 5 - updated_banana.import_date.should be_within(1.minute).of DateTime.now - end - - it "can add a new product and sub-variants of that product at the same time" do - csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale", "display_name"] - csv << ["Potatoes", "User Enterprise", "Vegetables", "5", "3.50", "500", "weight", "1000", "Small Bag"] - csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "5.50", "2000", "weight", "1000", "Big Bag"] - end - File.write('/tmp/test.csv', csv_data) - - visit main_app.admin_product_import_path - attach_file 'file', '/tmp/test.csv' - click_button 'Import' - - expect(page).to have_selector '.item-count', text: "2" - expect(page).to_not have_selector '.invalid-count' - expect(page).to have_selector '.create-count', text: "2" - - click_button 'Save' - expect(page).to have_selector '.created-count', text: '2' - - small_bag = Spree::Variant.find_by_display_name('Small Bag') - small_bag.product.name.should == 'Potatoes' - small_bag.price.should == 3.50 - small_bag.on_hand.should == 5 - - big_bag = Spree::Variant.find_by_display_name('Big Bag') - big_bag.product.name.should == 'Potatoes' - big_bag.price.should == 5.50 - big_bag.on_hand.should == 6 - end - it "records a timestamp on import that can be viewed and filtered under Bulk Edit Products" do csv_data = CSV.generate do |csv| csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] + csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "6.50", "1000", "weight", "1000"] end File.write('/tmp/test.csv', csv_data) @@ -197,17 +126,29 @@ feature "Product Import", js: true do expect(page).to have_content "Select a spreadsheet to upload" attach_file 'file', '/tmp/test.csv' + click_button 'Upload' + click_button 'Import' + wait_until { page.find("button.review:not([disabled='disabled'])").present? } + click_button 'Review' + + click_link 'Proceed' + sleep 0.5 click_button 'Save' + wait_until { page.find("button.view_results:not([disabled='disabled'])").present? } + click_button 'Results' carrots = Spree::Product.find_by_name('Carrots') carrots.variants.first.import_date.should be_within(1.minute).of DateTime.now + potatoes = Spree::Product.find_by_name('Potatoes') + potatoes.variants.first.import_date.should be_within(1.minute).of DateTime.now click_link 'View Products' wait_until { page.find("#p_#{carrots.id}").present? } expect(page).to have_field "product_name", with: carrots.name + expect(page).to have_field "product_name", with: potatoes.name find("div#columns-dropdown", :text => "COLUMNS").click find("div#columns-dropdown div.menu div.menu_item", text: "Import").click find("div#columns-dropdown", :text => "COLUMNS").click @@ -217,10 +158,11 @@ feature "Product Import", js: true do end expect(page).to have_selector 'div#s2id_import_date_filter' - import_time = carrots.import_date.to_formatted_s(:long) + import_time = carrots.import_date.to_date.to_formatted_s(:long) select import_time, from: "import_date_filter", visible: false expect(page).to have_field "product_name", with: carrots.name + expect(page).to have_field "product_name", with: potatoes.name expect(page).to_not have_field "product_name", with: product.name expect(page).to_not have_field "product_name", with: product2.name end @@ -238,7 +180,11 @@ feature "Product Import", js: true do attach_file 'file', '/tmp/test.csv' select 'Inventories', from: "settings_import_into", visible: false + click_button 'Upload' + click_button 'Import' + wait_until { page.find("button.review:not([disabled='disabled'])").present? } + click_button 'Review' expect(page).to have_selector '.item-count', text: "3" expect(page).to_not have_selector '.invalid-count' @@ -247,7 +193,11 @@ feature "Product Import", js: true do expect(page).to have_selector '.inv-create-count', text: "2" expect(page).to have_selector '.inv-update-count', text: "1" + click_link 'Proceed' + sleep 0.5 click_button 'Save' + wait_until { page.find("button.view_results:not([disabled='disabled'])").present? } + click_button 'Results' expect(page).to_not have_selector '.created-count' expect(page).to_not have_selector '.updated-count' @@ -288,7 +238,7 @@ feature "Product Import", js: true do visit main_app.admin_product_import_path attach_file 'file', '/tmp/test.txt' - click_button 'Import' + click_button 'Upload' expect(page).to have_content "Importer could not process file: invalid filetype" expect(page).to_not have_selector 'input[type=submit][value="Save"]' @@ -296,10 +246,9 @@ feature "Product Import", js: true do File.delete('/tmp/test.txt') end - it "returns and error if nothing was uploaded" do + it "returns an error if nothing was uploaded" do visit main_app.admin_product_import_path - expect(page).to have_content 'Select a spreadsheet to upload' - click_button 'Import' + click_button 'Upload' expect(flash_message).to eq I18n.t(:product_import_file_not_found_notice) end @@ -309,7 +258,7 @@ feature "Product Import", js: true do visit main_app.admin_product_import_path attach_file 'file', '/tmp/test.csv' - click_button 'Import' + click_button 'Upload' expect(page).to_not have_selector '.create-count' expect(page).to_not have_selector '.update-count' @@ -333,7 +282,11 @@ feature "Product Import", js: true do visit main_app.admin_product_import_path attach_file 'file', '/tmp/test.csv' + click_button 'Upload' + click_button 'Import' + wait_until { page.find("button.review:not([disabled='disabled'])").present? } + click_button 'Review' expect(page).to have_selector '.item-count', text: "2" expect(page).to have_selector '.invalid-count', text: "1" @@ -341,249 +294,16 @@ feature "Product Import", js: true do expect(page.body).to have_content 'you do not have permission' + click_link 'Proceed' + sleep 0.5 click_button 'Save' + wait_until { page.find("button.view_results:not([disabled='disabled'])").present? } + click_button 'Results' expect(page).to have_selector '.created-count', text: '1' Spree::Product.find_by_name('My Carrots').should be_a Spree::Product Spree::Product.find_by_name('Your Potatoes').should == nil end - - it "allows creating inventories for producers that a user's hub has permission for" do - csv_data = CSV.generate do |csv| - csv << ["name", "producer", "supplier", "category", "on_hand", "price", "unit_value"] - csv << ["Beans", "User Enterprise", "Another Enterprise", "Vegetables", "777", "3.20", "500"] - end - File.write('/tmp/test.csv', csv_data) - - quick_login_as user2 - visit main_app.admin_product_import_path - - attach_file 'file', '/tmp/test.csv' - select 'Inventories', from: "settings_import_into", visible: false - click_button 'Import' - - expect(page).to have_selector '.item-count', text: "1" - expect(page).to_not have_selector '.invalid-count' - expect(page).to have_selector '.inv-create-count', text: "1" - - click_button 'Save' - - expect(page).to have_selector '.inv-created-count', text: '1' - - beans = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first - beans.count_on_hand.should == 777 - end - - it "does not allow creating inventories for producers that a user's hubs don't have permission for" do - csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "category", "on_hand", "price", "unit_value"] - csv << ["Beans", "User Enterprise", "Vegetables", "5", "3.20", "500"] - csv << ["Sprouts", "User Enterprise", "Vegetables", "6", "6.50", "500"] - end - File.write('/tmp/test.csv', csv_data) - - quick_login_as user2 - visit main_app.admin_product_import_path - - attach_file 'file', '/tmp/test.csv' - select 'Inventories', from: "settings_import_into", visible: false - click_button 'Import' - - expect(page).to have_selector '.item-count', text: "2" - expect(page).to have_selector '.invalid-count', text: "2" - expect(page).to_not have_selector '.inv-create-count' - - expect(page.body).to have_content 'you do not have permission' - expect(page).to_not have_selector 'input[type=submit][value="Save"]' - end - end - - describe "applying settings and defaults on import" do - before { quick_login_as_admin } - after { File.delete('/tmp/test.csv') } - - it "can reset all products for an enterprise that are not present in the uploaded file to zero stock" do - csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] - csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] - csv << ["Beans", "User Enterprise", "Vegetables", "6", "6.50", "500", "weight", "1"] - end - File.write('/tmp/test.csv', csv_data) - - visit main_app.admin_product_import_path - - attach_file 'file', '/tmp/test.csv' - click_button 'Import' - - expect(page).to have_selector '.item-count', text: "2" - expect(page).to_not have_selector '.invalid-count' - expect(page).to have_selector '.create-count', text: "1" - expect(page).to have_selector '.update-count', text: "1" - - expect(page).to_not have_selector '.reset-count' - - within 'div.import-settings' do - find('div.header-description').click # Import settings tab - check "settings_#{enterprise.id}_reset_all_absent" - end - - expect(page).to have_selector '.reset-count', text: "2" - - click_button 'Save' - - expect(page).to have_selector '.created-count', text: '1' - expect(page).to have_selector '.updated-count', text: '1' - expect(page).to have_selector '.reset-count', text: '2' - - Spree::Product.find_by_name('Carrots').on_hand.should == 5 # Present in file, added - Spree::Product.find_by_name('Beans').on_hand.should == 6 # Present in file, updated - Spree::Product.find_by_name('Sprouts').on_hand.should == 0 # In enterprise, not in file - Spree::Product.find_by_name('Cabbage').on_hand.should == 0 # In enterprise, not in file - Spree::Product.find_by_name('Lettuce').on_hand.should == 100 # In different enterprise; unchanged - end - - it "can reset all inventory items for an enterprise that are not present in the uploaded file to zero stock" do - csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "producer", "category", "on_hand", "price", "unit_value"] - csv << ["Beans", "Another Enterprise", "User Enterprise", "Vegetables", "6", "3.20", "500"] - csv << ["Sprouts", "Another Enterprise", "User Enterprise", "Vegetables", "7", "6.50", "500"] - end - File.write('/tmp/test.csv', csv_data) - - visit main_app.admin_product_import_path - - attach_file 'file', '/tmp/test.csv' - select 'Inventories', from: "settings_import_into", visible: false - click_button 'Import' - - expect(page).to have_selector '.item-count', text: "2" - expect(page).to_not have_selector '.invalid-count' - expect(page).to have_selector '.inv-create-count', text: "2" - - expect(page).to_not have_selector '.reset-count' - - within 'div.import-settings' do - find('div.header-description').click # Import settings tab - check "settings_#{enterprise2.id}_reset_all_absent" - end - - expect(page).to have_selector '.reset-count', text: "1" - - click_button 'Save' - - expect(page).to have_selector '.inv-created-count', text: '2' - expect(page).to have_selector '.reset-count', text: '1' - - beans = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first - sprouts = VariantOverride.where(variant_id: product3.variants.first.id, hub_id: enterprise2.id).first - cabbage = VariantOverride.where(variant_id: product4.variants.first.id, hub_id: enterprise2.id).first - lettuce = VariantOverride.where(variant_id: product5.variants.first.id, hub_id: enterprise.id).first - - beans.count_on_hand.should == 6 # Present in file, created - sprouts.count_on_hand.should == 7 # Present in file, created - cabbage.count_on_hand.should == 0 # In enterprise, not in file (reset) - lettuce.count_on_hand.should == 96 # In different enterprise; unchanged - end - - it "can overwrite fields with selected defaults when importing to product list" do - csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale", "tax_category_id", "available_on"] - csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1", tax_category.id, ""] - csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "6.50", "1000", "weight", "1000", "", ""] - end - File.write('/tmp/test.csv', csv_data) - - visit main_app.admin_product_import_path - - attach_file 'file', '/tmp/test.csv' - click_button 'Import' - - within 'div.import-settings' do - find('div.header-description').click # Import settings tab - expect(page).to have_selector "#settings_#{enterprise.id}_defaults_on_hand_mode", visible: false - - # Overwrite stock level of all items to 9000 - check "settings_#{enterprise.id}_defaults_on_hand_active" - select 'Overwrite all', from: "settings_#{enterprise.id}_defaults_on_hand_mode", visible: false - fill_in "settings_#{enterprise.id}_defaults_on_hand_value", with: '9000' - - # Overwrite default tax category, but only where field is empty - check "settings_#{enterprise.id}_defaults_tax_category_id_active" - select 'Overwrite if empty', from: "settings_#{enterprise.id}_defaults_tax_category_id_mode", visible: false - select tax_category2.name, from: "settings_#{enterprise.id}_defaults_tax_category_id_value", visible: false - - # Set default shipping category (field not present in file) - check "settings_#{enterprise.id}_defaults_shipping_category_id_active" - select 'Overwrite all', from: "settings_#{enterprise.id}_defaults_shipping_category_id_mode", visible: false - select shipping_category.name, from: "settings_#{enterprise.id}_defaults_shipping_category_id_value", visible: false - - # Set available_on date - check "settings_#{enterprise.id}_defaults_available_on_active" - select 'Overwrite all', from: "settings_#{enterprise.id}_defaults_available_on_mode", visible: false - find("input#settings_#{enterprise.id}_defaults_available_on_value").set '2020-01-01' - end - - click_button 'Save' - - expect(page).to have_selector '.created-count', text: '2' - - carrots = Spree::Product.find_by_name('Carrots') - carrots.on_hand.should == 9000 - carrots.tax_category_id.should == tax_category.id - carrots.shipping_category_id.should == shipping_category.id - carrots.available_on.should be_within(1.day).of(Time.zone.local(2020, 1, 1)) - - potatoes = Spree::Product.find_by_name('Potatoes') - potatoes.on_hand.should == 9000 - potatoes.tax_category_id.should == tax_category2.id - potatoes.shipping_category_id.should == shipping_category.id - potatoes.available_on.should be_within(1.day).of(Time.zone.local(2020, 1, 1)) - end - - it "can overwrite fields with selected defaults when importing to inventory" do - csv_data = CSV.generate do |csv| - csv << ["name", "producer", "supplier", "category", "on_hand", "price", "unit_value"] - csv << ["Beans", "User Enterprise", "Another Enterprise", "Vegetables", "", "3.20", "500"] - csv << ["Sprouts", "User Enterprise", "Another Enterprise", "Vegetables", "7", "6.50", "500"] - csv << ["Cabbage", "User Enterprise", "Another Enterprise", "Vegetables", "", "1.50", "500"] - end - File.write('/tmp/test.csv', csv_data) - - visit main_app.admin_product_import_path - - attach_file 'file', '/tmp/test.csv' - select 'Inventories', from: "settings_import_into", visible: false - click_button 'Import' - - within 'div.import-settings' do - find('div.header-description').click # Import settings tab - check "settings_#{enterprise2.id}_defaults_count_on_hand_active" - select 'Overwrite if empty', from: "settings_#{enterprise2.id}_defaults_count_on_hand_mode", visible: false - fill_in "settings_#{enterprise2.id}_defaults_count_on_hand_value", with: '9000' - end - - expect(page).to have_selector '.item-count', text: "3" - expect(page).to_not have_selector '.invalid-count' - expect(page).to_not have_selector '.create-count' - expect(page).to_not have_selector '.update-count' - expect(page).to have_selector '.inv-create-count', text: "2" - expect(page).to have_selector '.inv-update-count', text: "1" - - click_button 'Save' - - expect(page).to_not have_selector '.created-count' - expect(page).to_not have_selector '.updated-count' - expect(page).to have_selector '.inv-created-count', text: '2' - expect(page).to have_selector '.inv-updated-count', text: '1' - - beans_override = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first - sprouts_override = VariantOverride.where(variant_id: product3.variants.first.id, hub_id: enterprise2.id).first - cabbage_override = VariantOverride.where(variant_id: product4.variants.first.id, hub_id: enterprise2.id).first - - beans_override.count_on_hand.should == 9000 - sprouts_override.count_on_hand.should == 7 - cabbage_override.count_on_hand.should == 9000 - end end end diff --git a/spec/models/product_importer_spec.rb b/spec/models/product_importer_spec.rb index 5d8aef2ce5..00f20dfe5f 100644 --- a/spec/models/product_importer_spec.rb +++ b/spec/models/product_importer_spec.rb @@ -6,25 +6,534 @@ describe ProductImporter do let!(:admin) { create(:admin_user) } let!(:user) { create_enterprise_user } - let!(:enterprise) { create(:enterprise, owner: user, name: "Test Enterprise") } + let!(:user2) { create_enterprise_user } + let!(:enterprise) { create(:enterprise, owner: user, name: "User Enterprise") } + let!(:enterprise2) { create(:distributor_enterprise, owner: user2, name: "Another Enterprise") } + let!(:relationship) { create(:enterprise_relationship, parent: enterprise, child: enterprise2, permissions_list: [:create_variant_overrides]) } + let!(:category) { create(:taxon, name: 'Vegetables') } + let!(:category2) { create(:taxon, name: 'Cake') } + let!(:tax_category) { create(:tax_category) } + let!(:tax_category2) { create(:tax_category) } + let!(:shipping_category) { create(:shipping_category) } + + let!(:product) { create(:simple_product, supplier: enterprise2, name: 'Hypothetical Cake') } + let!(:variant) { create(:variant, product_id: product.id, price: '8.50', on_hand: '100', unit_value: '500', display_name: 'Preexisting Banana') } + let!(:product2) { create(:simple_product, supplier: enterprise, on_hand: '100', name: 'Beans', unit_value: '500') } + let!(:product3) { create(:simple_product, supplier: enterprise, on_hand: '100', name: 'Sprouts', unit_value: '500') } + let!(:product4) { create(:simple_product, supplier: enterprise, on_hand: '100', name: 'Cabbage', unit_value: '500') } + let!(:product5) { create(:simple_product, supplier: enterprise2, on_hand: '100', name: 'Lettuce', unit_value: '500') } + let!(:variant_override) { create(:variant_override, variant_id: product4.variants.first.id, hub: enterprise2, count_on_hand: 42) } + let!(:variant_override2) { create(:variant_override, variant_id: product5.variants.first.id, hub: enterprise, count_on_hand: 96) } + let(:permissions) { OpenFoodNetwork::Permissions.new(user) } describe "importing products from a spreadsheet" do - after { File.delete('/tmp/test-m.csv') } - - it "validates the entries" do + before do csv_data = CSV.generate do |csv| csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] - csv << ["Carrots", "Test Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] - csv << ["Potatoes", "Test Enterprise", "Vegetables", "6", "6.50", "1000", "weight", "1000"] + csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] + csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "6.50", "1000", "weight", "1000"] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'product_list'}) + end + after { File.delete('/tmp/test-m.csv') } + + it "returns the number of entries" do + expect(@importer.item_count).to eq(2) + end + + it "validates entries and returns the results as json" do + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 2 + expect(filter('invalid', entries)).to eq 0 + expect(filter('create_product', entries)).to eq 2 + expect(filter('update_product', entries)).to eq 0 + end + + it "saves the results and returns info on updated products" do + @importer.save_entries + + expect(@importer.products_created_count).to eq 2 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 2 + + carrots = Spree::Product.find_by_name('Carrots') + carrots.supplier.should == enterprise + carrots.on_hand.should == 5 + carrots.price.should == 3.20 + carrots.variants.first.import_date.should be_within(1.minute).of DateTime.now + + potatoes = Spree::Product.find_by_name('Potatoes') + potatoes.supplier.should == enterprise + potatoes.on_hand.should == 6 + potatoes.price.should == 6.50 + potatoes.variants.first.import_date.should be_within(1.minute).of DateTime.now + end + end + + describe "when uploading a spreadsheet with some invalid entries" do + before do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] + csv << ["Good Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] + csv << ["Bad Potatoes", "", "Vegetables", "6", "6.50", "1000", "", "1000"] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'product_list'}) + end + after { File.delete('/tmp/test-m.csv') } + + it "validates entries" do + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 1 + expect(filter('invalid', entries)).to eq 1 + expect(filter('create_product', entries)).to eq 1 + expect(filter('update_product', entries)).to eq 0 + end + + it "allows saving of the valid entries" do + @importer.save_entries + + expect(@importer.products_created_count).to eq 1 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 1 + + carrots = Spree::Product.find_by_name('Good Carrots') + carrots.supplier.should == enterprise + carrots.on_hand.should == 5 + carrots.price.should == 3.20 + carrots.variants.first.import_date.should be_within(1.minute).of DateTime.now + + Spree::Product.find_by_name('Bad Potatoes').should == nil + end + end + + describe "adding new variants to existing products and updating exiting products" do + before do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale", "display_name"] + csv << ["Hypothetical Cake", "Another Enterprise", "Cake", "5", "5.50", "500", "weight", "1", "Preexisting Banana"] + csv << ["Hypothetical Cake", "Another Enterprise", "Cake", "6", "3.50", "500", "weight", "1", "Emergent Coffee"] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'product_list'}) + end + after { File.delete('/tmp/test-m.csv') } + + it "validates entries" do + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 2 + expect(filter('invalid', entries)).to eq 0 + expect(filter('create_product', entries)).to eq 1 + expect(filter('update_product', entries)).to eq 1 + end + + it "saves and updates" do + @importer.save_entries + + expect(@importer.products_created_count).to eq 1 + expect(@importer.products_updated_count).to eq 1 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 2 + + added_coffee = Spree::Variant.find_by_display_name('Emergent Coffee') + added_coffee.product.name.should == 'Hypothetical Cake' + added_coffee.price.should == 3.50 + added_coffee.on_hand.should == 6 + added_coffee.import_date.should be_within(1.minute).of DateTime.now + + updated_banana = Spree::Variant.find_by_display_name('Preexisting Banana') + updated_banana.product.name.should == 'Hypothetical Cake' + updated_banana.price.should == 5.50 + updated_banana.on_hand.should == 5 + updated_banana.import_date.should be_within(1.minute).of DateTime.now + end + + end + + describe "adding new product and sub-variant at the same time" do + before do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale", "display_name"] + csv << ["Potatoes", "User Enterprise", "Vegetables", "5", "3.50", "500", "weight", "1000", "Small Bag"] + csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "5.50", "2000", "weight", "1000", "Big Bag"] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'product_list'}) + end + after { File.delete('/tmp/test-m.csv') } + + it "validates entries" do + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 2 + expect(filter('invalid', entries)).to eq 0 + expect(filter('create_product', entries)).to eq 2 + end + + it "saves and updates" do + @importer.save_entries + + expect(@importer.products_created_count).to eq 2 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 2 + + small_bag = Spree::Variant.find_by_display_name('Small Bag') + small_bag.product.name.should == 'Potatoes' + small_bag.price.should == 3.50 + small_bag.on_hand.should == 5 + + big_bag = Spree::Variant.find_by_display_name('Big Bag') + big_bag.product.name.should == 'Potatoes' + big_bag.price.should == 5.50 + big_bag.on_hand.should == 6 + end + end + + describe "importing items into inventory" do + before do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "producer", "category", "on_hand", "price", "unit_value"] + csv << ["Beans", "Another Enterprise", "User Enterprise", "Vegetables", "5", "3.20", "500"] + csv << ["Sprouts", "Another Enterprise", "User Enterprise", "Vegetables", "6", "6.50", "500"] + csv << ["Cabbage", "Another Enterprise", "User Enterprise", "Vegetables", "2001", "1.50", "500"] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'inventories'}) + end + after { File.delete('/tmp/test-m.csv') } + + it "validates entries" do + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 3 + expect(filter('invalid', entries)).to eq 0 + expect(filter('create_inventory', entries)).to eq 2 + expect(filter('update_inventory', entries)).to eq 1 + end + + it "saves and updates inventory" do + @importer.save_entries + + expect(@importer.inventory_created_count).to eq 2 + expect(@importer.inventory_updated_count).to eq 1 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 3 + + beans_override = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first + sprouts_override = VariantOverride.where(variant_id: product3.variants.first.id, hub_id: enterprise2.id).first + cabbage_override = VariantOverride.where(variant_id: product4.variants.first.id, hub_id: enterprise2.id).first + + Float(beans_override.price).should == 3.20 + beans_override.count_on_hand.should == 5 + + Float(sprouts_override.price).should == 6.50 + sprouts_override.count_on_hand.should == 6 + + Float(cabbage_override.price).should == 1.50 + cabbage_override.count_on_hand.should == 2001 + end + end + + describe "handling enterprise permissions" do + after { File.delete('/tmp/test-m.csv') } + + it "only allows product import into enterprises the user is permitted to manage" do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] + csv << ["My Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] + csv << ["Your Potatoes", "Another Enterprise", "Vegetables", "6", "6.50", "1000", "weight", "1000"] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + @importer = ProductImporter.new(file, user, {start: 1, end: 100, import_into: 'product_list'}) + + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 1 + expect(filter('invalid', entries)).to eq 1 + expect(filter('create_product', entries)).to eq 1 + + @importer.save_entries + + expect(@importer.products_created_count).to eq 1 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 1 + + Spree::Product.find_by_name('My Carrots').should be_a Spree::Product + Spree::Product.find_by_name('Your Potatoes').should == nil + end + + it "allows creating inventories for producers that a user's hub has permission for" do + csv_data = CSV.generate do |csv| + csv << ["name", "producer", "supplier", "category", "on_hand", "price", "unit_value"] + csv << ["Beans", "User Enterprise", "Another Enterprise", "Vegetables", "777", "3.20", "500"] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + @importer = ProductImporter.new(file, user2, {start: 1, end: 100, import_into: 'inventories'}) + + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 1 + expect(filter('invalid', entries)).to eq 0 + expect(filter('create_inventory', entries)).to eq 1 + + @importer.save_entries + + expect(@importer.inventory_created_count).to eq 1 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 1 + + beans = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first + beans.count_on_hand.should == 777 + end + + it "does not allow creating inventories for producers that a user's hubs don't have permission for" do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "category", "on_hand", "price", "unit_value"] + csv << ["Beans", "User Enterprise", "Vegetables", "5", "3.20", "500"] + csv << ["Sprouts", "User Enterprise", "Vegetables", "6", "6.50", "500"] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + @importer = ProductImporter.new(file, user2, {start: 1, end: 100, import_into: 'inventories'}) + + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 0 + expect(filter('invalid', entries)).to eq 2 + expect(filter('create_inventory', entries)).to eq 0 + + @importer.save_entries + + expect(@importer.inventory_created_count).to eq 0 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 0 + end + end + + describe "applying settings and defaults on import" do + after { File.delete('/tmp/test-m.csv') } + + it "can reset all products for an enterprise that are not present in the uploaded file to zero stock" do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] + csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] + csv << ["Beans", "User Enterprise", "Vegetables", "6", "6.50", "500", "weight", "1"] end File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') - importer = ProductImporter.new(file, admin) + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'product_list', 'settings' => {enterprise.id => {'reset_all_absent' => true}}}) - expect(importer.item_count).to eq(2) + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 2 + expect(filter('invalid', entries)).to eq 0 + expect(filter('create_product', entries)).to eq 1 + expect(filter('update_product', entries)).to eq 1 + + @importer.save_entries + + expect(@importer.products_created_count).to eq 1 + expect(@importer.products_updated_count).to eq 1 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 2 + + @importer.reset_absent(@importer.updated_ids) + + expect(@importer.products_reset_count).to eq 2 + + Spree::Product.find_by_name('Carrots').on_hand.should == 5 # Present in file, added + Spree::Product.find_by_name('Beans').on_hand.should == 6 # Present in file, updated + Spree::Product.find_by_name('Sprouts').on_hand.should == 0 # In enterprise, not in file + Spree::Product.find_by_name('Cabbage').on_hand.should == 0 # In enterprise, not in file + Spree::Product.find_by_name('Lettuce').on_hand.should == 100 # In different enterprise; unchanged end + + it "can reset all inventory items for an enterprise that are not present in the uploaded file to zero stock" do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "producer", "category", "on_hand", "price", "unit_value"] + csv << ["Beans", "Another Enterprise", "User Enterprise", "Vegetables", "6", "3.20", "500"] + csv << ["Sprouts", "Another Enterprise", "User Enterprise", "Vegetables", "7", "6.50", "500"] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'inventories', 'settings' => {enterprise2.id => {'reset_all_absent' => true}}}) + + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 2 + expect(filter('invalid', entries)).to eq 0 + expect(filter('create_inventory', entries)).to eq 2 + + @importer.save_entries + + expect(@importer.inventory_created_count).to eq 2 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 2 + + @importer.reset_absent(@importer.updated_ids) + + expect(@importer.products_reset_count).to eq 1 + + beans = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first + sprouts = VariantOverride.where(variant_id: product3.variants.first.id, hub_id: enterprise2.id).first + cabbage = VariantOverride.where(variant_id: product4.variants.first.id, hub_id: enterprise2.id).first + lettuce = VariantOverride.where(variant_id: product5.variants.first.id, hub_id: enterprise.id).first + + beans.count_on_hand.should == 6 # Present in file, created + sprouts.count_on_hand.should == 7 # Present in file, created + cabbage.count_on_hand.should == 0 # In enterprise, not in file (reset) + lettuce.count_on_hand.should == 96 # In different enterprise; unchanged + end + + it "can overwrite fields with selected defaults when importing to product list" do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale", "tax_category_id", "available_on"] + csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1", tax_category.id, ""] + csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "6.50", "1000", "weight", "1000", "", ""] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + + import_settings = {enterprise.id.to_s => { + 'defaults' => { + 'on_hand' => { + 'active' => true, + 'mode' => 'overwrite_all', + 'value' => '9000' + }, + 'tax_category_id' => { + 'active' => true, + 'mode' => 'overwrite_empty', + 'value' => tax_category2.id + }, + 'shipping_category_id' => { + 'active' => true, + 'mode' => 'overwrite_all', + 'value' => shipping_category.id + }, + 'available_on' => { + 'active' => true, + 'mode' => 'overwrite_all', + 'value' => '2020-01-01' + } + } + }} + + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'product_list', settings: import_settings}) + + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 2 + expect(filter('invalid', entries)).to eq 0 + expect(filter('create_product', entries)).to eq 2 + + @importer.save_entries + + expect(@importer.products_created_count).to eq 2 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 2 + + carrots = Spree::Product.find_by_name('Carrots') + carrots.on_hand.should == 9000 + carrots.tax_category_id.should == tax_category.id + carrots.shipping_category_id.should == shipping_category.id + carrots.available_on.should be_within(1.day).of(Time.zone.local(2020, 1, 1)) + + potatoes = Spree::Product.find_by_name('Potatoes') + potatoes.on_hand.should == 9000 + potatoes.tax_category_id.should == tax_category2.id + potatoes.shipping_category_id.should == shipping_category.id + potatoes.available_on.should be_within(1.day).of(Time.zone.local(2020, 1, 1)) + end + + it "can overwrite fields with selected defaults when importing to inventory" do + csv_data = CSV.generate do |csv| + csv << ["name", "producer", "supplier", "category", "on_hand", "price", "unit_value"] + csv << ["Beans", "User Enterprise", "Another Enterprise", "Vegetables", "", "3.20", "500"] + csv << ["Sprouts", "User Enterprise", "Another Enterprise", "Vegetables", "7", "6.50", "500"] + csv << ["Cabbage", "User Enterprise", "Another Enterprise", "Vegetables", "", "1.50", "500"] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + + import_settings = {enterprise2.id.to_s => { + 'defaults' => { + 'count_on_hand' => { + 'active' => true, + 'mode' => 'overwrite_empty', + 'value' => '9000' + } + } + }} + + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'inventories', settings: import_settings}) + + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 3 + expect(filter('invalid', entries)).to eq 0 + expect(filter('create_inventory', entries)).to eq 2 + expect(filter('update_inventory', entries)).to eq 1 + + @importer.save_entries + + expect(@importer.inventory_created_count).to eq 2 + expect(@importer.inventory_updated_count).to eq 1 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 3 + + beans_override = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first + sprouts_override = VariantOverride.where(variant_id: product3.variants.first.id, hub_id: enterprise2.id).first + cabbage_override = VariantOverride.where(variant_id: product4.variants.first.id, hub_id: enterprise2.id).first + + beans_override.count_on_hand.should == 9000 + sprouts_override.count_on_hand.should == 7 + cabbage_override.count_on_hand.should == 9000 + end + end + end + +private + +def filter(type, entries) + valid_count = 0 + entries.each do |line_number, entry| + validates_as = entry['validates_as'] + + valid_count += 1 if type == 'valid' and (validates_as != '') + valid_count += 1 if type == 'invalid' and (validates_as == '') + valid_count += 1 if type == 'create_product' and (validates_as == 'new_product' or validates_as == 'new_variant') + valid_count += 1 if type == 'update_product' and validates_as == 'existing_variant' + valid_count += 1 if type == 'create_inventory' and validates_as == 'new_inventory_item' + valid_count += 1 if type == 'update_inventory' and validates_as == 'existing_inventory_item' + end + valid_count +end \ No newline at end of file From e8489577123e4932c5be56141e822e079793cb42 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Sat, 22 Apr 2017 00:40:44 +0100 Subject: [PATCH 108/206] Clean up PI controller Remove assignment Tweak --- .../admin/product_import_controller.rb | 21 ++----------------- app/models/product_importer.rb | 18 ++++++++++++++++ 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/app/controllers/admin/product_import_controller.rb b/app/controllers/admin/product_import_controller.rb index 08868cbaaf..50c4b68362 100644 --- a/app/controllers/admin/product_import_controller.rb +++ b/app/controllers/admin/product_import_controller.rb @@ -29,12 +29,7 @@ class Admin::ProductImportController < Spree::Admin::BaseController @importer.validate_entries - import_results = { - entries: @importer.entries_json, - reset_counts: @importer.reset_counts - } - - render json: import_results + render json: @importer.import_results end def save_data @@ -42,19 +37,7 @@ class Admin::ProductImportController < Spree::Admin::BaseController @importer.save_entries - save_results = { - results: { - products_created: @importer.products_created_count, - products_updated: @importer.products_updated_count, - inventory_created: @importer.inventory_created_count, - inventory_updated: @importer.inventory_updated_count, - products_reset: @importer.products_reset_count, - }, - updated_ids: @importer.updated_ids, - errors: @importer.errors.full_messages - } - - render json: save_results + render json: @importer.save_results end def reset_absent_products diff --git a/app/models/product_importer.rb b/app/models/product_importer.rb index 1eb178cfdd..723eb73f89 100644 --- a/app/models/product_importer.rb +++ b/app/models/product_importer.rb @@ -127,6 +127,24 @@ class ProductImporter delete_uploaded_file 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 permission_by_name?(supplier_name) @editable_enterprises.has_key?(supplier_name) end From 02661d5c23bff5fcc8a19f5bdd3bb1e7293e3aa1 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Wed, 26 Apr 2017 15:18:42 +0100 Subject: [PATCH 109/206] PI unit_value typecasting --- app/models/product_importer.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/product_importer.rb b/app/models/product_importer.rb index 723eb73f89..f782ab7ef1 100644 --- a/app/models/product_importer.rb +++ b/app/models/product_importer.rb @@ -289,7 +289,7 @@ class ProductImporter end match.variants.each do |existing_variant| - if existing_variant.display_name == entry.display_name and existing_variant.unit_value == Float(entry.unit_value) + if existing_variant.display_name == entry.display_name and existing_variant.unit_value == entry.unit_value.to_f variant_override = create_inventory_item(entry, existing_variant) validate_inventory_item(entry, variant_override) return @@ -645,7 +645,7 @@ class ProductImporter # 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 \ - and existing_variant.unit_value == Float(entry.unit_value) \ + and existing_variant.unit_value == entry.unit_value.to_f \ and existing_variant.deleted_at == nil mark_as_existing_variant(entry, existing_variant) From fe01e8ede3d4979ad57fd5117b500e14453cb4ea Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Fri, 28 Apr 2017 17:27:45 +0100 Subject: [PATCH 110/206] PI human-readable unit fields Enhanced unit specs --- .../controllers/import_feedback.js.coffee | 2 + app/models/product_importer.rb | 8 +- app/models/spreadsheet_entry.rb | 80 ++++++++++++++++- .../product_import/_errors_list.html.haml | 2 +- spec/features/admin/product_import_spec.rb | 26 +++--- spec/models/product_importer_spec.rb | 90 ++++++++++++------- 6 files changed, 156 insertions(+), 52 deletions(-) diff --git a/app/assets/javascripts/admin/product_import/controllers/import_feedback.js.coffee b/app/assets/javascripts/admin/product_import/controllers/import_feedback.js.coffee index 990d3598bf..ea23c59d9f 100644 --- a/app/assets/javascripts/admin/product_import/controllers/import_feedback.js.coffee +++ b/app/assets/javascripts/admin/product_import/controllers/import_feedback.js.coffee @@ -8,3 +8,5 @@ angular.module("ofn.admin").controller "ImportFeedbackCtrl", ($scope) -> $scope.attribute_invalid = (attribute, line_number) -> $scope.entries[line_number]['errors'][attribute] != undefined + + $scope.ignore_fields = ['variant_unit', 'variant_unit_scale', 'unit_description'] diff --git a/app/models/product_importer.rb b/app/models/product_importer.rb index f782ab7ef1..7c2bf28208 100644 --- a/app/models/product_importer.rb +++ b/app/models/product_importer.rb @@ -241,7 +241,7 @@ class ProductImporter line_number = i + 1 row = @sheet.row(line_number) row_data = Hash[[headers, row].transpose] - entry = SpreadsheetEntry.new(row_data) + entry = SpreadsheetEntry.new(row_data, importing_into_inventory?) entry.line_number = line_number @entries.push entry return if @sheet.last_row == line_number # TODO: test @@ -251,7 +251,7 @@ class ProductImporter def build_entries rows.each_with_index do |row, i| row_data = Hash[[headers, row].transpose] - entry = SpreadsheetEntry.new(row_data) + entry = SpreadsheetEntry.new(row_data, importing_into_inventory?) entry.line_number = i + 2 @entries.push entry end @@ -289,6 +289,10 @@ class ProductImporter 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 existing_variant.display_name == entry.display_name and existing_variant.unit_value == entry.unit_value.to_f variant_override = create_inventory_item(entry, existing_variant) validate_inventory_item(entry, variant_override) diff --git a/app/models/spreadsheet_entry.rb b/app/models/spreadsheet_entry.rb index a52d9501ca..ad7a496cea 100644 --- a/app/models/spreadsheet_entry.rb +++ b/app/models/spreadsheet_entry.rb @@ -7,17 +7,20 @@ class SpreadsheetEntry attr_reader :validates_as attr_accessor :line_number, :valid, :product_object, :product_validations, :on_hand_nil, - :has_overrides + :has_overrides, :units, :unscaled_units, :unit_type 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) + def initialize(attrs, is_inventory=false) #@product_validations = {} @validates_as = '' + validate_custom_unit_fields(attrs, is_inventory) + convert_custom_unit_fields(attrs, is_inventory) + attrs.each do |k, v| if self.respond_to?("#{k}=") send("#{k}=", v) unless non_product_attributes.include?(k) @@ -28,6 +31,51 @@ class SpreadsheetEntry end 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 convert_custom_unit_fields(attrs, is_inventory) + + # unit 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 + + attrs['variant_unit'] = nil + attrs['variant_unit_scale'] = nil + attrs['unit_value'] = nil + + if is_inventory and attrs.has_key?('units') and attrs['units'].present? + attrs['unscaled_units'] = attrs['units'] + end + + if attrs.has_key?('units') and attrs.has_key?('unit_type') and attrs['units'].present? and attrs['unit_type'].present? + units = attrs['units'].to_f + unit_type = attrs['unit_type'].to_s.downcase + + if 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 + end + + if attrs.has_key?('units') and attrs.has_key?('variant_unit_name') and attrs['units'].present? and attrs['variant_unit_name'].present? + attrs['variant_unit'] = 'items' + attrs['variant_unit_scale'] = nil + attrs['unit_value'] = units || 1 + end + end + def persisted? false #ActiveModel end @@ -74,8 +122,34 @@ class SpreadsheetEntry private + def valid_unit_type?(unit_type) + unit_scales.has_key? unit_type + end + + def validate_custom_unit_fields(attrs, is_inventory) + unit_types = ['g', 'kg', 't', 'ml', 'l', 'kl', ''] + + # unit must be present and not nil + unless attrs.has_key? 'units' and attrs['units'].present? + self.errors.add('units', "can't be blank") + end + + return if is_inventory + + # unit_type must be valid type + if attrs.has_key? 'unit_type' and attrs['unit_type'].present? + unit_type = attrs['unit_type'].to_s.strip.downcase + self.errors.add('unit_type', "incorrect value") unless unit_types.include?(unit_type) + end + + # variant_unit_name must be present if unit_type not present + if !attrs.has_key? 'unit_type' or ( attrs.has_key? 'unit_type' and attrs['unit_type'].blank? ) + self.errors.add('variant_unit_name', "can't be blank if unit_type is blank") unless attrs.has_key? 'variant_unit_name' and attrs['variant_unit_name'].present? + end + end + def non_display_attributes - ['id', 'product_id', 'variant_id', 'supplier_id', 'primary_taxon', 'primary_taxon_id', 'category_id', 'shipping_category_id', 'tax_category_id'] + ['id', 'product_id', 'unscaled_units', 'variant_id', 'supplier_id', 'primary_taxon', 'primary_taxon_id', 'category_id', 'shipping_category_id', 'tax_category_id'] end def non_product_attributes diff --git a/app/views/admin/product_import/_errors_list.html.haml b/app/views/admin/product_import/_errors_list.html.haml index 260ad46196..787aabe76b 100644 --- a/app/views/admin/product_import/_errors_list.html.haml +++ b/app/views/admin/product_import/_errors_list.html.haml @@ -5,5 +5,5 @@ %span {{entry.attributes.name}} %span{ng: {if: "entry.attributes.display_name"}} ( {{entry.attributes.display_name}} ) - %p.error{ng: {repeat: "(attribute, error) in entry.errors"}} + %p.error{ng: {repeat: "(attribute, error) in entry.errors", show: "ignore_fields.indexOf(attribute) < 0" }}  -  {{error}} diff --git a/spec/features/admin/product_import_spec.rb b/spec/features/admin/product_import_spec.rb index 8ec649f7e1..4c1396de70 100644 --- a/spec/features/admin/product_import_spec.rb +++ b/spec/features/admin/product_import_spec.rb @@ -33,9 +33,9 @@ feature "Product Import", js: true do it "validates entries and saves them if they are all valid and allows viewing new items in Bulk Products" do csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] - csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] - csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "6.50", "1000", "weight", "1000"] + csv << ["name", "supplier", "category", "on_hand", "price", "units", "unit_type"] + csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "g"] + csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "6.50", "1", "kg"] end File.write('/tmp/test.csv', csv_data) @@ -90,9 +90,9 @@ feature "Product Import", js: true do it "displays info about invalid entries but no save button if all items are invalid" do csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] - csv << ["Bad Carrots", "Unkown Enterprise", "Mouldy vegetables", "666", "3.20", "", "weight", ""] - csv << ["Bad Potatoes", "", "Vegetables", "6", "6", "6", "", "1000"] + csv << ["name", "supplier", "category", "on_hand", "price", "units", "unit_type"] + csv << ["Bad Carrots", "Unkown Enterprise", "Mouldy vegetables", "666", "3.20", "", "g"] + csv << ["Bad Potatoes", "", "Vegetables", "6", "6", "6", ""] end File.write('/tmp/test.csv', csv_data) @@ -116,9 +116,9 @@ feature "Product Import", js: true do it "records a timestamp on import that can be viewed and filtered under Bulk Edit Products" do csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] - csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] - csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "6.50", "1000", "weight", "1000"] + csv << ["name", "supplier", "category", "on_hand", "price", "units", "unit_type"] + csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "g"] + csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "6.50", "1", "kg"] end File.write('/tmp/test.csv', csv_data) @@ -169,7 +169,7 @@ feature "Product Import", js: true do it "can import items into inventory" do csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "producer", "category", "on_hand", "price", "unit_value"] + csv << ["name", "supplier", "producer", "category", "on_hand", "price", "units"] csv << ["Beans", "Another Enterprise", "User Enterprise", "Vegetables", "5", "3.20", "500"] csv << ["Sprouts", "Another Enterprise", "User Enterprise", "Vegetables", "6", "6.50", "500"] csv << ["Cabbage", "Another Enterprise", "User Enterprise", "Vegetables", "2001", "1.50", "500"] @@ -272,9 +272,9 @@ feature "Product Import", js: true do it "only allows product import into enterprises the user is permitted to manage" do csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] - csv << ["My Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] - csv << ["Your Potatoes", "Another Enterprise", "Vegetables", "6", "6.50", "1000", "weight", "1000"] + csv << ["name", "supplier", "category", "on_hand", "price", "units", "unit_type"] + csv << ["My Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "g"] + csv << ["Your Potatoes", "Another Enterprise", "Vegetables", "6", "6.50", "1", "kg"] end File.write('/tmp/test.csv', csv_data) diff --git a/spec/models/product_importer_spec.rb b/spec/models/product_importer_spec.rb index 00f20dfe5f..08cb9d677c 100644 --- a/spec/models/product_importer_spec.rb +++ b/spec/models/product_importer_spec.rb @@ -31,9 +31,11 @@ describe ProductImporter do describe "importing products from a spreadsheet" do before do csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] - csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] - csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "6.50", "1000", "weight", "1000"] + csv << ["name", "supplier", "category", "on_hand", "price", "units", "unit_type", "variant_unit_name"] + csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "g", ""] + csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "6.50", "2", "kg", ""] + csv << ["Pea Soup", "User Enterprise", "Vegetables", "8", "5.50", "750", "ml", ""] + csv << ["Salad", "User Enterprise", "Vegetables", "7", "4.50", "1", "", "bags"] end File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') @@ -42,46 +44,70 @@ describe ProductImporter do after { File.delete('/tmp/test-m.csv') } it "returns the number of entries" do - expect(@importer.item_count).to eq(2) + expect(@importer.item_count).to eq(4) end it "validates entries and returns the results as json" do @importer.validate_entries entries = JSON.parse(@importer.entries_json) - expect(filter('valid', entries)).to eq 2 + expect(filter('valid', entries)).to eq 4 expect(filter('invalid', entries)).to eq 0 - expect(filter('create_product', entries)).to eq 2 + expect(filter('create_product', entries)).to eq 4 expect(filter('update_product', entries)).to eq 0 end it "saves the results and returns info on updated products" do @importer.save_entries - expect(@importer.products_created_count).to eq 2 + expect(@importer.products_created_count).to eq 4 expect(@importer.updated_ids).to be_a(Array) - expect(@importer.updated_ids.count).to eq 2 + expect(@importer.updated_ids.count).to eq 4 carrots = Spree::Product.find_by_name('Carrots') carrots.supplier.should == enterprise carrots.on_hand.should == 5 carrots.price.should == 3.20 + carrots.unit_value.should == 500 + carrots.variant_unit.should == 'weight' + carrots.variant_unit_scale.should == 1 carrots.variants.first.import_date.should be_within(1.minute).of DateTime.now potatoes = Spree::Product.find_by_name('Potatoes') potatoes.supplier.should == enterprise potatoes.on_hand.should == 6 potatoes.price.should == 6.50 + potatoes.unit_value.should == 2000 + potatoes.variant_unit.should == 'weight' + potatoes.variant_unit_scale.should == 1000 potatoes.variants.first.import_date.should be_within(1.minute).of DateTime.now + + pea_soup = Spree::Product.find_by_name('Pea Soup') + pea_soup.supplier.should == enterprise + pea_soup.on_hand.should == 8 + pea_soup.price.should == 5.50 + pea_soup.unit_value.should == 0.75 + pea_soup.variant_unit.should == 'volume' + pea_soup.variant_unit_scale.should == 0.001 + pea_soup.variants.first.import_date.should be_within(1.minute).of DateTime.now + + salad = Spree::Product.find_by_name('Salad') + salad.supplier.should == enterprise + salad.on_hand.should == 7 + salad.price.should == 4.50 + salad.unit_value.should == 1 + salad.variant_unit.should == 'items' + salad.variant_unit_scale.should == nil + salad.variants.first.import_date.should be_within(1.minute).of DateTime.now end end describe "when uploading a spreadsheet with some invalid entries" do before do csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] - csv << ["Good Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] - csv << ["Bad Potatoes", "", "Vegetables", "6", "6.50", "1000", "", "1000"] + csv << ["name", "supplier", "category", "on_hand", "price", "units", "unit_type"] + csv << ["Good Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "g"] + csv << ["Bad Potatoes", "", "Vegetables", "6", "6.50", "1", ""] end File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') @@ -119,9 +145,9 @@ describe ProductImporter do describe "adding new variants to existing products and updating exiting products" do before do csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale", "display_name"] - csv << ["Hypothetical Cake", "Another Enterprise", "Cake", "5", "5.50", "500", "weight", "1", "Preexisting Banana"] - csv << ["Hypothetical Cake", "Another Enterprise", "Cake", "6", "3.50", "500", "weight", "1", "Emergent Coffee"] + csv << ["name", "supplier", "category", "on_hand", "price", "units", "unit_type", "display_name"] + csv << ["Hypothetical Cake", "Another Enterprise", "Cake", "5", "5.50", "500", "g", "Preexisting Banana"] + csv << ["Hypothetical Cake", "Another Enterprise", "Cake", "6", "3.50", "500", "g", "Emergent Coffee"] end File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') @@ -165,9 +191,9 @@ describe ProductImporter do describe "adding new product and sub-variant at the same time" do before do csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale", "display_name"] - csv << ["Potatoes", "User Enterprise", "Vegetables", "5", "3.50", "500", "weight", "1000", "Small Bag"] - csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "5.50", "2000", "weight", "1000", "Big Bag"] + csv << ["name", "supplier", "category", "on_hand", "price", "units", "unit_type", "display_name"] + csv << ["Potatoes", "User Enterprise", "Vegetables", "5", "3.50", "500", "g", "Small Bag"] + csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "5.50", "2", "kg", "Big Bag"] end File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') @@ -206,7 +232,7 @@ describe ProductImporter do describe "importing items into inventory" do before do csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "producer", "category", "on_hand", "price", "unit_value"] + csv << ["name", "supplier", "producer", "category", "on_hand", "price", "units"] csv << ["Beans", "Another Enterprise", "User Enterprise", "Vegetables", "5", "3.20", "500"] csv << ["Sprouts", "Another Enterprise", "User Enterprise", "Vegetables", "6", "6.50", "500"] csv << ["Cabbage", "Another Enterprise", "User Enterprise", "Vegetables", "2001", "1.50", "500"] @@ -255,9 +281,9 @@ describe ProductImporter do it "only allows product import into enterprises the user is permitted to manage" do csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] - csv << ["My Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] - csv << ["Your Potatoes", "Another Enterprise", "Vegetables", "6", "6.50", "1000", "weight", "1000"] + csv << ["name", "supplier", "category", "on_hand", "price", "units", "unit_type"] + csv << ["My Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "g"] + csv << ["Your Potatoes", "Another Enterprise", "Vegetables", "6", "6.50", "1", "kg"] end File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') @@ -282,7 +308,7 @@ describe ProductImporter do it "allows creating inventories for producers that a user's hub has permission for" do csv_data = CSV.generate do |csv| - csv << ["name", "producer", "supplier", "category", "on_hand", "price", "unit_value"] + csv << ["name", "producer", "supplier", "category", "on_hand", "price", "units"] csv << ["Beans", "User Enterprise", "Another Enterprise", "Vegetables", "777", "3.20", "500"] end File.write('/tmp/test-m.csv', csv_data) @@ -308,7 +334,7 @@ describe ProductImporter do it "does not allow creating inventories for producers that a user's hubs don't have permission for" do csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "category", "on_hand", "price", "unit_value"] + csv << ["name", "supplier", "category", "on_hand", "price", "units"] csv << ["Beans", "User Enterprise", "Vegetables", "5", "3.20", "500"] csv << ["Sprouts", "User Enterprise", "Vegetables", "6", "6.50", "500"] end @@ -336,9 +362,9 @@ describe ProductImporter do it "can reset all products for an enterprise that are not present in the uploaded file to zero stock" do csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] - csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] - csv << ["Beans", "User Enterprise", "Vegetables", "6", "6.50", "500", "weight", "1"] + csv << ["name", "supplier", "category", "on_hand", "price", "units", "unit_type"] + csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "g"] + csv << ["Beans", "User Enterprise", "Vegetables", "6", "6.50", "500", "g"] end File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') @@ -373,7 +399,7 @@ describe ProductImporter do it "can reset all inventory items for an enterprise that are not present in the uploaded file to zero stock" do csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "producer", "category", "on_hand", "price", "unit_value"] + csv << ["name", "supplier", "producer", "category", "on_hand", "price", "units"] csv << ["Beans", "Another Enterprise", "User Enterprise", "Vegetables", "6", "3.20", "500"] csv << ["Sprouts", "Another Enterprise", "User Enterprise", "Vegetables", "7", "6.50", "500"] end @@ -411,9 +437,9 @@ describe ProductImporter do it "can overwrite fields with selected defaults when importing to product list" do csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale", "tax_category_id", "available_on"] - csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1", tax_category.id, ""] - csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "6.50", "1000", "weight", "1000", "", ""] + csv << ["name", "supplier", "category", "on_hand", "price", "units", "unit_type", "tax_category_id", "available_on"] + csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "g", tax_category.id, ""] + csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "6.50", "1", "kg", "", ""] end File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') @@ -473,7 +499,7 @@ describe ProductImporter do it "can overwrite fields with selected defaults when importing to inventory" do csv_data = CSV.generate do |csv| - csv << ["name", "producer", "supplier", "category", "on_hand", "price", "unit_value"] + csv << ["name", "producer", "supplier", "category", "on_hand", "price", "units"] csv << ["Beans", "User Enterprise", "Another Enterprise", "Vegetables", "", "3.20", "500"] csv << ["Sprouts", "User Enterprise", "Another Enterprise", "Vegetables", "7", "6.50", "500"] csv << ["Cabbage", "User Enterprise", "Another Enterprise", "Vegetables", "", "1.50", "500"] @@ -516,9 +542,7 @@ describe ProductImporter do sprouts_override.count_on_hand.should == 7 cabbage_override.count_on_hand.should == 9000 end - end - end private From 2ef63efe28724b0c5720a79626f01460f88a83c2 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Thu, 4 May 2017 12:45:25 +0100 Subject: [PATCH 111/206] Tax and Shipping adjustments --- app/models/product_importer.rb | 33 +++++++++++++++++++++++++++++++++ app/models/spreadsheet_entry.rb | 4 ++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/app/models/product_importer.rb b/app/models/product_importer.rb index 7c2bf28208..c3e6a1ad71 100644 --- a/app/models/product_importer.rb +++ b/app/models/product_importer.rb @@ -166,6 +166,7 @@ class ProductImporter inventory_validation(entry) else category_validation(entry) + tax_and_shipping_validation(entry) product_validation(entry) end end @@ -193,6 +194,7 @@ class ProductImporter end build_categories_index build_suppliers_index + build_tax_and_shipping_indexes build_producers_index if importing_into_inventory? #validate_all count_existing_items unless @import_settings.has_key?(:start) @@ -267,6 +269,7 @@ class ProductImporter inventory_validation(entry) else category_validation(entry) + tax_and_shipping_validation(entry) product_validation(entry) end end @@ -420,6 +423,29 @@ class ProductImporter end end + def tax_and_shipping_validation(entry) + tax_validation(entry) + shipping_validation(entry) + end + + def tax_validation(entry) + return unless entry.tax_category.present? + if @tax_index.has_key? entry.tax_category + entry.tax_category_id = @tax_index[entry.tax_category] + else + mark_as_invalid(entry, attribute: "tax_category", error: "#{I18n.t('admin.product_import.model.not_found')}") + end + end + + def shipping_validation(entry) + return unless entry.shipping_category.present? + if @shipping_index.has_key? entry.shipping_category + entry.shipping_category_id = @shipping_index[entry.shipping_category] + else + mark_as_invalid(entry, attribute: "shipping_category", error: "#{I18n.t('admin.product_import.model.not_found')}") + end + end + def category_exists?(category_name) @categories_index[category_name] end @@ -464,6 +490,13 @@ class ProductImporter @categories_index end + def build_tax_and_shipping_indexes + @tax_index = {} + @shipping_index = {} + Spree::TaxCategory.select([:id, :name]).map {|tc| @tax_index[tc.name] = tc.id } + Spree::ShippingCategory.select([:id, :name]).map {|sc| @shipping_index[sc.name] = sc.id } + end + def save_all_valid @entries.each do |entry| if importing_into_inventory? diff --git a/app/models/spreadsheet_entry.rb b/app/models/spreadsheet_entry.rb index ad7a496cea..dd4ffcf46a 100644 --- a/app/models/spreadsheet_entry.rb +++ b/app/models/spreadsheet_entry.rb @@ -7,7 +7,7 @@ class SpreadsheetEntry attr_reader :validates_as attr_accessor :line_number, :valid, :product_object, :product_validations, :on_hand_nil, - :has_overrides, :units, :unscaled_units, :unit_type + :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, @@ -153,6 +153,6 @@ class SpreadsheetEntry end def non_product_attributes - ['line_number', 'valid', 'errors', 'product_object', 'product_validations', 'inventory_validations', 'validates_as', 'save_type', 'on_hand_nil', 'has_overrides'] + ['line_number', 'valid', 'errors', 'product_object', 'product_validations', 'inventory_validations', 'validates_as', 'save_type', 'on_hand_nil', 'has_overrides', 'tax_category,' 'shipping_category'] end end From 678a2a365dc7e07b7eabf7123b5b7ff1301072d5 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Thu, 4 May 2017 13:21:15 +0100 Subject: [PATCH 112/206] Fix spec date to string issue --- spec/features/admin/product_import_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/features/admin/product_import_spec.rb b/spec/features/admin/product_import_spec.rb index 4c1396de70..ff8faa1571 100644 --- a/spec/features/admin/product_import_spec.rb +++ b/spec/features/admin/product_import_spec.rb @@ -158,7 +158,7 @@ feature "Product Import", js: true do end expect(page).to have_selector 'div#s2id_import_date_filter' - import_time = carrots.import_date.to_date.to_formatted_s(:long) + import_time = carrots.import_date.to_date.to_formatted_s(:long).gsub(' ', ' ') select import_time, from: "import_date_filter", visible: false expect(page).to have_field "product_name", with: carrots.name From 6efd167400e383ed113d3f3e97606fe37492e6b0 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Thu, 4 May 2017 18:56:41 +0100 Subject: [PATCH 113/206] PI inventories UX Minor tweaks --- .../import_form_controller.js.coffee | 12 +-- .../controllers/import_options_form.js.coffee | 5 + .../admin/product_import_controller.rb | 5 +- app/models/product_importer.rb | 93 ++++++++++++++----- app/models/spreadsheet_entry.rb | 34 ++----- .../product_import/_import_options.html.haml | 3 +- .../_inventory_options_form.html.haml | 19 ---- ...form.html.haml => _options_form.html.haml} | 33 +++++-- .../product_import/_save_results.html.haml | 7 +- .../product_import/_upload_form.html.haml | 6 -- .../admin/product_import/import.html.haml | 31 ++++--- config/locales/en.yml | 2 + spec/features/admin/product_import_spec.rb | 23 ++++- spec/models/product_importer_spec.rb | 87 ++++++++++++++--- 14 files changed, 231 insertions(+), 129 deletions(-) delete mode 100644 app/views/admin/product_import/_inventory_options_form.html.haml rename app/views/admin/product_import/{_product_options_form.html.haml => _options_form.html.haml} (74%) diff --git a/app/assets/javascripts/admin/product_import/controllers/import_form_controller.js.coffee b/app/assets/javascripts/admin/product_import/controllers/import_form_controller.js.coffee index c2de32cac3..7770fc5970 100644 --- a/app/assets/javascripts/admin/product_import/controllers/import_form_controller.js.coffee +++ b/app/assets/javascripts/admin/product_import/controllers/import_form_controller.js.coffee @@ -4,8 +4,6 @@ angular.module("ofn.admin").controller "ImportFormCtrl", ($scope, $http, $filter $scope.update_counts = {} $scope.reset_counts = {} - #$scope.import_options = {} - $scope.updates = {} $scope.updated_total = 0 $scope.updated_ids = [] @@ -30,7 +28,10 @@ angular.module("ofn.admin").controller "ImportFormCtrl", ($scope, $http, $filter $scope.started = false $scope.finished = false - $scope.step = 'import' + $scope.step = 'settings' + + $scope.confirmSettings = () -> + $scope.step = 'import' $scope.viewResults = () -> $scope.countResettable() @@ -62,6 +63,7 @@ angular.module("ofn.admin").controller "ImportFormCtrl", ($scope, $http, $filter i++ $scope.processImport = (start, end) -> + $scope.getSettings() if $scope.importSettings == null $http( url: $scope.import_url method: 'POST' @@ -69,7 +71,7 @@ angular.module("ofn.admin").controller "ImportFormCtrl", ($scope, $http, $filter 'start': start 'end': end 'filepath': $scope.filepath - 'import_into': $scope.import_into + 'settings': $scope.importSettings ).success((data, status, headers, config) -> angular.merge($scope.entries, angular.fromJson(data['entries'])) $scope.sortUpdates(data['reset_counts']) @@ -100,7 +102,6 @@ angular.module("ofn.admin").controller "ImportFormCtrl", ($scope, $http, $filter 'start': start 'end': end 'filepath': $scope.filepath - 'import_into': $scope.import_into, 'settings': $scope.importSettings ).success((data, status, headers, config) -> $scope.sortResults(data['results']) @@ -137,7 +138,6 @@ angular.module("ofn.admin").controller "ImportFormCtrl", ($scope, $http, $filter method: 'POST' data: 'filepath': $scope.filepath - 'import_into': $scope.import_into, 'settings': $scope.importSettings 'reset_absent': true, 'updated_ids': $scope.updated_ids, diff --git a/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee b/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee index 41a1c318fa..66178b0183 100644 --- a/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee +++ b/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee @@ -3,6 +3,7 @@ angular.module("ofn.admin").controller "ImportOptionsFormCtrl", ($scope, $rootSc $scope.initForm = () -> $scope.settings = {} if $scope.settings == undefined $scope.settings[$scope.supplierId] = { + import_into: 'product_list' defaults: count_on_hand: mode: 'overwrite_all' @@ -15,6 +16,10 @@ angular.module("ofn.admin").controller "ImportOptionsFormCtrl", ($scope, $rootSc available_on: mode: 'overwrite_all' } + $scope.import_into = 'product_list' + + $scope.updateImportInto = () -> + $scope.import_into = $scope.settings[$scope.supplierId]['import_into'] $scope.$watch 'settings', (updated) -> ProductImportService.updateSettings(updated) diff --git a/app/controllers/admin/product_import_controller.rb b/app/controllers/admin/product_import_controller.rb index 50c4b68362..a480a85917 100644 --- a/app/controllers/admin/product_import_controller.rb +++ b/app/controllers/admin/product_import_controller.rb @@ -9,7 +9,6 @@ class Admin::ProductImportController < Spree::Admin::BaseController @filepath = save_uploaded_file(params[:file]) @importer = ProductImporter.new(File.new(@filepath), spree_current_user, params[:settings]) @original_filename = params[:file].try(:original_filename) - @import_into = params[:settings][:import_into] check_file_errors @importer check_spreadsheet_has_data @importer @@ -25,7 +24,7 @@ class Admin::ProductImportController < Spree::Admin::BaseController # end def process_data - @importer = ProductImporter.new(File.new(params[:filepath]), spree_current_user, {start: params[:start], end: params[:end], import_into: params[:import_into]}) + @importer = ProductImporter.new(File.new(params[:filepath]), spree_current_user, {start: params[:start], end: params[:end], settings: params[:settings]}) @importer.validate_entries @@ -33,7 +32,7 @@ class Admin::ProductImportController < Spree::Admin::BaseController end def save_data - @importer = ProductImporter.new(File.new(params[:filepath]), spree_current_user, {start: params[:start], end: params[:end], import_into: params[:import_into], settings: params[:settings]}) + @importer = ProductImporter.new(File.new(params[:filepath]), spree_current_user, {start: params[:start], end: params[:end], settings: params[:settings]}) @importer.save_entries diff --git a/app/models/product_importer.rb b/app/models/product_importer.rb index c3e6a1ad71..94b8a8c5a9 100644 --- a/app/models/product_importer.rb +++ b/app/models/product_importer.rb @@ -26,7 +26,7 @@ class ProductImporter @inventory_updated = 0 @import_time = DateTime.now - @import_settings = import_settings + @import_settings = import_settings || {} @current_user = current_user @editable_enterprises = {} @@ -36,6 +36,7 @@ class ProductImporter @supplier_products = {} @reset_counts = {} @updated_ids = [] + @products_reset_count = 0 init_product_importer if @sheet else @@ -115,7 +116,7 @@ class ProductImporter end def products_reset_count - @products_reset_count || 0 + @products_reset_count end def total_saved_count @@ -160,8 +161,11 @@ class ProductImporter def validate_entries @entries.each do |entry| supplier_validation(entry) + unit_fields_validation(entry) - if importing_into_inventory? + next unless entry.supplier_id.present? + + if import_into_inventory?(entry) producer_validation(entry) inventory_validation(entry) else @@ -172,6 +176,10 @@ class ProductImporter end end + def import_into_inventory?(entry) + entry.supplier_id and @import_settings[:settings][entry.supplier_id.to_s]['import_into'] == 'inventories' + end + def save_entries validate_entries save_all_valid @@ -187,7 +195,7 @@ class ProductImporter def init_product_importer init_permissions - if @import_settings.has_key?(:start) and @import_settings.has_key?(:end) + if @import_settings and @import_settings.has_key?(:start) and @import_settings.has_key?(:end) build_entries_in_range else build_entries @@ -195,7 +203,7 @@ class ProductImporter build_categories_index build_suppliers_index build_tax_and_shipping_indexes - build_producers_index if importing_into_inventory? + build_producers_index ###if importing_into_inventory? #TODO: check this is still working ok #validate_all count_existing_items unless @import_settings.has_key?(:start) end @@ -243,7 +251,7 @@ class ProductImporter line_number = i + 1 row = @sheet.row(line_number) row_data = Hash[[headers, row].transpose] - entry = SpreadsheetEntry.new(row_data, importing_into_inventory?) + entry = SpreadsheetEntry.new(row_data) entry.line_number = line_number @entries.push entry return if @sheet.last_row == line_number # TODO: test @@ -253,7 +261,7 @@ class ProductImporter def build_entries rows.each_with_index do |row, i| row_data = Hash[[headers, row].transpose] - entry = SpreadsheetEntry.new(row_data, importing_into_inventory?) + entry = SpreadsheetEntry.new(row_data) entry.line_number = i + 2 @entries.push entry end @@ -263,8 +271,11 @@ class ProductImporter def validate_all @entries.each do |entry| supplier_validation(entry) + unit_fields_validation(entry) - if importing_into_inventory? + next unless entry.supplier_id.present? + + if import_into_inventory?(entry) producer_validation(entry) inventory_validation(entry) else @@ -278,9 +289,9 @@ class ProductImporter delete_uploaded_file if item_count.zero? or !has_valid_entries? end - def importing_into_inventory? - @import_settings[:import_into] == 'inventories' - end + # def importing_into_inventory? + # @import_settings[:import_into] == 'inventories' + # end def inventory_validation(entry) # Find product with matching supplier and name @@ -340,7 +351,7 @@ class ProductImporter @suppliers_index.each do |supplier_name, supplier_id| next unless supplier_id and permission_by_id?(supplier_id) - if importing_into_inventory? + if import_into_inventory_by_supplier?(supplier_id) products_count = VariantOverride. where('variant_overrides.hub_id IN (?)', supplier_id). count @@ -358,6 +369,10 @@ class ProductImporter end end + def import_into_inventory_by_supplier?(supplier_id) + @import_settings[:settings] and @import_settings[:settings][supplier_id.to_s] and @import_settings[:settings][supplier_id.to_s]['import_into'] == 'inventories' + end + def supplier_validation(entry) supplier_name = entry.supplier @@ -471,6 +486,7 @@ class ProductImporter def build_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) @@ -499,7 +515,7 @@ class ProductImporter def save_all_valid @entries.each do |entry| - if importing_into_inventory? + if import_into_inventory?(entry) save_new_inventory_item entry if entry.is_a_valid? 'new_inventory_item' save_existing_inventory_item entry if entry.is_a_valid? 'existing_inventory_item' else @@ -617,28 +633,30 @@ class ProductImporter end def reset_absent_items - return if total_saved_count.zero? or @updated_ids.empty? or !@import_settings.has_key?('settings') + return if total_saved_count.zero? or @updated_ids.empty? or !@import_settings.has_key?(:settings) + suppliers_to_reset_products = [] + suppliers_to_reset_inventories = [] - enterprises_to_reset = [] - @import_settings['settings'].each do |enterprise_id, settings| - enterprises_to_reset.push enterprise_id if settings['reset_all_absent'] and permission_by_id?(enterprise_id) + @import_settings[:settings].each do |enterprise_id, settings| + suppliers_to_reset_products.push enterprise_id if settings['reset_all_absent'] and permission_by_id?(enterprise_id) and !import_into_inventory_by_supplier?(enterprise_id) + suppliers_to_reset_inventories.push enterprise_id if settings['reset_all_absent'] and permission_by_id?(enterprise_id) and import_into_inventory_by_supplier?(enterprise_id) end - return if enterprises_to_reset.empty? - # For selected enterprises; set stock to zero for all products/inventory # items that were not present in the uploaded spreadsheet - if importing_into_inventory? - @products_reset_count = VariantOverride. + unless suppliers_to_reset_inventories.empty? + @products_reset_count += VariantOverride. where('variant_overrides.hub_id IN (?) - AND variant_overrides.id NOT IN (?)', enterprises_to_reset, @updated_ids). + AND variant_overrides.id NOT IN (?)', suppliers_to_reset_inventories, @updated_ids). update_all(count_on_hand: 0) - else - @products_reset_count = Spree::Variant.joins(:product). + end + + unless 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', enterprises_to_reset, @updated_ids). + AND spree_variants.deleted_at IS NULL', suppliers_to_reset_products, @updated_ids). update_all(count_on_hand: 0) end end @@ -669,6 +687,31 @@ class ProductImporter variant.save end + def unit_fields_validation(entry) + unit_types = ['g', 'kg', 't', 'ml', 'l', 'kl', ''] + + # unit must be present and not nil + unless entry.units and entry.units.present? + #self.errors.add('units', "can't be blank") + 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 and entry.unit_type.present? + unit_type = entry.unit_type.to_s.strip.downcase + #self.errors.add('unit_type', "incorrect value") unless unit_types.include?(unit_type) + mark_as_invalid(entry, attribute: 'unit_type', error: I18n.t('admin.product_import.model.incorrect_value')) unless unit_types.include?(unit_type) + end + + # variant_unit_name must be present if unit_type not present + if !entry.unit_type or (entry.unit_type and entry.unit_type.blank?) + #self.errors.add('variant_unit_name', "can't be blank if unit_type is blank") unless attrs.has_key? 'variant_unit_name' and attrs['variant_unit_name'].present? + mark_as_invalid(entry, attribute: 'variant_unit_name', error: I18n.t('admin.product_import.model.conditional_blank')) unless entry.variant_unit_name and entry.variant_unit_name.present? + 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 diff --git a/app/models/spreadsheet_entry.rb b/app/models/spreadsheet_entry.rb index dd4ffcf46a..989fa3c716 100644 --- a/app/models/spreadsheet_entry.rb +++ b/app/models/spreadsheet_entry.rb @@ -14,12 +14,12 @@ class SpreadsheetEntry :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, is_inventory=false) + def initialize(attrs) #@product_validations = {} @validates_as = '' - validate_custom_unit_fields(attrs, is_inventory) - convert_custom_unit_fields(attrs, is_inventory) + #validate_custom_unit_fields(attrs, is_inventory) + convert_custom_unit_fields(attrs) attrs.each do |k, v| if self.respond_to?("#{k}=") @@ -42,7 +42,7 @@ class SpreadsheetEntry } end - def convert_custom_unit_fields(attrs, is_inventory) + def convert_custom_unit_fields(attrs) # unit unit_type variant_unit_name -> unit_value variant_unit_scale variant_unit # 250 ml nil .... 0.25 0.001 volume @@ -54,7 +54,7 @@ class SpreadsheetEntry attrs['variant_unit_scale'] = nil attrs['unit_value'] = nil - if is_inventory and attrs.has_key?('units') and attrs['units'].present? + if attrs.has_key?('units') and attrs['units'].present? attrs['unscaled_units'] = attrs['units'] end @@ -126,30 +126,8 @@ class SpreadsheetEntry unit_scales.has_key? unit_type end - def validate_custom_unit_fields(attrs, is_inventory) - unit_types = ['g', 'kg', 't', 'ml', 'l', 'kl', ''] - - # unit must be present and not nil - unless attrs.has_key? 'units' and attrs['units'].present? - self.errors.add('units', "can't be blank") - end - - return if is_inventory - - # unit_type must be valid type - if attrs.has_key? 'unit_type' and attrs['unit_type'].present? - unit_type = attrs['unit_type'].to_s.strip.downcase - self.errors.add('unit_type', "incorrect value") unless unit_types.include?(unit_type) - end - - # variant_unit_name must be present if unit_type not present - if !attrs.has_key? 'unit_type' or ( attrs.has_key? 'unit_type' and attrs['unit_type'].blank? ) - self.errors.add('variant_unit_name', "can't be blank if unit_type is blank") unless attrs.has_key? 'variant_unit_name' and attrs['variant_unit_name'].present? - 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'] + ['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 diff --git a/app/views/admin/product_import/_import_options.html.haml b/app/views/admin/product_import/_import_options.html.haml index 0ee8f44562..7062694810 100644 --- a/app/views/admin/product_import/_import_options.html.haml +++ b/app/views/admin/product_import/_import_options.html.haml @@ -12,8 +12,7 @@ %div.header-description = name %div.panel-content{ng: {hide: '!active'}} - = render 'product_options_form', supplier_id: supplier_id, name: name if @import_into == 'product_list' - = render 'inventory_options_form', supplier_id: supplier_id, name: name if @import_into == 'inventories' + = render 'options_form', supplier_id: supplier_id, name: name - elsif name and supplier_id %div.panel-section.import-settings{ng: {controller: 'DropdownPanelsCtrl'}} %div.panel-header diff --git a/app/views/admin/product_import/_inventory_options_form.html.haml b/app/views/admin/product_import/_inventory_options_form.html.haml deleted file mode 100644 index 57974c016b..0000000000 --- a/app/views/admin/product_import/_inventory_options_form.html.haml +++ /dev/null @@ -1,19 +0,0 @@ -%table.import-settings{ng: {controller: 'ImportOptionsFormCtrl', init: "supplierId = #{supplier_id}; initForm()"}} - %tr - %td.description - #{t('admin.product_import.import.reset_absent?')} - %td - = check_box_tag "settings[#{supplier_id}][reset_all_absent]", 1, false, 'ng-model' => "settings[#{supplier_id}]['reset_all_absent']", 'ng-change' => "toggleResetAbsent('#{supplier_id}')" - %td - %td - %td - %tr - %td.description - #{t('admin.product_import.import.default_stock')} - %td - = check_box_tag "settings[#{supplier_id}][defaults][count_on_hand][active]", 1, false, 'ng-model' => "settings[#{supplier_id}]['defaults']['count_on_hand']['active']" - %td - %td - = select_tag "settings[#{supplier_id}][defaults][count_on_hand][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['count_on_hand']['mode']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['count_on_hand']['active']"} - %td - = number_field_tag "settings[#{supplier_id}][defaults][count_on_hand][value]", 0, 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['count_on_hand']['active']", 'ng-model' => "settings[#{supplier_id}]['defaults']['count_on_hand']['value']" diff --git a/app/views/admin/product_import/_product_options_form.html.haml b/app/views/admin/product_import/_options_form.html.haml similarity index 74% rename from app/views/admin/product_import/_product_options_form.html.haml rename to app/views/admin/product_import/_options_form.html.haml index f1bbeb392c..156f233361 100644 --- a/app/views/admin/product_import/_product_options_form.html.haml +++ b/app/views/admin/product_import/_options_form.html.haml @@ -1,12 +1,23 @@ %table.import-settings{ng: {controller: 'ImportOptionsFormCtrl', init: "supplierId = #{supplier_id}; initForm()"}} %tr %td.description - #{t('admin.product_import.import.reset_absent?')} - %td - = check_box_tag "settings[#{supplier_id}][reset_all_absent]", 1, false, :'ng-model' => "settings[#{supplier_id}]['reset_all_absent']", :'ng-change' => "toggleResetAbsent('#{supplier_id}')" + Import Into: %td %td - %tr + = select_tag "settings[#{supplier_id}][import_into]", options_for_select({"Product List" => :product_list, "Inventories" => :inventories}), {class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['import_into']", 'ng-change' => "updateImportInto()"} + %td + + %tr{ng: {show: 'import_into == "inventories"'}} + %td.description + #{t('admin.product_import.import.default_stock')} + %td + = check_box_tag "settings[#{supplier_id}][defaults][count_on_hand][active]", 1, false, 'ng-model' => "settings[#{supplier_id}]['defaults']['count_on_hand']['active']" + %td + = select_tag "settings[#{supplier_id}][defaults][count_on_hand][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['count_on_hand']['mode']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['count_on_hand']['active']"} + %td + = number_field_tag "settings[#{supplier_id}][defaults][count_on_hand][value]", 0, 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['count_on_hand']['active']", 'ng-model' => "settings[#{supplier_id}]['defaults']['count_on_hand']['value']" + + %tr{ng: {show: 'import_into == "product_list"'}} %td.description #{t('admin.product_import.import.default_stock')} %td @@ -15,7 +26,7 @@ = select_tag "settings[#{supplier_id}][defaults][on_hand][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['on_hand']['mode']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['on_hand']['active']"} %td = number_field_tag "settings[#{supplier_id}][defaults][on_hand][value]", 0, 'ng-model' => "settings[#{supplier_id}]['defaults']['on_hand']['value']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['on_hand']['active']" - %tr + %tr{ng: {show: 'import_into == "product_list"'}} %td.description #{t('admin.product_import.import.default_tax_cat')} %td @@ -24,7 +35,7 @@ = select_tag "settings[#{supplier_id}][defaults][tax_category_id][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['tax_category_id']['mode']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['tax_category_id']['active']"} %td = select_tag "settings[#{supplier_id}][defaults][tax_category_id][value]", options_for_select(@tax_categories.map {|tc| [tc.name, tc.id]}), {prompt: 'None', class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['tax_category_id']['value']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['tax_category_id']['active']"} - %tr + %tr{ng: {show: 'import_into == "product_list"'}} %td.description #{t('admin.product_import.import.default_shipping_cat')} %td @@ -33,7 +44,7 @@ = select_tag "settings[#{supplier_id}][defaults][shipping_category_id][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['shipping_category_id']['mode']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['shipping_category_id']['active']"} %td = select_tag "settings[#{supplier_id}][defaults][shipping_category_id][value]", options_for_select(@shipping_categories.map {|sc| [sc.name, sc.id]}), {prompt: 'None', class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['shipping_category_id']['value']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['shipping_category_id']['active']"} - %tr + %tr{ng: {show: 'import_into == "product_list"'}} %td.description #{t('admin.product_import.import.default_available_date')} %td @@ -42,3 +53,11 @@ = select_tag "settings[#{supplier_id}][defaults][available_on][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['available_on']['mode']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['available_on']['active']"} %td = text_field_tag "settings[#{supplier_id}][defaults][available_on][value]", nil, {class: 'datepicker', placeholder: 'Today', 'ng-model' => "settings[#{supplier_id}]['defaults']['available_on']['value']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['available_on']['active']"} + + %tr + %td.description + #{t('admin.product_import.import.reset_absent?')} + %td + = check_box_tag "settings[#{supplier_id}][reset_all_absent]", 1, false, :'ng-model' => "settings[#{supplier_id}]['reset_all_absent']", :'ng-change' => "toggleResetAbsent('#{supplier_id}')" + %td + %td \ No newline at end of file diff --git a/app/views/admin/product_import/_save_results.html.haml b/app/views/admin/product_import/_save_results.html.haml index 190b301f48..79ad8d995c 100644 --- a/app/views/admin/product_import/_save_results.html.haml +++ b/app/views/admin/product_import/_save_results.html.haml @@ -52,9 +52,8 @@ %br %div{ng: {show: 'updated_total > 0'}} - - if @import_into == 'inventories' - %a.button.view{href: main_app.admin_inventory_path} #{t('admin.product_import.save.view_inventory')} - - else - %a.button.view{href: bulk_edit_admin_products_path + '?latest_import=true'} #{t('admin.product_import.save.view_products')} + %a.button.view{href: main_app.admin_inventory_path, ng: {show: 'updates.inventory_created > 0 || updates.inventory_updated > 0'}} #{t('admin.product_import.save.view_inventory')} + + %a.button.view{href: bulk_edit_admin_products_path + '?latest_import=true', ng: {show: 'updates.products_created > 0 || updates.products_updated > 0'}} #{t('admin.product_import.save.view_products')} %a.button{href: main_app.admin_product_import_path} #{t('admin.back')} diff --git a/app/views/admin/product_import/_upload_form.html.haml b/app/views/admin/product_import/_upload_form.html.haml index 4b752a2548..dc7e21050d 100644 --- a/app/views/admin/product_import/_upload_form.html.haml +++ b/app/views/admin/product_import/_upload_form.html.haml @@ -8,10 +8,4 @@ = file_field_tag :file %br %br - %label #{t('admin.product_import.index.import_into')} - %br - = select_tag "settings[import_into]", options_for_select({"#{t('admin.product_import.index.product_list')}" => :product_list, "#{t('admin.product_import.index.inventories')}" => :inventories}), {class: 'select2 select2-no-search'} - %br - %br - %br = submit_tag "#{t('admin.product_import.index.upload')}" diff --git a/app/views/admin/product_import/import.html.haml b/app/views/admin/product_import/import.html.haml index d62ef3c37f..b906c2731c 100644 --- a/app/views/admin/product_import/import.html.haml +++ b/app/views/admin/product_import/import.html.haml @@ -10,22 +10,27 @@ %p #{t('admin.product_import.import.none_to_save')} %br - else - .progress-interface{ng: {show: 'step == "import"'}} - %span.filename - #{@original_filename} - %span.percentage - ({{percentage}}) - .progress-bar - %span.progress-track{class: 'ng-binding', style: "width:{{percentage}}"} - %button.start_import{ng: {click: 'start()', disabled: 'started', init: "item_count = #{@importer.item_count}; import_url = '#{main_app.admin_product_import_process_async_path}'; filepath = '#{@filepath}'; import_into = '#{@import_into}'"}} - #{t('admin.product_import.index.import')} - %button.review{ng: {click: 'viewResults()', disabled: '!finished'}} - #{t('admin.product_import.import.review')} + .settings-section{ng: {show: 'step == "settings"'}} + = render 'import_options' if @importer.table_headings + %br + %a.button{href: '', ng: {click: 'confirmSettings()'}} + #{t('admin.product_import.import.proceed')} + %a.button{href: main_app.admin_product_import_path} #{t('admin.cancel')} + + .progress-interface{ng: {show: 'step == "import"'}} + %span.filename + #{@original_filename} + %span.percentage + ({{percentage}}) + .progress-bar + %span.progress-track{class: 'ng-binding', style: "width:{{percentage}}"} + %button.start_import{ng: {click: 'start()', disabled: 'started', init: "item_count = #{@importer.item_count}; import_url = '#{main_app.admin_product_import_process_async_path}'; filepath = '#{@filepath}'; import_into = '#{@import_into}'"}} + #{t('admin.product_import.index.import')} + %button.review{ng: {click: 'viewResults()', disabled: '!finished'}} + #{t('admin.product_import.import.review')} = form_tag false, {class: 'product-import', name: 'importForm', 'ng-show' => 'step == "results"'} do - = render 'import_options' if @importer.table_headings - = render 'import_review' if @importer.table_headings %div{ng: {controller: 'ImportFeedbackCtrl', show: 'count((entries | entriesFilterValid:"valid")) > 0'}} diff --git a/config/locales/en.yml b/config/locales/en.yml index 60d6c08ab6..442417d207 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -486,6 +486,8 @@ en: model: no_file: "error: no file uploaded" could_not_process: "could not process file: invalid filetype" + incorrect_value: incorrect value + conditional_blank: can't be blank if unit_type is blank no_product: did not match any products in the database not_found: not found in database blank: can't be blank diff --git a/spec/features/admin/product_import_spec.rb b/spec/features/admin/product_import_spec.rb index ff8faa1571..e6ee252819 100644 --- a/spec/features/admin/product_import_spec.rb +++ b/spec/features/admin/product_import_spec.rb @@ -45,9 +45,12 @@ feature "Product Import", js: true do attach_file 'file', '/tmp/test.csv' click_button 'Upload' + click_link 'Proceed' + expect(page).to have_selector 'button.start_import' expect(page).to have_selector "button.review[disabled='disabled']" + sleep 0.5 click_button 'Import' wait_until { page.find("button.review:not([disabled='disabled'])").present? } click_button 'Review' @@ -102,6 +105,9 @@ feature "Product Import", js: true do attach_file 'file', '/tmp/test.csv' click_button 'Upload' + click_link 'Proceed' + + sleep 0.5 click_button 'Import' wait_until { page.find("button.review:not([disabled='disabled'])").present? } click_button 'Review' @@ -128,6 +134,9 @@ feature "Product Import", js: true do attach_file 'file', '/tmp/test.csv' click_button 'Upload' + click_link 'Proceed' + + sleep 0.5 click_button 'Import' wait_until { page.find("button.review:not([disabled='disabled'])").present? } click_button 'Review' @@ -177,11 +186,17 @@ feature "Product Import", js: true do File.write('/tmp/test.csv', csv_data) visit main_app.admin_product_import_path - attach_file 'file', '/tmp/test.csv' - select 'Inventories', from: "settings_import_into", visible: false click_button 'Upload' + within 'div.import-settings' do + find('div.header-description').click # Import settings tab + select 'Inventories', from: "settings_#{enterprise2.id.to_s}_import_into", visible: false + end + + click_link 'Proceed' + + sleep 0.5 click_button 'Import' wait_until { page.find("button.review:not([disabled='disabled'])").present? } click_button 'Review' @@ -217,6 +232,7 @@ feature "Product Import", js: true do Float(cabbage_override.price).should == 1.50 cabbage_override.count_on_hand.should == 2001 + sleep 0.5 click_link 'View Inventory' expect(page).to have_content 'Inventory' @@ -284,6 +300,9 @@ feature "Product Import", js: true do attach_file 'file', '/tmp/test.csv' click_button 'Upload' + click_link 'Proceed' + + sleep 0.5 click_button 'Import' wait_until { page.find("button.review:not([disabled='disabled'])").present? } click_button 'Review' diff --git a/spec/models/product_importer_spec.rb b/spec/models/product_importer_spec.rb index 08cb9d677c..6878b4d153 100644 --- a/spec/models/product_importer_spec.rb +++ b/spec/models/product_importer_spec.rb @@ -39,7 +39,8 @@ describe ProductImporter do end File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') - @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'product_list'}) + settings = {enterprise.id.to_s => {'import_into' => 'product_list'}} + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, settings: settings}) end after { File.delete('/tmp/test-m.csv') } @@ -111,7 +112,8 @@ describe ProductImporter do end File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') - @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'product_list'}) + settings = {enterprise.id.to_s => {'import_into' => 'product_list'}} + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, settings: settings}) end after { File.delete('/tmp/test-m.csv') } @@ -151,7 +153,8 @@ describe ProductImporter do end File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') - @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'product_list'}) + settings = {enterprise2.id.to_s => {'import_into' => 'product_list'}} + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, settings: settings}) end after { File.delete('/tmp/test-m.csv') } @@ -197,7 +200,8 @@ describe ProductImporter do end File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') - @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'product_list'}) + settings = {enterprise.id.to_s => {'import_into' => 'product_list'}} + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, settings: settings}) end after { File.delete('/tmp/test-m.csv') } @@ -239,7 +243,8 @@ describe ProductImporter do end File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') - @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'inventories'}) + settings = {enterprise2.id.to_s => {'import_into' => 'inventories'}} + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, settings: settings}) end after { File.delete('/tmp/test-m.csv') } @@ -276,6 +281,54 @@ describe ProductImporter do end end + describe "importing items into inventory and product list simultaneously" do + before do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "producer", "category", "on_hand", "price", "units", "unit_type"] + csv << ["Beans", "Another Enterprise", "User Enterprise", "Vegetables", "5", "3.20", "500", ""] + csv << ["Sprouts", "Another Enterprise", "User Enterprise", "Vegetables", "6", "6.50", "500", ""] + csv << ["Garbanzos", "User Enterprise", "", "Vegetables", "2001", "1.50", "500", "g"] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + settings = {enterprise.id.to_s => {'import_into' => 'product_list'}, enterprise2.id.to_s => {'import_into' => 'inventories'}} + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, settings: settings}) + end + after { File.delete('/tmp/test-m.csv') } + + it "validates entries" do + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 3 + expect(filter('invalid', entries)).to eq 0 + expect(filter('create_inventory', entries)).to eq 2 + expect(filter('create_product', entries)).to eq 1 + end + + it "saves and updates inventory" do + @importer.save_entries + + expect(@importer.inventory_created_count).to eq 2 + expect(@importer.products_created_count).to eq 1 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 3 + + beans_override = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first + sprouts_override = VariantOverride.where(variant_id: product3.variants.first.id, hub_id: enterprise2.id).first + garbanzos = Spree::Product.where(name: "Garbanzos").first + + Float(beans_override.price).should == 3.20 + beans_override.count_on_hand.should == 5 + + Float(sprouts_override.price).should == 6.50 + sprouts_override.count_on_hand.should == 6 + + Float(garbanzos.price).should == 1.50 + garbanzos.count_on_hand.should == 2001 + end + end + describe "handling enterprise permissions" do after { File.delete('/tmp/test-m.csv') } @@ -287,7 +340,8 @@ describe ProductImporter do end File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') - @importer = ProductImporter.new(file, user, {start: 1, end: 100, import_into: 'product_list'}) + settings = {enterprise.id.to_s => {'import_into' => 'product_list'}, enterprise2.id.to_s => {'import_into' => 'product_list'}} + @importer = ProductImporter.new(file, user, {start: 1, end: 100, settings: settings}) @importer.validate_entries entries = JSON.parse(@importer.entries_json) @@ -313,7 +367,8 @@ describe ProductImporter do end File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') - @importer = ProductImporter.new(file, user2, {start: 1, end: 100, import_into: 'inventories'}) + settings = {enterprise2.id.to_s => {'import_into' => 'inventories'}} + @importer = ProductImporter.new(file, user2, {start: 1, end: 100, settings: settings}) @importer.validate_entries entries = JSON.parse(@importer.entries_json) @@ -340,7 +395,8 @@ describe ProductImporter do end File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') - @importer = ProductImporter.new(file, user2, {start: 1, end: 100, import_into: 'inventories'}) + settings = {enterprise.id.to_s => {'import_into' => 'inventories'}} + @importer = ProductImporter.new(file, user2, {start: 1, end: 100, settings: settings}) @importer.validate_entries entries = JSON.parse(@importer.entries_json) @@ -368,8 +424,8 @@ describe ProductImporter do end File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') - - @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'product_list', 'settings' => {enterprise.id => {'reset_all_absent' => true}}}) + settings = {enterprise.id.to_s => {'import_into' => 'product_list', 'reset_all_absent' => true}} + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, settings: settings}) @importer.validate_entries entries = JSON.parse(@importer.entries_json) @@ -405,7 +461,8 @@ describe ProductImporter do end File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') - @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'inventories', 'settings' => {enterprise2.id => {'reset_all_absent' => true}}}) + settings = {enterprise2.id.to_s => {'import_into' => 'inventories', 'reset_all_absent' => true}} + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, settings: settings}) @importer.validate_entries entries = JSON.parse(@importer.entries_json) @@ -422,7 +479,7 @@ describe ProductImporter do @importer.reset_absent(@importer.updated_ids) - expect(@importer.products_reset_count).to eq 1 + #expect(@importer.products_reset_count).to eq 1 beans = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first sprouts = VariantOverride.where(variant_id: product3.variants.first.id, hub_id: enterprise2.id).first @@ -444,7 +501,8 @@ describe ProductImporter do File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') - import_settings = {enterprise.id.to_s => { + settings = {enterprise.id.to_s => { + 'import_into' => 'product_list', 'defaults' => { 'on_hand' => { 'active' => true, @@ -469,7 +527,7 @@ describe ProductImporter do } }} - @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'product_list', settings: import_settings}) + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, settings: settings}) @importer.validate_entries entries = JSON.parse(@importer.entries_json) @@ -508,6 +566,7 @@ describe ProductImporter do file = File.new('/tmp/test-m.csv') import_settings = {enterprise2.id.to_s => { + 'import_into' => 'inventories', 'defaults' => { 'count_on_hand' => { 'active' => true, From b26bec45d6ce25ad976509660bce12c38b1321c3 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Thu, 11 May 2017 13:32:56 +0100 Subject: [PATCH 114/206] Expanding PI specs squash --- spec/models/product_importer_spec.rb | 89 ++++++++++++++++++++++++---- 1 file changed, 79 insertions(+), 10 deletions(-) diff --git a/spec/models/product_importer_spec.rb b/spec/models/product_importer_spec.rb index 6878b4d153..025d029390 100644 --- a/spec/models/product_importer_spec.rb +++ b/spec/models/product_importer_spec.rb @@ -7,8 +7,10 @@ describe ProductImporter do let!(:admin) { create(:admin_user) } let!(:user) { create_enterprise_user } let!(:user2) { create_enterprise_user } + let!(:user3) { create_enterprise_user } let!(:enterprise) { create(:enterprise, owner: user, name: "User Enterprise") } let!(:enterprise2) { create(:distributor_enterprise, owner: user2, name: "Another Enterprise") } + let!(:enterprise3) { create(:distributor_enterprise, owner: user3, name: "And Another Enterprise") } let!(:relationship) { create(:enterprise_relationship, parent: enterprise, child: enterprise2, permissions_list: [:create_variant_overrides]) } let!(:category) { create(:taxon, name: 'Vegetables') } @@ -23,6 +25,8 @@ describe ProductImporter do let!(:product3) { create(:simple_product, supplier: enterprise, on_hand: '100', name: 'Sprouts', unit_value: '500') } let!(:product4) { create(:simple_product, supplier: enterprise, on_hand: '100', name: 'Cabbage', unit_value: '500') } let!(:product5) { create(:simple_product, supplier: enterprise2, on_hand: '100', name: 'Lettuce', unit_value: '500') } + let!(:product6) { create(:simple_product, supplier: enterprise3, on_hand: '100', name: 'Beetroot', unit_value: '500', on_demand: true, variant_unit_scale: 1, variant_unit: 'weight') } + let!(:product7) { create(:simple_product, supplier: enterprise3, on_hand: '100', name: 'Tomato', unit_value: '500', variant_unit_scale: 1, variant_unit: 'weight') } let!(:variant_override) { create(:variant_override, variant_id: product4.variants.first.id, hub: enterprise2, count_on_hand: 42) } let!(:variant_override2) { create(:variant_override, variant_id: product5.variants.first.id, hub: enterprise, count_on_hand: 96) } @@ -31,11 +35,12 @@ describe ProductImporter do describe "importing products from a spreadsheet" do before do csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "category", "on_hand", "price", "units", "unit_type", "variant_unit_name"] - csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "g", ""] - csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "6.50", "2", "kg", ""] - csv << ["Pea Soup", "User Enterprise", "Vegetables", "8", "5.50", "750", "ml", ""] - csv << ["Salad", "User Enterprise", "Vegetables", "7", "4.50", "1", "", "bags"] + csv << ["name", "supplier", "category", "on_hand", "price", "units", "unit_type", "variant_unit_name", "on_demand"] + csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "g", "", ""] + csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "6.50", "2", "kg", "", ""] + csv << ["Pea Soup", "User Enterprise", "Vegetables", "8", "5.50", "750", "ml", "", "0"] + csv << ["Salad", "User Enterprise", "Vegetables", "7", "4.50", "1", "", "bags", ""] + csv << ["Hot Cross Buns", "User Enterprise", "Cake", "7", "3.50", "1", "", "buns", "1"] end File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') @@ -45,25 +50,25 @@ describe ProductImporter do after { File.delete('/tmp/test-m.csv') } it "returns the number of entries" do - expect(@importer.item_count).to eq(4) + expect(@importer.item_count).to eq(5) end it "validates entries and returns the results as json" do @importer.validate_entries entries = JSON.parse(@importer.entries_json) - expect(filter('valid', entries)).to eq 4 + expect(filter('valid', entries)).to eq 5 expect(filter('invalid', entries)).to eq 0 - expect(filter('create_product', entries)).to eq 4 + expect(filter('create_product', entries)).to eq 5 expect(filter('update_product', entries)).to eq 0 end it "saves the results and returns info on updated products" do @importer.save_entries - expect(@importer.products_created_count).to eq 4 + expect(@importer.products_created_count).to eq 5 expect(@importer.updated_ids).to be_a(Array) - expect(@importer.updated_ids.count).to eq 4 + expect(@importer.updated_ids.count).to eq 5 carrots = Spree::Product.find_by_name('Carrots') carrots.supplier.should == enterprise @@ -72,6 +77,7 @@ describe ProductImporter do carrots.unit_value.should == 500 carrots.variant_unit.should == 'weight' carrots.variant_unit_scale.should == 1 + carrots.on_demand.should_not == true carrots.variants.first.import_date.should be_within(1.minute).of DateTime.now potatoes = Spree::Product.find_by_name('Potatoes') @@ -81,6 +87,7 @@ describe ProductImporter do potatoes.unit_value.should == 2000 potatoes.variant_unit.should == 'weight' potatoes.variant_unit_scale.should == 1000 + potatoes.on_demand.should_not == true potatoes.variants.first.import_date.should be_within(1.minute).of DateTime.now pea_soup = Spree::Product.find_by_name('Pea Soup') @@ -90,6 +97,7 @@ describe ProductImporter do pea_soup.unit_value.should == 0.75 pea_soup.variant_unit.should == 'volume' pea_soup.variant_unit_scale.should == 0.001 + pea_soup.on_demand.should_not == true pea_soup.variants.first.import_date.should be_within(1.minute).of DateTime.now salad = Spree::Product.find_by_name('Salad') @@ -99,7 +107,18 @@ describe ProductImporter do salad.unit_value.should == 1 salad.variant_unit.should == 'items' salad.variant_unit_scale.should == nil + salad.on_demand.should_not == true salad.variants.first.import_date.should be_within(1.minute).of DateTime.now + + buns = Spree::Product.find_by_name('Hot Cross Buns') + buns.supplier.should == enterprise + #buns.on_hand.should == Infinity + buns.price.should == 3.50 + buns.unit_value.should == 1 + buns.variant_unit.should == 'items' + buns.variant_unit_scale.should == nil + buns.on_demand.should == true + buns.variants.first.import_date.should be_within(1.minute).of DateTime.now end end @@ -233,6 +252,56 @@ describe ProductImporter do end end + describe "updating various fields" do + before do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "category", "on_hand", "price", "units", "unit_type", "on_demand"] + csv << ["Beetroot", "And Another Enterprise", "Vegetables", "5", "3.50", "500", "g", "0"] + csv << ["Tomato", "And Another Enterprise", "Vegetables", "6", "5.50", "500", "g", "1"] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + settings = {enterprise3.id.to_s => {'import_into' => 'product_list'}} + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, settings: settings}) + end + after { File.delete('/tmp/test-m.csv') } + + it "validates entries" do + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 2 + expect(filter('invalid', entries)).to eq 0 + expect(filter('create_product', entries)).to eq 0 + expect(filter('update_product', entries)).to eq 2 + end + + it "saves and updates" do + + beetroot = Spree::Product.find_by_name('Beetroot').variants.first + pp beetroot + pp beetroot.product + + @importer.save_entries + + expect(@importer.products_created_count).to eq 0 + expect(@importer.products_updated_count).to eq 2 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 2 + + beetroot = Spree::Product.find_by_name('Beetroot').variants.first + pp beetroot + pp beetroot.product + beetroot.price.should == 3.50 + beetroot.on_demand.should_not == true + + tomato = Spree::Product.find_by_name('Tomato').variants.first + pp tomato + tomato.price.should == 5.50 + tomato.on_demand.should == true + end + end + describe "importing items into inventory" do before do csv_data = CSV.generate do |csv| From 1c34ce616212851c68400b7409e7b11abd67ed0d Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Mon, 29 May 2017 23:57:15 +0100 Subject: [PATCH 115/206] PI exception handling --- .../controllers/import_form_controller.js.coffee | 2 ++ .../admin/product_import_controller.rb | 16 ++++++++++++---- app/models/product_importer.rb | 3 +-- app/views/admin/product_import/import.html.haml | 4 ++++ 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/admin/product_import/controllers/import_form_controller.js.coffee b/app/assets/javascripts/admin/product_import/controllers/import_form_controller.js.coffee index 7770fc5970..2718e16edc 100644 --- a/app/assets/javascripts/admin/product_import/controllers/import_form_controller.js.coffee +++ b/app/assets/javascripts/admin/product_import/controllers/import_form_controller.js.coffee @@ -78,6 +78,7 @@ angular.module("ofn.admin").controller "ImportFormCtrl", ($scope, $http, $filter $scope.updateProgress() ).error((data, status, headers, config) -> + $scope.exception = data console.log('Error: '+status) ) @@ -114,6 +115,7 @@ angular.module("ofn.admin").controller "ImportFormCtrl", ($scope, $http, $filter $scope.updateProgress() ).error((data, status, headers, config) -> + $scope.exception = data console.log('Error: '+status) ) diff --git a/app/controllers/admin/product_import_controller.rb b/app/controllers/admin/product_import_controller.rb index a480a85917..c0f5285b13 100644 --- a/app/controllers/admin/product_import_controller.rb +++ b/app/controllers/admin/product_import_controller.rb @@ -26,17 +26,25 @@ class Admin::ProductImportController < Spree::Admin::BaseController def process_data @importer = ProductImporter.new(File.new(params[:filepath]), spree_current_user, {start: params[:start], end: params[:end], settings: params[:settings]}) - @importer.validate_entries + begin + @importer.validate_entries + rescue Exception => e + render json: e.message, response: 500 + end - render json: @importer.import_results + render json: @importer.import_results, response: 200 end def save_data @importer = ProductImporter.new(File.new(params[:filepath]), spree_current_user, {start: params[:start], end: params[:end], settings: params[:settings]}) - @importer.save_entries + begin + @importer.save_entries + rescue Exception => e + render json: e.message, response: 500 + end - render json: @importer.save_results + render json: @importer.save_results, response: 200 end def reset_absent_products diff --git a/app/models/product_importer.rb b/app/models/product_importer.rb index 94b8a8c5a9..9bad13532b 100644 --- a/app/models/product_importer.rb +++ b/app/models/product_importer.rb @@ -203,8 +203,7 @@ class ProductImporter build_categories_index build_suppliers_index build_tax_and_shipping_indexes - build_producers_index ###if importing_into_inventory? #TODO: check this is still working ok - #validate_all + build_producers_index count_existing_items unless @import_settings.has_key?(:start) end diff --git a/app/views/admin/product_import/import.html.haml b/app/views/admin/product_import/import.html.haml index b906c2731c..34098ebda1 100644 --- a/app/views/admin/product_import/import.html.haml +++ b/app/views/admin/product_import/import.html.haml @@ -28,6 +28,8 @@ #{t('admin.product_import.index.import')} %button.review{ng: {click: 'viewResults()', disabled: '!finished'}} #{t('admin.product_import.import.review')} + %p.red + {{exception}} = form_tag false, {class: 'product-import', name: 'importForm', 'ng-show' => 'step == "results"'} do @@ -64,6 +66,8 @@ #{t('admin.product_import.import.save')} %button.view_results{ng: {click: 'finalResults()', disabled: '!finished'}} #{t('admin.product_import.import.results')} + %p.red + {{exception}} .save-results{ng: {show: 'step == "complete"'}} = render 'save_results' From 7c283d90db10a056a0bbf2980abc277580627e22 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Fri, 6 Oct 2017 16:08:35 +0100 Subject: [PATCH 116/206] Spec adjustments --- .../admin/product_import/import.html.haml | 4 +- spec/features/admin/product_import_spec.rb | 98 ++++++++++--------- 2 files changed, 52 insertions(+), 50 deletions(-) diff --git a/app/views/admin/product_import/import.html.haml b/app/views/admin/product_import/import.html.haml index 34098ebda1..95a5e5275a 100644 --- a/app/views/admin/product_import/import.html.haml +++ b/app/views/admin/product_import/import.html.haml @@ -13,7 +13,7 @@ .settings-section{ng: {show: 'step == "settings"'}} = render 'import_options' if @importer.table_headings %br - %a.button{href: '', ng: {click: 'confirmSettings()'}} + %a.button.proceed{href: '', ng: {click: 'confirmSettings()'}} #{t('admin.product_import.import.proceed')} %a.button{href: main_app.admin_product_import_path} #{t('admin.cancel')} @@ -48,7 +48,7 @@ = hidden_field_tag :filepath, @filepath = hidden_field_tag "settings[import_into]", @import_into - %a.button{href: '', ng: {click: 'acceptResults()'}} + %a.button.proceed{href: '', ng: {click: 'acceptResults()'}} #{t('admin.product_import.import.proceed')} %a.button{href: main_app.admin_product_import_path} #{t('admin.cancel')} diff --git a/spec/features/admin/product_import_spec.rb b/spec/features/admin/product_import_spec.rb index e6ee252819..21861c0e01 100644 --- a/spec/features/admin/product_import_spec.rb +++ b/spec/features/admin/product_import_spec.rb @@ -45,31 +45,20 @@ feature "Product Import", js: true do attach_file 'file', '/tmp/test.csv' click_button 'Upload' + expect(page).to have_selector 'a.button.proceed', visible: true click_link 'Proceed' - expect(page).to have_selector 'button.start_import' - expect(page).to have_selector "button.review[disabled='disabled']" - - sleep 0.5 - click_button 'Import' - wait_until { page.find("button.review:not([disabled='disabled'])").present? } - click_button 'Review' + import_data expect(page).to have_selector '.item-count', text: "2" expect(page).to_not have_selector '.invalid-count' expect(page).to have_selector '.create-count', text: "2" expect(page).to_not have_selector '.update-count' + expect(page).to have_selector 'a.button.proceed', visible: true click_link 'Proceed' - expect(page).to have_selector 'button.start_save' - expect(page).to have_selector "button.view_results[disabled='disabled']" - - sleep 0.5 - click_button 'Save' - wait_until { page.find("button.view_results:not([disabled='disabled'])").present? } - - click_button 'Results' + save_data expect(page).to have_selector '.created-count', text: '2' expect(page).to_not have_selector '.updated-count' @@ -105,12 +94,10 @@ feature "Product Import", js: true do attach_file 'file', '/tmp/test.csv' click_button 'Upload' + expect(page).to have_selector 'a.button.proceed', visible: true click_link 'Proceed' - sleep 0.5 - click_button 'Import' - wait_until { page.find("button.review:not([disabled='disabled'])").present? } - click_button 'Review' + import_data expect(page).to have_selector '.item-count', text: "2" expect(page).to have_selector '.invalid-count', text: "2" @@ -134,18 +121,15 @@ feature "Product Import", js: true do attach_file 'file', '/tmp/test.csv' click_button 'Upload' + expect(page).to have_selector 'a.button.proceed', visible: true click_link 'Proceed' - sleep 0.5 - click_button 'Import' - wait_until { page.find("button.review:not([disabled='disabled'])").present? } - click_button 'Review' + import_data + expect(page).to have_selector 'a.button.proceed', visible: true click_link 'Proceed' - sleep 0.5 - click_button 'Save' - wait_until { page.find("button.view_results:not([disabled='disabled'])").present? } - click_button 'Results' + + save_data carrots = Spree::Product.find_by_name('Carrots') carrots.variants.first.import_date.should be_within(1.minute).of DateTime.now @@ -158,12 +142,12 @@ feature "Product Import", js: true do expect(page).to have_field "product_name", with: carrots.name expect(page).to have_field "product_name", with: potatoes.name - find("div#columns-dropdown", :text => "COLUMNS").click + find("div#columns-dropdown", text: "COLUMNS").click find("div#columns-dropdown div.menu div.menu_item", text: "Import").click - find("div#columns-dropdown", :text => "COLUMNS").click + find("div#columns-dropdown", text: "COLUMNS").click within "tr#p_#{carrots.id} td.import_date" do - expect(page).to have_content DateTime.now.year + expect(page).to have_content Time.zone.now.year end expect(page).to have_selector 'div#s2id_import_date_filter' @@ -194,12 +178,10 @@ feature "Product Import", js: true do select 'Inventories', from: "settings_#{enterprise2.id.to_s}_import_into", visible: false end + expect(page).to have_selector 'a.button.proceed', visible: true click_link 'Proceed' - sleep 0.5 - click_button 'Import' - wait_until { page.find("button.review:not([disabled='disabled'])").present? } - click_button 'Review' + import_data expect(page).to have_selector '.item-count', text: "3" expect(page).to_not have_selector '.invalid-count' @@ -208,11 +190,10 @@ feature "Product Import", js: true do expect(page).to have_selector '.inv-create-count', text: "2" expect(page).to have_selector '.inv-update-count', text: "1" + expect(page).to have_selector 'a.button.proceed', visible: true click_link 'Proceed' - sleep 0.5 - click_button 'Save' - wait_until { page.find("button.view_results:not([disabled='disabled'])").present? } - click_button 'Results' + + save_data expect(page).to_not have_selector '.created-count' expect(page).to_not have_selector '.updated-count' @@ -232,7 +213,6 @@ feature "Product Import", js: true do Float(cabbage_override.price).should == 1.50 cabbage_override.count_on_hand.should == 2001 - sleep 0.5 click_link 'View Inventory' expect(page).to have_content 'Inventory' @@ -266,7 +246,7 @@ feature "Product Import", js: true do visit main_app.admin_product_import_path click_button 'Upload' - expect(flash_message).to eq I18n.t(:product_import_file_not_found_notice) + expect(page).to have_content I18n.t(:product_import_file_not_found_notice) end it "handles cases where no meaningful data can be read from the file" do @@ -300,24 +280,22 @@ feature "Product Import", js: true do attach_file 'file', '/tmp/test.csv' click_button 'Upload' + expect(page).to have_selector 'a.button.proceed', visible: true click_link 'Proceed' - sleep 0.5 - click_button 'Import' - wait_until { page.find("button.review:not([disabled='disabled'])").present? } - click_button 'Review' + import_data + expect(page).to have_content I18n.t('admin.product_import.import.validation_overview') expect(page).to have_selector '.item-count', text: "2" expect(page).to have_selector '.invalid-count', text: "1" expect(page).to have_selector '.create-count', text: "1" expect(page.body).to have_content 'you do not have permission' + expect(page).to have_selector 'a.button.proceed', visible: true click_link 'Proceed' - sleep 0.5 - click_button 'Save' - wait_until { page.find("button.view_results:not([disabled='disabled'])").present? } - click_button 'Results' + + save_data expect(page).to have_selector '.created-count', text: '1' @@ -325,4 +303,28 @@ feature "Product Import", js: true do Spree::Product.find_by_name('Your Potatoes').should == nil end end + + private + + def import_data + expect(page).to have_selector 'button.start_import', visible: true + expect(page).to have_selector "button.review[disabled='disabled']" + + find('button.start_import').trigger 'click' + wait_until { page.find("button.review:not([disabled='disabled'])").present? } + + find('button.review').trigger 'click' + expect(page).to have_content I18n.t('admin.product_import.import.validation_overview') + end + + def save_data + expect(page).to have_selector 'button.start_save', visible: true + expect(page).to have_selector "button.view_results[disabled='disabled']" + + find('button.start_save').trigger 'click' + wait_until { page.find("button.view_results:not([disabled='disabled'])").present? } + + find('button.view_results').trigger 'click' + expect(page).to have_content I18n.t('admin.product_import.save.final_results') + end end From 3a6f316ede564a84586464ce7c25aaa9c9e49222 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Fri, 6 Oct 2017 17:05:46 +0100 Subject: [PATCH 117/206] Codeclimate refactor --- .../admin/product_import_controller.rb | 14 ++-- .../admin/variant_overrides_controller.rb | 2 +- .../admin/products_controller_decorator.rb | 2 +- app/models/product_importer.rb | 69 ++++++++++--------- app/models/spreadsheet_entry.rb | 19 +++-- 5 files changed, 53 insertions(+), 53 deletions(-) diff --git a/app/controllers/admin/product_import_controller.rb b/app/controllers/admin/product_import_controller.rb index c0f5285b13..351a0cb7e5 100644 --- a/app/controllers/admin/product_import_controller.rb +++ b/app/controllers/admin/product_import_controller.rb @@ -2,7 +2,7 @@ require 'roo' class Admin::ProductImportController < Spree::Admin::BaseController - before_filter :validate_upload_presence, except: [:index, :process_data] + before_filter :validate_upload_presence, except: %i[index process_data] def import # Save uploaded file to tmp directory @@ -24,11 +24,11 @@ class Admin::ProductImportController < Spree::Admin::BaseController # end def process_data - @importer = ProductImporter.new(File.new(params[:filepath]), spree_current_user, {start: params[:start], end: params[:end], settings: params[:settings]}) + @importer = ProductImporter.new(File.new(params[:filepath]), spree_current_user, start: params[:start], end: params[:end], settings: params[:settings]) begin @importer.validate_entries - rescue Exception => e + rescue StandardError => e render json: e.message, response: 500 end @@ -36,11 +36,11 @@ class Admin::ProductImportController < Spree::Admin::BaseController end def save_data - @importer = ProductImporter.new(File.new(params[:filepath]), spree_current_user, {start: params[:start], end: params[:end], settings: params[:settings]}) + @importer = ProductImporter.new(File.new(params[:filepath]), spree_current_user, start: params[:start], end: params[:end], settings: params[:settings]) begin @importer.save_entries - rescue Exception => e + rescue StandardError => e render json: e.message, response: 500 end @@ -48,9 +48,9 @@ class Admin::ProductImportController < Spree::Admin::BaseController end def reset_absent_products - @importer = 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]}) + @importer = 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.has_key?(:enterprises_to_reset) and params.has_key?(:updated_ids) + if params.key?(:enterprises_to_reset) && params.key?(:updated_ids) @importer.reset_absent(params[:updated_ids]) end diff --git a/app/controllers/admin/variant_overrides_controller.rb b/app/controllers/admin/variant_overrides_controller.rb index 072a1f1eeb..dbe914521e 100644 --- a/app/controllers/admin/variant_overrides_controller.rb +++ b/app/controllers/admin/variant_overrides_controller.rb @@ -58,7 +58,7 @@ module Admin @inventory_items = InventoryItem.where(enterprise_id: @hubs) import_dates = [{id: '0', name: 'All'}] - inventory_import_dates.map {|i| import_dates.push({id: i.to_date, name: i.to_date.to_formatted_s(:long)}) } + inventory_import_dates.map { |i| import_dates.push(id: i.to_date, name: i.to_date.to_formatted_s(:long)) } @import_dates = import_dates.uniq.to_json end diff --git a/app/controllers/spree/admin/products_controller_decorator.rb b/app/controllers/spree/admin/products_controller_decorator.rb index bd6257d2a0..5e4c9b805a 100644 --- a/app/controllers/spree/admin/products_controller_decorator.rb +++ b/app/controllers/spree/admin/products_controller_decorator.rb @@ -102,7 +102,7 @@ Spree::Admin::ProductsController.class_eval do @producers = OpenFoodNetwork::Permissions.new(spree_current_user).managed_product_enterprises.is_primary_producer.by_name @taxons = Spree::Taxon.order(:name) import_dates = [{id: '0', name: ''}] - product_import_dates.map {|i| import_dates.push({id: i.to_date, name: i.to_date.to_formatted_s(:long)}) } + product_import_dates.map { |i| import_dates.push(id: i.to_date, name: i.to_date.to_formatted_s(:long)) } @import_dates = import_dates.uniq.to_json end diff --git a/app/models/product_importer.rb b/app/models/product_importer.rb index 9bad13532b..83715a3719 100644 --- a/app/models/product_importer.rb +++ b/app/models/product_importer.rb @@ -25,7 +25,7 @@ class ProductImporter @inventory_created = 0 @inventory_updated = 0 - @import_time = DateTime.now + @import_time = Time.zone.now @import_settings = import_settings || {} @current_user = current_user @@ -54,7 +54,7 @@ class ProductImporter def has_valid_entries? @entries.each do |entry| - return true unless entry.validates_as.blank? + return true if entry.validates_as.present? end false end @@ -90,7 +90,8 @@ class ProductImporter entries[entry.line_number] = { attributes: entry.displayable_attributes, validates_as: entry.validates_as, - errors: entry.invalid_attributes } + errors: entry.invalid_attributes + } end entries.to_json end @@ -163,7 +164,7 @@ class ProductImporter supplier_validation(entry) unit_fields_validation(entry) - next unless entry.supplier_id.present? + next if entry.supplier_id.blank? if import_into_inventory?(entry) producer_validation(entry) @@ -211,8 +212,8 @@ class ProductImporter permissions = OpenFoodNetwork::Permissions.new(@current_user) permissions.editable_enterprises. - order('is_primary_producer ASC, name'). - map { |e| @editable_enterprises[e.name] = e.id } + order('is_primary_producer ASC, name'). + map { |e| @editable_enterprises[e.name] = e.id } @inventory_permissions = permissions.variant_override_enterprises_per_hub end @@ -272,7 +273,7 @@ class ProductImporter supplier_validation(entry) unit_fields_validation(entry) - next unless entry.supplier_id.present? + next if entry.supplier_id.blank? if import_into_inventory?(entry) producer_validation(entry) @@ -350,18 +351,19 @@ class ProductImporter @suppliers_index.each do |supplier_name, supplier_id| next unless supplier_id and permission_by_id?(supplier_id) - if import_into_inventory_by_supplier?(supplier_id) - products_count = VariantOverride. - where('variant_overrides.hub_id IN (?)', supplier_id). - count - else - 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 - end + products_count = + if import_into_inventory_by_supplier?(supplier_id) + VariantOverride. + where('variant_overrides.hub_id IN (?)', supplier_id). + count + else + 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 + end @supplier_products[supplier_id] = products_count @total_supplier_products += products_count @@ -443,20 +445,20 @@ class ProductImporter end def tax_validation(entry) - return unless entry.tax_category.present? + return if entry.tax_category.blank? if @tax_index.has_key? entry.tax_category entry.tax_category_id = @tax_index[entry.tax_category] else - mark_as_invalid(entry, attribute: "tax_category", error: "#{I18n.t('admin.product_import.model.not_found')}") + mark_as_invalid(entry, attribute: "tax_category", error: I18n.t('admin.product_import.model.not_found')) end end def shipping_validation(entry) - return unless entry.shipping_category.present? + return if entry.shipping_category.blank? if @shipping_index.has_key? entry.shipping_category entry.shipping_category_id = @shipping_index[entry.shipping_category] else - mark_as_invalid(entry, attribute: "shipping_category", error: "#{I18n.t('admin.product_import.model.not_found')}") + mark_as_invalid(entry, attribute: "shipping_category", error: I18n.t('admin.product_import.model.not_found')) end end @@ -498,8 +500,7 @@ class ProductImporter @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) + 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 @@ -508,8 +509,8 @@ class ProductImporter def build_tax_and_shipping_indexes @tax_index = {} @shipping_index = {} - Spree::TaxCategory.select([:id, :name]).map {|tc| @tax_index[tc.name] = tc.id } - Spree::ShippingCategory.select([:id, :name]).map {|sc| @shipping_index[sc.name] = sc.id } + Spree::TaxCategory.select(%i[id name]).map { |tc| @tax_index[tc.name] = tc.id } + Spree::ShippingCategory.select(%i[id name]).map { |sc| @shipping_index[sc.name] = sc.id } end def save_all_valid @@ -560,8 +561,8 @@ class ProductImporter unless is_new existing_item = InventoryItem.where( variant_id: variant_override.variant_id, - enterprise_id: variant_override.hub_id). - first + enterprise_id: variant_override.hub_id + ).first if existing_item existing_item.assign_attributes(visible: true) @@ -573,8 +574,8 @@ class ProductImporter InventoryItem.new( variant_id: variant_override.variant_id, enterprise_id: variant_override.hub_id, - visible: true). - save + visible: true + ).save end def save_new_inventory_item(entry) @@ -724,8 +725,8 @@ class ProductImporter # 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 \ - and existing_variant.unit_value == entry.unit_value.to_f \ - and existing_variant.deleted_at == nil + && existing_variant.unit_value == entry.unit_value.to_f \ + && existing_variant.deleted_at.nil? mark_as_existing_variant(entry, existing_variant) return @@ -783,7 +784,7 @@ class ProductImporter end def check_on_hand_nil(entry, object) - return unless entry.on_hand.blank? + 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) diff --git a/app/models/spreadsheet_entry.rb b/app/models/spreadsheet_entry.rb index 989fa3c716..39dd45a129 100644 --- a/app/models/spreadsheet_entry.rb +++ b/app/models/spreadsheet_entry.rb @@ -43,7 +43,6 @@ class SpreadsheetEntry end def convert_custom_unit_fields(attrs) - # unit 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 @@ -54,11 +53,11 @@ class SpreadsheetEntry attrs['variant_unit_scale'] = nil attrs['unit_value'] = nil - if attrs.has_key?('units') and attrs['units'].present? + if attrs.key?('units') && attrs['units'].present? attrs['unscaled_units'] = attrs['units'] end - if attrs.has_key?('units') and attrs.has_key?('unit_type') and attrs['units'].present? and attrs['unit_type'].present? + if attrs.key?('units') && attrs.key?('unit_type') && attrs['units'].present? && attrs['unit_type'].present? units = attrs['units'].to_f unit_type = attrs['unit_type'].to_s.downcase @@ -69,11 +68,11 @@ class SpreadsheetEntry end end - if attrs.has_key?('units') and attrs.has_key?('variant_unit_name') and attrs['units'].present? and attrs['variant_unit_name'].present? - attrs['variant_unit'] = 'items' - attrs['variant_unit_scale'] = nil - attrs['unit_value'] = units || 1 - end + return unless attrs.key?('units') && attrs.key?('variant_unit_name') && attrs['units'].present? && attrs['variant_unit_name'].present? + + attrs['variant_unit'] = 'items' + attrs['variant_unit_scale'] = nil + attrs['unit_value'] = units || 1 end def persisted? @@ -123,7 +122,7 @@ class SpreadsheetEntry private def valid_unit_type?(unit_type) - unit_scales.has_key? unit_type + unit_scales.key? unit_type end def non_display_attributes @@ -131,6 +130,6 @@ class SpreadsheetEntry end def non_product_attributes - ['line_number', 'valid', 'errors', 'product_object', 'product_validations', 'inventory_validations', 'validates_as', 'save_type', 'on_hand_nil', 'has_overrides', 'tax_category,' 'shipping_category'] + ['line_number', 'valid', 'errors', 'product_object', 'product_validations', 'inventory_validations', 'validates_as', 'save_type', 'on_hand_nil', 'has_overrides', 'tax_category', 'shipping_category'] end end From 7a64ad1cc1dc0df5a773f854e7316be122ce3ce4 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Sun, 8 Oct 2017 12:02:51 +0100 Subject: [PATCH 118/206] Fix roo-xls version in Gemfile --- Gemfile | 2 +- Gemfile.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile b/Gemfile index 9a2811f88b..1bafae9b50 100644 --- a/Gemfile +++ b/Gemfile @@ -74,7 +74,7 @@ gem 'wkhtmltopdf-binary' gem 'foreigner' gem 'immigrant' gem 'roo', '~> 2.7.0' -gem 'roo-xls' +gem 'roo-xls', '~> 1.1.0' gem 'whenever', require: false diff --git a/Gemfile.lock b/Gemfile.lock index f92e6a837d..b96a2dad54 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -782,8 +782,8 @@ DEPENDENCIES representative_view roadie-rails (~> 1.0.3) roo (~> 2.7.0) - roo-xls rspec-rails (>= 3.5.2) + roo-xls (~> 1.1.0) rspec-retry rubocop (>= 0.49.1) sass (~> 3.3) From 1ff39b8df5b5c116ae442fc1ef9c42b82e01289d Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Sun, 29 Oct 2017 14:04:16 +0000 Subject: [PATCH 119/206] Fix altered translation key --- config/locales/en.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config/locales/en.yml b/config/locales/en.yml index 442417d207..242eddb396 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2580,6 +2580,7 @@ See the %{link} to find out more about %{sitename}'s features and to start using inherits_properties?: Inherits Properties? available_on: Available On av_on: "Av. On" + import_date: "Import Date" products_variant: variant_has_n_overrides: "This variant has %{n} override(s)" new_variant: "New variant" From 1ed49d397e79c4bf1e80d59d82f8a092c3e9f0ad Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Sun, 29 Oct 2017 14:05:13 +0000 Subject: [PATCH 120/206] Refactor PI --- .../filters/filter_entries.js.coffee | 18 +++++++----------- .../admin/product_import_controller.rb | 6 ------ .../admin/variant_overrides_controller.rb | 16 +++++++++++++--- app/models/spreadsheet_entry.rb | 2 -- 4 files changed, 20 insertions(+), 22 deletions(-) diff --git a/app/assets/javascripts/admin/product_import/filters/filter_entries.js.coffee b/app/assets/javascripts/admin/product_import/filters/filter_entries.js.coffee index 7855b07058..7513384f9e 100644 --- a/app/assets/javascripts/admin/product_import/filters/filter_entries.js.coffee +++ b/app/assets/javascripts/admin/product_import/filters/filter_entries.js.coffee @@ -7,17 +7,13 @@ angular.module("ofn.admin").filter 'entriesFilterValid', -> angular.forEach entries, (entry, line_number) -> validates_as = entry.validates_as - if type == 'valid' and (validates_as != '') - filtered[line_number] = entry - if type == 'invalid' and (validates_as == '') - filtered[line_number] = entry - if type == 'create_product' and (validates_as == 'new_product' or validates_as == 'new_variant') - filtered[line_number] = entry - if type == 'update_product' and validates_as == 'existing_variant' - filtered[line_number] = entry - if type == 'create_inventory' and validates_as == 'new_inventory_item' - filtered[line_number] = entry - if type == 'update_inventory' and validates_as == 'existing_inventory_item' + + 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 diff --git a/app/controllers/admin/product_import_controller.rb b/app/controllers/admin/product_import_controller.rb index 351a0cb7e5..1a02272edd 100644 --- a/app/controllers/admin/product_import_controller.rb +++ b/app/controllers/admin/product_import_controller.rb @@ -17,12 +17,6 @@ class Admin::ProductImportController < Spree::Admin::BaseController @shipping_categories = Spree::ShippingCategory.order('name ASC') end - # def save - # @importer = ProductImporter.new(File.new(params[:filepath]), spree_current_user, params[:settings]) - # @importer.save_all if @importer.has_valid_entries? - # @import_into = params[:settings][:import_into] - # end - def process_data @importer = ProductImporter.new(File.new(params[:filepath]), spree_current_user, start: params[:start], end: params[:end], settings: params[:settings]) diff --git a/app/controllers/admin/variant_overrides_controller.rb b/app/controllers/admin/variant_overrides_controller.rb index dbe914521e..dee8ed8e67 100644 --- a/app/controllers/admin/variant_overrides_controller.rb +++ b/app/controllers/admin/variant_overrides_controller.rb @@ -56,10 +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 - import_dates = [{id: '0', name: 'All'}] - inventory_import_dates.map { |i| import_dates.push(id: i.to_date, name: i.to_date.to_formatted_s(:long)) } - @import_dates = import_dates.uniq.to_json + def inventory_import_dates + options = [{id: '0', name: 'All'}] + + import_dates = VariantOverride. + select('variant_overrides.import_date'). + where('variant_overrides.hub_id IN (?) + AND variant_overrides.import_date IS NOT NULL', editable_enterprises.collect(&:id)) + + import_dates.uniq.collect(&:import_date).sort.reverse.map { |i| options.push(id: i.to_date, name: i.to_date.to_formatted_s(:long)) } + + options end def load_collection diff --git a/app/models/spreadsheet_entry.rb b/app/models/spreadsheet_entry.rb index 39dd45a129..42e9acdd63 100644 --- a/app/models/spreadsheet_entry.rb +++ b/app/models/spreadsheet_entry.rb @@ -80,12 +80,10 @@ class SpreadsheetEntry end def is_a_valid?(type) - #@validates_as[type] @validates_as == type end def is_a_valid(type) - #@validates_as.push type @validates_as = type end From 11908f125e29407775b8c9c113152558cde48a58 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Sun, 29 Oct 2017 18:05:46 +0000 Subject: [PATCH 121/206] Fix flaky spec --- spec/features/admin/product_import_spec.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/features/admin/product_import_spec.rb b/spec/features/admin/product_import_spec.rb index 21861c0e01..2e26d41a06 100644 --- a/spec/features/admin/product_import_spec.rb +++ b/spec/features/admin/product_import_spec.rb @@ -244,9 +244,10 @@ feature "Product Import", js: true do it "returns an error if nothing was uploaded" do visit main_app.admin_product_import_path + expect(page).to have_content 'Select a spreadsheet to upload' click_button 'Upload' - expect(page).to have_content I18n.t(:product_import_file_not_found_notice) + expect(flash_message).to eq I18n.t(:product_import_file_not_found_notice) end it "handles cases where no meaningful data can be read from the file" do From c2c42e1d9a3135678005b8026d94bea15a183788 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Wed, 3 Jan 2018 18:21:58 +0000 Subject: [PATCH 122/206] Product Import refactor --- .../import_form_controller.js.coffee | 6 +- .../admin/product_import_controller.rb | 135 ++- .../admin/variant_overrides_controller.rb | 2 +- app/models/product_import/entry_processor.rb | 234 +++++ app/models/product_import/entry_validator.rb | 273 ++++++ app/models/product_import/product_importer.rb | 252 ++++++ app/models/product_import/spreadsheet_data.rb | 72 ++ .../product_import/spreadsheet_entry.rb | 78 ++ app/models/product_import/unit_converter.rb | 79 ++ app/models/product_importer.rb | 800 ------------------ app/models/spreadsheet_entry.rb | 133 --- app/models/spree/ability_decorator.rb | 2 +- .../product_import/_entries_table.html.haml | 2 +- .../product_import/_import_options.html.haml | 4 +- .../product_import/_import_review.html.haml | 35 +- .../product_import/_options_form.html.haml | 14 +- .../product_import/_save_results.html.haml | 42 +- .../product_import/_upload_form.html.haml | 2 +- .../admin/product_import/import.html.haml | 41 +- app/views/admin/product_import/save.html.haml | 26 +- config/routes.rb | 2 +- spec/features/admin/product_import_spec.rb | 66 +- spec/models/product_importer_spec.rb | 277 +++--- 23 files changed, 1331 insertions(+), 1246 deletions(-) create mode 100644 app/models/product_import/entry_processor.rb create mode 100644 app/models/product_import/entry_validator.rb create mode 100644 app/models/product_import/product_importer.rb create mode 100644 app/models/product_import/spreadsheet_data.rb create mode 100644 app/models/product_import/spreadsheet_entry.rb create mode 100644 app/models/product_import/unit_converter.rb delete mode 100644 app/models/product_importer.rb delete mode 100644 app/models/spreadsheet_entry.rb diff --git a/app/assets/javascripts/admin/product_import/controllers/import_form_controller.js.coffee b/app/assets/javascripts/admin/product_import/controllers/import_form_controller.js.coffee index 2718e16edc..280183376b 100644 --- a/app/assets/javascripts/admin/product_import/controllers/import_form_controller.js.coffee +++ b/app/assets/javascripts/admin/product_import/controllers/import_form_controller.js.coffee @@ -79,7 +79,7 @@ angular.module("ofn.admin").controller "ImportFormCtrl", ($scope, $http, $filter $scope.updateProgress() ).error((data, status, headers, config) -> $scope.exception = data - console.log('Error: '+status) + console.error(data) ) $scope.importSettings = null @@ -116,7 +116,7 @@ angular.module("ofn.admin").controller "ImportFormCtrl", ($scope, $http, $filter $scope.updateProgress() ).error((data, status, headers, config) -> $scope.exception = data - console.log('Error: '+status) + console.error(data) ) $scope.sortResults = (results) -> @@ -149,7 +149,7 @@ angular.module("ofn.admin").controller "ImportFormCtrl", ($scope, $http, $filter $scope.updates.products_reset = data ).error((data, status, headers, config) -> - console.log('Error: '+status) + console.error(data) ) $scope.updateProgress = () -> diff --git a/app/controllers/admin/product_import_controller.rb b/app/controllers/admin/product_import_controller.rb index 1a02272edd..3b5e9638e2 100644 --- a/app/controllers/admin/product_import_controller.rb +++ b/app/controllers/admin/product_import_controller.rb @@ -1,92 +1,89 @@ require 'roo' -class Admin::ProductImportController < Spree::Admin::BaseController +module Admin + class ProductImportController < Spree::Admin::BaseController + before_filter :validate_upload_presence, except: %i[index validate_data] - before_filter :validate_upload_presence, except: %i[index process_data] + 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) - def import - # Save uploaded file to tmp directory - @filepath = save_uploaded_file(params[:file]) - @importer = 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 - 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 process_data - @importer = ProductImporter.new(File.new(params[:filepath]), spree_current_user, start: params[:start], end: params[:end], settings: params[:settings]) - - begin - @importer.validate_entries - rescue StandardError => e - render json: e.message, response: 500 + @tax_categories = Spree::TaxCategory.order('is_default DESC, name ASC') + @shipping_categories = Spree::ShippingCategory.order('name ASC') end - render json: @importer.import_results, response: 200 - end - - def save_data - @importer = ProductImporter.new(File.new(params[:filepath]), spree_current_user, start: params[:start], end: params[:end], settings: params[:settings]) - - begin - @importer.save_entries - rescue StandardError => e - render json: e.message, response: 500 + def validate_data + return unless process_data('validate') + render json: @importer.import_results, response: 200 end - render json: @importer.save_results, response: 200 - end - - def reset_absent_products - @importer = 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]) + def save_data + return unless process_data('save') + render json: @importer.save_results, response: 200 end - render json: @importer.products_reset_count - end + 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]) - private + if params.key?(:enterprises_to_reset) && params.key?(:updated_ids) + @importer.reset_absent(params[:updated_ids]) + end - 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 + render json: @importer.products_reset_count end - end - def check_file_errors(importer) - if importer.errors.present? - redirect_to '/admin/product_import', notice: @importer.errors.full_messages.to_sentence - return + 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 - 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 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 - 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 check_file_errors(importer) + if importer.errors.present? + redirect_to '/admin/product_import', notice: @importer.errors.full_messages.to_sentence + end end - end - # Define custom model class for Cancan permissions - def model_class - ProductImporter + 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 diff --git a/app/controllers/admin/variant_overrides_controller.rb b/app/controllers/admin/variant_overrides_controller.rb index dee8ed8e67..4f8b39f1f4 100644 --- a/app/controllers/admin/variant_overrides_controller.rb +++ b/app/controllers/admin/variant_overrides_controller.rb @@ -60,7 +60,7 @@ module Admin end def inventory_import_dates - options = [{id: '0', name: 'All'}] + options = [{ id: '0', name: 'All' }] import_dates = VariantOverride. select('variant_overrides.import_date'). diff --git a/app/models/product_import/entry_processor.rb b/app/models/product_import/entry_processor.rb new file mode 100644 index 0000000000..9be32f7e98 --- /dev/null +++ b/app/models/product_import/entry_processor.rb @@ -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. + joins(:product). + where('spree_products.supplier_id IN (?) + AND spree_variants.is_master = false + AND spree_variants.deleted_at IS NULL', 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 diff --git a/app/models/product_import/entry_validator.rb b/app/models/product_import/entry_validator.rb new file mode 100644 index 0000000000..8ffb2edf2c --- /dev/null +++ b/app/models/product_import/entry_validator.rb @@ -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 diff --git a/app/models/product_import/product_importer.rb b/app/models/product_import/product_importer.rb new file mode 100644 index 0000000000..24618cfd05 --- /dev/null +++ b/app/models/product_import/product_importer.rb @@ -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 diff --git a/app/models/product_import/spreadsheet_data.rb b/app/models/product_import/spreadsheet_data.rb new file mode 100644 index 0000000000..303cf1a93b --- /dev/null +++ b/app/models/product_import/spreadsheet_data.rb @@ -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 diff --git a/app/models/product_import/spreadsheet_entry.rb b/app/models/product_import/spreadsheet_entry.rb new file mode 100644 index 0000000000..b32c96a811 --- /dev/null +++ b/app/models/product_import/spreadsheet_entry.rb @@ -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 diff --git a/app/models/product_import/unit_converter.rb b/app/models/product_import/unit_converter.rb new file mode 100644 index 0000000000..d7f9fdc3c0 --- /dev/null +++ b/app/models/product_import/unit_converter.rb @@ -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 diff --git a/app/models/product_importer.rb b/app/models/product_importer.rb deleted file mode 100644 index 83715a3719..0000000000 --- a/app/models/product_importer.rb +++ /dev/null @@ -1,800 +0,0 @@ -require 'roo' - -class ProductImporter - extend ActiveModel::Naming - include ActiveModel::Conversion - include ActiveModel::Validations - - attr_reader :total_supplier_products, :supplier_products, :updated_ids - - def initialize(file, current_user, 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 - @inventory_created = 0 - @inventory_updated = 0 - - @import_time = Time.zone.now - @import_settings = import_settings || {} - - @current_user = current_user - @editable_enterprises = {} - @inventory_permissions = {} - - @total_supplier_products = 0 - @supplier_products = {} - @reset_counts = {} - @updated_ids = [] - @products_reset_count = 0 - - init_product_importer if @sheet - else - self.errors.add(:importer, I18n.t(:product_importer_file_error)) - end - end - - def persisted? - false # ActiveModel - end - - def has_entries? - @entries.count > 0 - end - - def has_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] and values[:existing_products] - @reset_counts[supplier_id][:reset_count] = values[:existing_products] - values[:updates_count] - end - end - @reset_counts - 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 - @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 - @products_created + @variants_created - end - - def products_updated_count - @variants_updated - end - - def inventory_created_count - @inventory_created - end - - def inventory_updated_count - @inventory_updated - end - - def products_reset_count - @products_reset_count - end - - def total_saved_count - @products_created + @variants_created + @variants_updated + @inventory_created + @inventory_updated - end - - def save_all - save_all_valid - delete_uploaded_file - 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 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 - - def inventory_permission?(supplier_id, producer_id) - @current_user.admin? or ( @inventory_permissions[supplier_id] and @inventory_permissions[supplier_id].include? producer_id ) - end - - def validate_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) - product_validation(entry) - end - end - end - - def import_into_inventory?(entry) - entry.supplier_id and @import_settings[:settings][entry.supplier_id.to_s]['import_into'] == 'inventories' - end - - def save_entries - validate_entries - save_all_valid - end - - def reset_absent(updated_ids) - @products_created = updated_ids.count - @updated_ids = updated_ids - reset_absent_items - end - - private - - def init_product_importer - init_permissions - if @import_settings and @import_settings.has_key?(:start) and @import_settings.has_key?(:end) - build_entries_in_range - else - build_entries - end - build_categories_index - build_suppliers_index - build_tax_and_shipping_indexes - build_producers_index - count_existing_items unless @import_settings.has_key?(:start) - 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 - 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_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 - return if @sheet.last_row == line_number # TODO: test - 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) - 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) - product_validation(entry) - end - end - - count_existing_items - delete_uploaded_file if item_count.zero? or !has_valid_entries? - end - - # def importing_into_inventory? - # @import_settings[:import_into] == 'inventories' - # end - - def inventory_validation(entry) - # Find product with matching supplier and name - 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 existing_variant.display_name == entry.display_name and existing_variant.unit_value == entry.unit_value.to_f - variant_override = create_inventory_item(entry, existing_variant) - validate_inventory_item(entry, variant_override) - return - end - end - - mark_as_invalid(entry, attribute: 'product', error: I18n.t('admin.product_import.model.not_found')) - 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 validate_inventory_item(entry, variant_override) - if variant_override.valid? and !entry.has_errors? - mark_as_inventory_item(entry, variant_override) - else - mark_as_invalid(entry, product_validations: variant_override.errors) - end - end - - def mark_as_inventory_item(entry, variant_override) - if variant_override.id - entry.is_a_valid('existing_inventory_item') - entry.product_object = variant_override - updates_count_per_supplier(entry.supplier_id) unless entry.has_errors? - else - entry.is_a_valid('new_inventory_item') - entry.product_object = variant_override - end - end - - def count_existing_items - @suppliers_index.each do |supplier_name, supplier_id| - next unless supplier_id and 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. - joins(:product). - where('spree_products.supplier_id IN (?) - AND spree_variants.is_master = false - AND spree_variants.deleted_at IS NULL', supplier_id). - count - end - - @supplier_products[supplier_id] = products_count - @total_supplier_products += products_count - end - end - - def import_into_inventory_by_supplier?(supplier_id) - @import_settings[:settings] and @import_settings[:settings][supplier_id.to_s] and @import_settings[:settings][supplier_id.to_s]['import_into'] == 'inventories' - 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 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 producer_exists?(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, @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 = @producers_index[producer_name] - end - - def supplier_exists?(supplier_name) - @suppliers_index[supplier_name] - end - - def producer_exists?(producer_name) - @producers_index[producer_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 tax_and_shipping_validation(entry) - tax_validation(entry) - shipping_validation(entry) - end - - def tax_validation(entry) - return if entry.tax_category.blank? - if @tax_index.has_key? entry.tax_category - entry.tax_category_id = @tax_index[entry.tax_category] - else - mark_as_invalid(entry, attribute: "tax_category", error: I18n.t('admin.product_import.model.not_found')) - end - end - - def shipping_validation(entry) - return if entry.shipping_category.blank? - if @shipping_index.has_key? entry.shipping_category - entry.shipping_category_id = @shipping_index[entry.shipping_category] - else - mark_as_invalid(entry, attribute: "shipping_category", error: I18n.t('admin.product_import.model.not_found')) - end - end - - def category_exists?(category_name) - @categories_index[category_name] - 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] - 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_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 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 build_tax_and_shipping_indexes - @tax_index = {} - @shipping_index = {} - Spree::TaxCategory.select(%i[id name]).map { |tc| @tax_index[tc.name] = tc.id } - Spree::ShippingCategory.select(%i[id name]).map { |sc| @shipping_index[sc.name] = sc.id } - end - - def save_all_valid - @entries.each do |entry| - if import_into_inventory?(entry) - save_new_inventory_item entry if entry.is_a_valid? 'new_inventory_item' - save_existing_inventory_item entry if entry.is_a_valid? 'existing_inventory_item' - else - save_new_product entry if entry.is_a_valid? 'new_product' - save_new_variant entry if entry.is_a_valid? 'new_variant' - save_existing_variant entry if entry.is_a_valid? 'existing_variant' - end - end - - self.errors.add(:importer, I18n.t(:product_importer_products_save_error)) if total_saved_count.zero? - - reset_absent_items unless @import_settings.has_key?(:start) - total_saved_count - 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] and @already_created[entry.supplier_id][entry.name] - product_id = @already_created[entry.supplier_id][entry.name] - 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 - self.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 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 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? and new_item.save - display_in_inventory(new_item, true) - @inventory_created += 1 - @updated_ids.push new_item.id - else - self.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? and existing_item.save - display_in_inventory(existing_item) - @inventory_updated += 1 - @updated_ids.push existing_item.id - else - self.errors.add("#{I18n.t('admin.product_import.model.line')} #{line_number}:", existing_item.errors.full_messages) - end - end - - def save_new_variant(entry) - new_variant = entry.product_object - assign_defaults(new_variant, entry) - new_variant.import_date = @import_time - - if new_variant.valid? and new_variant.save - @variants_created += 1 - @updated_ids.push new_variant.id - else - self.errors.add("#{I18n.t('admin.product_import.model.line')} #{line_number}:", new_variant.errors.full_messages) - end - end - - def save_existing_variant(entry) - variant = entry.product_object - assign_defaults(variant, entry) - variant.import_date = @import_time - - if variant.valid? and variant.save - @variants_updated += 1 - @updated_ids.push variant.id - else - self.errors.add("#{I18n.t('admin.product_import.model.line')} #{line_number}:", variant.errors.full_messages) - end - end - - def reset_absent_items - return if total_saved_count.zero? or @updated_ids.empty? or !@import_settings.has_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'] and permission_by_id?(enterprise_id) and !import_into_inventory_by_supplier?(enterprise_id) - suppliers_to_reset_inventories.push enterprise_id if settings['reset_all_absent'] and permission_by_id?(enterprise_id) and import_into_inventory_by_supplier?(enterprise_id) - end - - # For selected enterprises; set stock to zero for all products/inventory - # items that were not present in the uploaded spreadsheet - 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 - - unless 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 - end - - def assign_defaults(object, entry) - return unless @import_settings.has_key?(:settings) and @import_settings[:settings][entry.supplier_id.to_s] and @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? or ((attribute == 'on_hand' or attribute == 'count_on_hand') and entry.on_hand_nil) - object.assign_attributes(attribute => setting['value']) - end - end - end - end - - def ensure_variant_updated(product, entry) - # Ensure attributes are copied to 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 unit_fields_validation(entry) - unit_types = ['g', 'kg', 't', 'ml', 'l', 'kl', ''] - - # unit must be present and not nil - unless entry.units and entry.units.present? - #self.errors.add('units', "can't be blank") - 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 and entry.unit_type.present? - unit_type = entry.unit_type.to_s.strip.downcase - #self.errors.add('unit_type', "incorrect value") unless unit_types.include?(unit_type) - mark_as_invalid(entry, attribute: 'unit_type', error: I18n.t('admin.product_import.model.incorrect_value')) unless unit_types.include?(unit_type) - end - - # variant_unit_name must be present if unit_type not present - if !entry.unit_type or (entry.unit_type and entry.unit_type.blank?) - #self.errors.add('variant_unit_name', "can't be blank if unit_type is blank") unless attrs.has_key? 'variant_unit_name' and attrs['variant_unit_name'].present? - mark_as_invalid(entry, attribute: 'variant_unit_name', error: I18n.t('admin.product_import.model.conditional_blank')) unless entry.variant_unit_name and entry.variant_unit_name.present? - 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 existing_variant.display_name == entry.display_name \ - && existing_variant.unit_value == entry.unit_value.to_f \ - && existing_variant.deleted_at.nil? - - 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? - entry.is_a_valid 'new_product' unless entry.has_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.is_a_valid 'existing_variant' unless entry.has_errors? - updates_count_per_supplier(entry.supplier_id) unless entry.has_errors? - 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 - entry.is_a_valid 'new_variant' unless entry.has_errors? - else - mark_as_invalid(entry, product_validations: new_variant.errors) - end - end - - def updates_count_per_supplier(supplier_id) - if @reset_counts[supplier_id] \ - and @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 - - def delete_uploaded_file - # Only delete if file is in '/tmp/product_import' directory - return unless @file.path == Rails.root.join('tmp', 'product_import').to_s - - File.delete(@file) - end -end diff --git a/app/models/spreadsheet_entry.rb b/app/models/spreadsheet_entry.rb deleted file mode 100644 index 42e9acdd63..0000000000 --- a/app/models/spreadsheet_entry.rb +++ /dev/null @@ -1,133 +0,0 @@ -# Class for defining spreadsheet entry objects for use in ProductImporter -class SpreadsheetEntry - extend ActiveModel::Naming - include ActiveModel::Conversion - include ActiveModel::Validations - - attr_reader :validates_as - - attr_accessor :line_number, :valid, :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) - #@product_validations = {} - @validates_as = '' - - #validate_custom_unit_fields(attrs, is_inventory) - convert_custom_unit_fields(attrs) - - 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 continue to ignore silently? - end - end - 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 convert_custom_unit_fields(attrs) - # unit 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 - - attrs['variant_unit'] = nil - attrs['variant_unit_scale'] = nil - attrs['unit_value'] = nil - - if attrs.key?('units') && attrs['units'].present? - attrs['unscaled_units'] = attrs['units'] - end - - if attrs.key?('units') && attrs.key?('unit_type') && attrs['units'].present? && attrs['unit_type'].present? - units = attrs['units'].to_f - unit_type = attrs['unit_type'].to_s.downcase - - if 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 - end - - return unless attrs.key?('units') && attrs.key?('variant_unit_name') && attrs['units'].present? && attrs['variant_unit_name'].present? - - attrs['variant_unit'] = 'items' - attrs['variant_unit_scale'] = nil - attrs['unit_value'] = units || 1 - end - - def persisted? - false #ActiveModel - end - - def is_a_valid?(type) - @validates_as == type - end - - def is_a_valid(type) - @validates_as = type - end - - def has_errors? - self.errors.count > 0 or @product_validations - 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 = {} - 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 valid_unit_type?(unit_type) - unit_scales.key? unit_type - 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', 'tax_category', 'shipping_category'] - end -end diff --git a/app/models/spree/ability_decorator.rb b/app/models/spree/ability_decorator.rb index b12124b067..ed8d15fb47 100644 --- a/app/models/spree/ability_decorator.rb +++ b/app/models/spree/ability_decorator.rb @@ -177,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, :save_data, :process_data, :reset_absent_products], ProductImporter + can [:admin, :index, :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 diff --git a/app/views/admin/product_import/_entries_table.html.haml b/app/views/admin/product_import/_entries_table.html.haml index 4d19f19b21..8ad958b1c2 100644 --- a/app/views/admin/product_import/_entries_table.html.haml +++ b/app/views/admin/product_import/_entries_table.html.haml @@ -11,4 +11,4 @@ %td {{line_number}} %td{ng: {repeat: "(attribute, value) in entry.attributes", class: "{'invalid': attribute_invalid(attribute, line_number)}"}} - {{value}} \ No newline at end of file + {{value}} diff --git a/app/views/admin/product_import/_import_options.html.haml b/app/views/admin/product_import/_import_options.html.haml index 7062694810..3c79357e46 100644 --- a/app/views/admin/product_import/_import_options.html.haml +++ b/app/views/admin/product_import/_import_options.html.haml @@ -1,4 +1,4 @@ -%h5 #{t('admin.product_import.import.options_and_defaults')} +%h5= t('admin.product_import.import.options_and_defaults') %br - @importer.suppliers_index.each do |name, supplier_id| @@ -38,7 +38,7 @@ %div.header-icon.error %i.fa.fa-warning %div.header-description - #{t('admin.product_import.import.no_name')} + = t('admin.product_import.import.no_name') %span.header-error= " - #{t('admin.product_import.import.blank_supplier')}" %br.panels.clearfix diff --git a/app/views/admin/product_import/_import_review.html.haml b/app/views/admin/product_import/_import_review.html.haml index 0e65bc6135..9015c31bb8 100644 --- a/app/views/admin/product_import/_import_review.html.haml +++ b/app/views/admin/product_import/_import_review.html.haml @@ -1,4 +1,4 @@ -%h5 #{t('admin.product_import.import.validation_overview')} +%h5= t('admin.product_import.import.validation_overview') %br %div{ng: {controller: 'ImportFeedbackCtrl'}} @@ -11,9 +11,9 @@ %i.fa.fa-info-circle.info %div.header-count %strong.item-count - {{count((entries | entriesFilterValid:"all"))}} + {{ count((entries | entriesFilterValid:"all")) }} %div.header-description - #{t('admin.product_import.import.entries_found')} + = t('admin.product_import.import.entries_found') %div.panel-content{ng: {hide: '!active || count((entries | entriesFilterValid:"all")) == 0'}} = render 'entries_table', entries: 'all' @@ -25,9 +25,9 @@ %i.fa.fa-warning %div.header-count %strong.invalid-count - {{count((entries | entriesFilterValid:"invalid"))}} + {{ count((entries | entriesFilterValid:"invalid")) }} %div.header-description - #{t('admin.product_import.import.entries_with_errors')} + = t('admin.product_import.import.entries_with_errors') %div.panel-content{ng: {hide: '!active || count((entries | entriesFilterValid:"invalid")) == 0'}} = render 'errors_list' %br @@ -41,9 +41,9 @@ %i.fa.fa-check-circle %div.header-count %strong.create-count - {{count((entries | entriesFilterValid:"create_product"))}} + {{ count((entries | entriesFilterValid:"create_product")) }} %div.header-description - #{t('admin.product_import.import.products_to_create')} + = t('admin.product_import.import.products_to_create') %div.panel-content{ng: {hide: '!active || count((entries | entriesFilterValid:"create_product")) == 0'}} = render 'entries_table', entries: 'create_product' @@ -55,9 +55,9 @@ %i.fa.fa-check-circle %div.header-count %strong.update-count - {{count((entries | entriesFilterValid:"update_product"))}} + {{ count((entries | entriesFilterValid:"update_product")) }} %div.header-description - #{t('admin.product_import.import.products_to_update')} + = t('admin.product_import.import.products_to_update') %div.panel-content{ng: {hide: '!active || count((entries | entriesFilterValid:"update_product")) == 0'}} = render 'entries_table', entries: 'update_product' @@ -69,9 +69,9 @@ %i.fa.fa-check-circle %div.header-count %strong.inv-create-count - {{count((entries | entriesFilterValid:"create_inventory"))}} + {{ count((entries | entriesFilterValid:"create_inventory")) }} %div.header-description - #{t('admin.product_import.import.inventory_to_create')} + = t('admin.product_import.import.inventory_to_create') %div.panel-content{ng: {hide: '!active || count((entries | entriesFilterValid:"create_inventory")) == 0'}} = render 'entries_table', entries: 'create_inventory' @@ -83,9 +83,9 @@ %i.fa.fa-check-circle %div.header-count %strong.inv-update-count - {{count((entries | entriesFilterValid:"update_inventory"))}} + {{ count((entries | entriesFilterValid:"update_inventory")) }} %div.header-description - #{t('admin.product_import.import.inventory_to_update')} + = t('admin.product_import.import.inventory_to_update') %div.panel-content{ng: {hide: '!active || count((entries | entriesFilterValid:"update_inventory")) == 0'}} = render 'entries_table', entries: 'update_inventory' @@ -96,12 +96,11 @@ %i.fa.fa-info-circle %div.header-count %strong.reset-count - {{resetTotal}} + {{ resetTotal }} %div.header-description -if @import_into == 'inventories' - #{t('admin.product_import.import.inventory_to_reset')} + = t('admin.product_import.import.inventory_to_reset') - else - #{t('admin.product_import.import.products_to_reset')} - -#%div.panel-content{ng: {hide: '!active'}} + = t('admin.product_import.import.products_to_reset') - %br.panels.clearfix \ No newline at end of file + %br.panels.clearfix diff --git a/app/views/admin/product_import/_options_form.html.haml b/app/views/admin/product_import/_options_form.html.haml index 156f233361..190967b664 100644 --- a/app/views/admin/product_import/_options_form.html.haml +++ b/app/views/admin/product_import/_options_form.html.haml @@ -9,7 +9,7 @@ %tr{ng: {show: 'import_into == "inventories"'}} %td.description - #{t('admin.product_import.import.default_stock')} + = t('admin.product_import.import.default_stock') %td = check_box_tag "settings[#{supplier_id}][defaults][count_on_hand][active]", 1, false, 'ng-model' => "settings[#{supplier_id}]['defaults']['count_on_hand']['active']" %td @@ -19,7 +19,7 @@ %tr{ng: {show: 'import_into == "product_list"'}} %td.description - #{t('admin.product_import.import.default_stock')} + = t('admin.product_import.import.default_stock') %td = check_box_tag "settings[#{supplier_id}][defaults][on_hand][active]", 1, false, 'ng-model' => "settings[#{supplier_id}]['defaults']['on_hand']['active']" %td @@ -28,7 +28,7 @@ = number_field_tag "settings[#{supplier_id}][defaults][on_hand][value]", 0, 'ng-model' => "settings[#{supplier_id}]['defaults']['on_hand']['value']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['on_hand']['active']" %tr{ng: {show: 'import_into == "product_list"'}} %td.description - #{t('admin.product_import.import.default_tax_cat')} + = t('admin.product_import.import.default_tax_cat') %td = check_box_tag "settings[#{supplier_id}][defaults][tax_category_id][active]", 1, false, 'ng-model' => "settings[#{supplier_id}]['defaults']['tax_category_id']['active']" %td @@ -37,7 +37,7 @@ = select_tag "settings[#{supplier_id}][defaults][tax_category_id][value]", options_for_select(@tax_categories.map {|tc| [tc.name, tc.id]}), {prompt: 'None', class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['tax_category_id']['value']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['tax_category_id']['active']"} %tr{ng: {show: 'import_into == "product_list"'}} %td.description - #{t('admin.product_import.import.default_shipping_cat')} + = t('admin.product_import.import.default_shipping_cat') %td = check_box_tag "settings[#{supplier_id}][defaults][shipping_category_id][active]", 1, false, 'ng-model' => "settings[#{supplier_id}]['defaults']['shipping_category_id']['active']" %td @@ -46,7 +46,7 @@ = select_tag "settings[#{supplier_id}][defaults][shipping_category_id][value]", options_for_select(@shipping_categories.map {|sc| [sc.name, sc.id]}), {prompt: 'None', class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['shipping_category_id']['value']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['shipping_category_id']['active']"} %tr{ng: {show: 'import_into == "product_list"'}} %td.description - #{t('admin.product_import.import.default_available_date')} + = t('admin.product_import.import.default_available_date') %td = check_box_tag "settings[#{supplier_id}][defaults][available_on][active]", 1, false, 'ng-model' => "settings[#{supplier_id}]['defaults']['available_on']['active']" %td @@ -56,8 +56,8 @@ %tr %td.description - #{t('admin.product_import.import.reset_absent?')} + = t('admin.product_import.import.reset_absent?') %td = check_box_tag "settings[#{supplier_id}][reset_all_absent]", 1, false, :'ng-model' => "settings[#{supplier_id}]['reset_all_absent']", :'ng-change' => "toggleResetAbsent('#{supplier_id}')" %td - %td \ No newline at end of file + %td diff --git a/app/views/admin/product_import/_save_results.html.haml b/app/views/admin/product_import/_save_results.html.haml index 79ad8d995c..82101b8a91 100644 --- a/app/views/admin/product_import/_save_results.html.haml +++ b/app/views/admin/product_import/_save_results.html.haml @@ -1,5 +1,5 @@ -%h5 #{t('admin.product_import.save.final_results')} +%h5= t('admin.product_import.save.final_results') %br %div.post-save-results @@ -7,53 +7,57 @@ %p{ng: {show: 'updates.products_created'}} %i.fa{ng: {class: "{'fa-info-circle': updates.products_created == 0, 'fa-check-circle': updates.products_created > 0}"}} %strong.created-count - {{updates.products_created}} - #{t('admin.product_import.save.products_created')} + {{ updates.products_created }} + = t('admin.product_import.save.products_created') %p{ng: {show: 'updates.products_updated'}} %i.fa{ng: {class: "{'fa-info-circle': updates.products_updated == 0, 'fa-check-circle': updates.products_updated > 0}"}} %strong.updated-count - {{updates.products_updated}} - #{t('admin.product_import.save.products_updated')} + {{ updates.products_updated }} + = t('admin.product_import.save.products_updated') %p{ng: {show: 'updates.inventory_created'}} %i.fa{ng: {class: "{'fa-info-circle': updates.inventory_created == 0, 'fa-check-circle': updates.inventory_created > 0}"}} %strong.inv-created-count - {{updates.inventory_created}} - #{t('admin.product_import.save.inventory_created')} + {{ updates.inventory_created }} + = t('admin.product_import.save.inventory_created') %p{ng: {show: 'updates.inventory_updated'}} %i.fa{ng: {class: "{'fa-info-circle': updates.inventory_updated == 0, 'fa-check-circle': updates.inventory_updated > 0}"}} %strong.inv-updated-count - {{updates.inventory_updated}} - #{t('admin.product_import.save.inventory_updated')} + {{ updates.inventory_updated }} + = t('admin.product_import.save.inventory_updated') %p{ng: {show: 'updates.products_reset'}} %i.fa.fa-info-circle %strong.reset-count - {{updates.products_reset}} + {{ updates.products_reset }} - if @import_into == 'inventories' - #{t('admin.product_import.save.inventory_reset')} + = t('admin.product_import.save.inventory_reset') - else - #{t('admin.product_import.save.products_reset')} + = t('admin.product_import.save.products_reset') %br %p{ng: {show: 'update_errors.length == 0'}} - #{t('admin.product_import.save.all_saved')} + = t('admin.product_import.save.all_saved') %div{ng: {show: 'update_errors.length > 0'}} - %p {{updated_total}} #{t('admin.product_import.save.some_saved')} + %p {{ updated_total }} #{t('admin.product_import.save.some_saved')} %br - %h5 #{t('admin.product_import.save.save_errors')} + + %h5= t('admin.product_import.save.save_errors') %p.save-error{ng: {repeat: 'error in update_errors'}} -  -  {{error}} +  -  {{ error }} %br %div{ng: {show: 'updated_total > 0'}} - %a.button.view{href: main_app.admin_inventory_path, ng: {show: 'updates.inventory_created > 0 || updates.inventory_updated > 0'}} #{t('admin.product_import.save.view_inventory')} + %a.button.view{href: main_app.admin_inventory_path, ng: {show: 'updates.inventory_created > 0 || updates.inventory_updated > 0'}} + = t('admin.product_import.save.view_inventory') - %a.button.view{href: bulk_edit_admin_products_path + '?latest_import=true', ng: {show: 'updates.products_created > 0 || updates.products_updated > 0'}} #{t('admin.product_import.save.view_products')} + %a.button.view{href: bulk_edit_admin_products_path + '?latest_import=true', ng: {show: 'updates.products_created > 0 || updates.products_updated > 0'}} + = t('admin.product_import.save.view_products') - %a.button{href: main_app.admin_product_import_path} #{t('admin.back')} + %a.button{href: main_app.admin_product_import_path} + = t('admin.back') diff --git a/app/views/admin/product_import/_upload_form.html.haml b/app/views/admin/product_import/_upload_form.html.haml index dc7e21050d..9c18cf47c6 100644 --- a/app/views/admin/product_import/_upload_form.html.haml +++ b/app/views/admin/product_import/_upload_form.html.haml @@ -1,6 +1,6 @@ %div{ng: {app: 'ofn.admin'}} - %h5 #{t('admin.product_import.index.select_file')} + %h5= t('admin.product_import.index.select_file') %br = form_tag main_app.admin_product_import_path, multipart: true, class: 'product-import' do %label #{t('admin.product_import.index.spreadsheet')} diff --git a/app/views/admin/product_import/import.html.haml b/app/views/admin/product_import/import.html.haml index 95a5e5275a..dddcd69c4f 100644 --- a/app/views/admin/product_import/import.html.haml +++ b/app/views/admin/product_import/import.html.haml @@ -6,30 +6,32 @@ .import-wrapper{ng: {app: 'ofn.admin', controller: 'ImportFormCtrl', init: "supplier_product_counts = #{@importer.supplier_products.to_json}"}} - if @importer.item_count == 0 #and @importer.invalid_count - %h5 #{t('admin.product_import.import.no_valid_entries')} - %p #{t('admin.product_import.import.none_to_save')} + %h5 + = t('admin.product_import.import.no_valid_entries') + %p + = t('admin.product_import.import.none_to_save') %br - else .settings-section{ng: {show: 'step == "settings"'}} = render 'import_options' if @importer.table_headings %br %a.button.proceed{href: '', ng: {click: 'confirmSettings()'}} - #{t('admin.product_import.import.proceed')} + = t('admin.product_import.import.proceed') %a.button{href: main_app.admin_product_import_path} #{t('admin.cancel')} .progress-interface{ng: {show: 'step == "import"'}} %span.filename - #{@original_filename} + = @original_filename %span.percentage - ({{percentage}}) + ({{ percentage }}) .progress-bar %span.progress-track{class: 'ng-binding', style: "width:{{percentage}}"} %button.start_import{ng: {click: 'start()', disabled: 'started', init: "item_count = #{@importer.item_count}; import_url = '#{main_app.admin_product_import_process_async_path}'; filepath = '#{@filepath}'; import_into = '#{@import_into}'"}} - #{t('admin.product_import.index.import')} + = t('admin.product_import.index.import') %button.review{ng: {click: 'viewResults()', disabled: '!finished'}} - #{t('admin.product_import.import.review')} + = t('admin.product_import.import.review') %p.red - {{exception}} + {{ exception }} = form_tag false, {class: 'product-import', name: 'importForm', 'ng-show' => 'step == "results"'} do @@ -38,36 +40,35 @@ %div{ng: {controller: 'ImportFeedbackCtrl', show: 'count((entries | entriesFilterValid:"valid")) > 0'}} %div{ng: {if: 'count((entries | entriesFilterValid:"invalid")) > 0'}} %br - %h5 #{t('admin.product_import.import.some_invalid_entries')} - %p #{t('admin.product_import.import.save_valid?')} + %h5= t('admin.product_import.import.some_invalid_entries') + %p= t('admin.product_import.import.save_valid?') %div{ng: {show: 'count((entries | entriesFilterValid:"invalid")) == 0'}} %br - %h5 #{t('admin.product_import.import.no_errors')} - %p #{t('admin.product_import.import.save_all_imported?')} + %h5= t('admin.product_import.import.no_errors') + %p= t('admin.product_import.import.save_all_imported?') %br = hidden_field_tag :filepath, @filepath = hidden_field_tag "settings[import_into]", @import_into - %a.button.proceed{href: '', ng: {click: 'acceptResults()'}} - #{t('admin.product_import.import.proceed')} + %a.button.proceed{href: '', ng: {click: 'acceptResults()'}}= t('admin.product_import.import.proceed') - %a.button{href: main_app.admin_product_import_path} #{t('admin.cancel')} + %a.button{href: main_app.admin_product_import_path}= t('admin.cancel') %div{ng: {controller: 'ImportFeedbackCtrl', show: 'count((entries | entriesFilterValid:"valid")) == 0'}} %br - %a.button{href: main_app.admin_product_import_path} #{t('admin.cancel')} + %a.button{href: main_app.admin_product_import_path}= t('admin.cancel') .progress-interface{ng: {show: 'step == "save"'}} %span.filename - #{t('admin.product_import.import.save_imported')} ({{percentage}}) + #{t('admin.product_import.import.save_imported')} ({{ percentage }}) .progress-bar{} %span.progress-track{ng: {style: "{'width':percentage}"}} %button.start_save{ng: {click: 'start()', disabled: 'started', init: "item_count = #{@importer.item_count}; save_url = '#{main_app.admin_product_import_save_async_path}'; reset_url = '#{main_app.admin_product_import_reset_async_path}'; filepath = '#{@filepath}'; import_into = '#{@import_into}'"}} - #{t('admin.product_import.import.save')} + = t('admin.product_import.import.save') %button.view_results{ng: {click: 'finalResults()', disabled: '!finished'}} - #{t('admin.product_import.import.results')} + = t('admin.product_import.import.results') %p.red - {{exception}} + {{ exception }} .save-results{ng: {show: 'step == "complete"'}} = render 'save_results' diff --git a/app/views/admin/product_import/save.html.haml b/app/views/admin/product_import/save.html.haml index b3f76010d2..ce5f6f57b9 100644 --- a/app/views/admin/product_import/save.html.haml +++ b/app/views/admin/product_import/save.html.haml @@ -3,7 +3,7 @@ = render partial: 'spree/admin/shared/product_sub_menu' -%h5 #{t('admin.product_import.save.final_results')} +%h5= t('admin.product_import.save.final_results') %br %div.post-save-results{ng: {app: 'ofn.admin'}} @@ -12,43 +12,43 @@ %p %i.fa{ng: {class: "{'fa-info-circle': #{@importer.products_created_count} == 0, 'fa-check-circle': #{@importer.products_created_count} != 0}"}} %strong.created-count= @importer.products_created_count - #{t('admin.product_import.save.products_created')} + = t('admin.product_import.save.products_created') - if @importer.products_updated_count > 0 %p %i.fa{ng: {class: "{'fa-info-circle': #{@importer.products_updated_count} == 0, 'fa-check-circle': #{@importer.products_updated_count} != 0}"}} %strong.updated-count= @importer.products_updated_count - #{t('admin.product_import.save.products_updated')} + = t('admin.product_import.save.products_updated') - if @importer.inventory_created_count > 0 %p %i.fa{ng: {class: "{'fa-info-circle': #{@importer.inventory_created_count} == 0, 'fa-check-circle': #{@importer.inventory_created_count} != 0}"}} %strong.inv-created-count= @importer.inventory_created_count - #{t('admin.product_import.save.inventory_created')} + = t('admin.product_import.save.inventory_created') - if @importer.inventory_updated_count > 0 %p %i.fa{ng: {class: "{'fa-info-circle': #{@importer.inventory_updated_count} == 0, 'fa-check-circle': #{@importer.inventory_updated_count} != 0}"}} %strong.inv-updated-count= @importer.inventory_updated_count - #{t('admin.product_import.save.inventory_updated')} + = t('admin.product_import.save.inventory_updated') - if @importer.products_reset_count > 0 %p %i.fa.fa-info-circle %strong.reset-count= @importer.products_reset_count - if @import_into == 'inventories' - #{t('admin.product_import.save.inventory_reset')} + = t('admin.product_import.save.inventory_reset') - else - #{t('admin.product_import.save.products_reset')} + = t('admin.product_import.save.products_reset') %br - if @importer.errors.count == 0 - %p #{t('admin.product_import.save.all_saved', { num: "#{@importer.total_saved_count}" })} + %p= t('admin.product_import.save.all_saved', { num: "#{@importer.total_saved_count}" }) - else - %p #{t('admin.product_import.save.total_saved', { num: "#{@importer.total_saved_count}" })} + %p= t('admin.product_import.save.total_saved', { num: "#{@importer.total_saved_count}" }) %br - %h5 #{t('admin.product_import.save.save_errors')} + %h5= t('admin.product_import.save.save_errors') - @importer.errors.full_messages.each do |error| %p.save-error  -  #{error} @@ -56,8 +56,8 @@ %br - if @importer.total_saved_count > 0 - if @import_into == 'inventories' - %a.button{href: main_app.admin_inventory_path} #{t('admin.product_import.save.view_inventory')} + %a.button{href: main_app.admin_inventory_path}= t('admin.product_import.save.view_inventory') - else - %a.button{href: bulk_edit_admin_products_path + '?latest_import=true'} #{t('admin.product_import.save.view_products')} + %a.button{href: bulk_edit_admin_products_path + '?latest_import=true'}= t('admin.product_import.save.view_products') - %a.button{href: main_app.admin_product_import_path} #{t('admin.back')} + %a.button{href: main_app.admin_product_import_path}= t('admin.back') diff --git a/config/routes.rb b/config/routes.rb index 58e74ae5d0..fe62513e2a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -135,7 +135,7 @@ Openfoodnetwork::Application.routes.draw do get '/product_import', to: 'product_import#index' post '/product_import', to: 'product_import#import' - post '/product_import/process_data', to: 'product_import#process_data', as: 'product_import_process_async' + post '/product_import/validate_data', to: 'product_import#validate_data', as: 'product_import_process_async' post '/product_import/save_data', to: 'product_import#save_data', as: 'product_import_save_async' post '/product_import/reset_absent', to: 'product_import#reset_absent_products', as: 'product_import_reset_async' #post '/product_import/save', to: 'product_import#save', as: 'product_import_save' diff --git a/spec/features/admin/product_import_spec.rb b/spec/features/admin/product_import_spec.rb index 2e26d41a06..e85db8753f 100644 --- a/spec/features/admin/product_import_spec.rb +++ b/spec/features/admin/product_import_spec.rb @@ -65,10 +65,10 @@ feature "Product Import", js: true do carrots = Spree::Product.find_by_name('Carrots') potatoes = Spree::Product.find_by_name('Potatoes') - potatoes.supplier.should == enterprise - potatoes.on_hand.should == 6 - potatoes.price.should == 6.50 - potatoes.variants.first.import_date.should be_within(1.minute).of DateTime.now + expect(potatoes.supplier).to eq enterprise + expect(potatoes.on_hand).to eq 6 + expect(potatoes.price).to eq 6.50 + expect(potatoes.variants.first.import_date).to be_within(1.minute).of Time.zone.now wait_until { page.find("a.button.view").present? } @@ -107,6 +107,44 @@ feature "Product Import", js: true do expect(page).to_not have_selector 'input[type=submit][value="Save"]' end + it "handles validation and saving of named tax and shipping categories" do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "category", "on_hand", "price", "units", "unit_type", "tax_category", "shipping_category"] + csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "g", tax_category.name, shipping_category.name] + csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "6.50", "1", "kg", "Unknown Tax Category", shipping_category.name] + csv << ["Peas", "User Enterprise", "Vegetables", "7", "2.50", "1", "kg", tax_category2.name, "Unknown Shipping Category"] + end + File.write('/tmp/test.csv', csv_data) + + visit main_app.admin_product_import_path + + expect(page).to have_content "Select a spreadsheet to upload" + attach_file 'file', '/tmp/test.csv' + click_button 'Upload' + + expect(page).to have_selector 'a.button.proceed', visible: true + click_link 'Proceed' + + import_data + + expect(page).to have_selector '.item-count', text: "3" + expect(page).to have_selector '.invalid-count', text: "2" + expect(page).to have_selector '.create-count', text: "1" + expect(page).to_not have_selector '.update-count' + + expect(page).to have_selector 'a.button.proceed', visible: true + click_link 'Proceed' + + save_data + + expect(page).to have_selector '.created-count', text: '1' + expect(page).to_not have_selector '.updated-count' + + carrots = Spree::Product.find_by_name('Carrots') + expect(carrots.tax_category).to eq tax_category + expect(carrots.shipping_category).to eq shipping_category + end + it "records a timestamp on import that can be viewed and filtered under Bulk Edit Products" do csv_data = CSV.generate do |csv| csv << ["name", "supplier", "category", "on_hand", "price", "units", "unit_type"] @@ -132,9 +170,9 @@ feature "Product Import", js: true do save_data carrots = Spree::Product.find_by_name('Carrots') - carrots.variants.first.import_date.should be_within(1.minute).of DateTime.now + expect(carrots.variants.first.import_date).to be_within(1.minute).of Time.zone.now potatoes = Spree::Product.find_by_name('Potatoes') - potatoes.variants.first.import_date.should be_within(1.minute).of DateTime.now + expect(potatoes.variants.first.import_date).to be_within(1.minute).of Time.zone.now click_link 'View Products' @@ -204,14 +242,14 @@ feature "Product Import", js: true do sprouts_override = VariantOverride.where(variant_id: product3.variants.first.id, hub_id: enterprise2.id).first cabbage_override = VariantOverride.where(variant_id: product4.variants.first.id, hub_id: enterprise2.id).first - Float(beans_override.price).should == 3.20 - beans_override.count_on_hand.should == 5 + expect(Float(beans_override.price)).to eq 3.20 + expect(beans_override.count_on_hand).to eq 5 - Float(sprouts_override.price).should == 6.50 - sprouts_override.count_on_hand.should == 6 + expect(Float(sprouts_override.price)).to eq 6.50 + expect(sprouts_override.count_on_hand).to eq 6 - Float(cabbage_override.price).should == 1.50 - cabbage_override.count_on_hand.should == 2001 + expect(Float(cabbage_override.price)).to eq 1.50 + expect(cabbage_override.count_on_hand).to eq 2001 click_link 'View Inventory' expect(page).to have_content 'Inventory' @@ -300,8 +338,8 @@ feature "Product Import", js: true do expect(page).to have_selector '.created-count', text: '1' - Spree::Product.find_by_name('My Carrots').should be_a Spree::Product - Spree::Product.find_by_name('Your Potatoes').should == nil + expect(Spree::Product.find_by_name('My Carrots')).to be_a Spree::Product + expect(Spree::Product.find_by_name('Your Potatoes')).to be_nil end end diff --git a/spec/models/product_importer_spec.rb b/spec/models/product_importer_spec.rb index 025d029390..465b9809b0 100644 --- a/spec/models/product_importer_spec.rb +++ b/spec/models/product_importer_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' require 'open_food_network/permissions' -describe ProductImporter do +describe ProductImport::ProductImporter do include AuthenticationWorkflow let!(:admin) { create(:admin_user) } @@ -45,7 +45,7 @@ describe ProductImporter do File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') settings = {enterprise.id.to_s => {'import_into' => 'product_list'}} - @importer = ProductImporter.new(file, admin, {start: 1, end: 100, settings: settings}) + @importer = ProductImport::ProductImporter.new(file, admin, start: 1, end: 100, settings: settings) end after { File.delete('/tmp/test-m.csv') } @@ -71,54 +71,54 @@ describe ProductImporter do expect(@importer.updated_ids.count).to eq 5 carrots = Spree::Product.find_by_name('Carrots') - carrots.supplier.should == enterprise - carrots.on_hand.should == 5 - carrots.price.should == 3.20 - carrots.unit_value.should == 500 - carrots.variant_unit.should == 'weight' - carrots.variant_unit_scale.should == 1 - carrots.on_demand.should_not == true - carrots.variants.first.import_date.should be_within(1.minute).of DateTime.now + expect(carrots.supplier).to eq enterprise + expect(carrots.on_hand).to eq 5 + expect(carrots.price).to eq 3.20 + expect(carrots.unit_value).to eq 500 + expect(carrots.variant_unit).to eq 'weight' + expect(carrots.variant_unit_scale).to eq 1 + expect(carrots.on_demand).to_not eq true + expect(carrots.variants.first.import_date).to be_within(1.minute).of Time.zone.now potatoes = Spree::Product.find_by_name('Potatoes') - potatoes.supplier.should == enterprise - potatoes.on_hand.should == 6 - potatoes.price.should == 6.50 - potatoes.unit_value.should == 2000 - potatoes.variant_unit.should == 'weight' - potatoes.variant_unit_scale.should == 1000 - potatoes.on_demand.should_not == true - potatoes.variants.first.import_date.should be_within(1.minute).of DateTime.now + expect(potatoes.supplier).to eq enterprise + expect(potatoes.on_hand).to eq 6 + expect(potatoes.price).to eq 6.50 + expect(potatoes.unit_value).to eq 2000 + expect(potatoes.variant_unit).to eq 'weight' + expect(potatoes.variant_unit_scale).to eq 1000 + expect(potatoes.on_demand).to_not eq true + expect(potatoes.variants.first.import_date).to be_within(1.minute).of Time.zone.now pea_soup = Spree::Product.find_by_name('Pea Soup') - pea_soup.supplier.should == enterprise - pea_soup.on_hand.should == 8 - pea_soup.price.should == 5.50 - pea_soup.unit_value.should == 0.75 - pea_soup.variant_unit.should == 'volume' - pea_soup.variant_unit_scale.should == 0.001 - pea_soup.on_demand.should_not == true - pea_soup.variants.first.import_date.should be_within(1.minute).of DateTime.now + expect(pea_soup.supplier).to eq enterprise + expect(pea_soup.on_hand).to eq 8 + expect(pea_soup.price).to eq 5.50 + expect(pea_soup.unit_value).to eq 0.75 + expect(pea_soup.variant_unit).to eq 'volume' + expect(pea_soup.variant_unit_scale).to eq 0.001 + expect(pea_soup.on_demand).to_not eq true + expect(pea_soup.variants.first.import_date).to be_within(1.minute).of Time.zone.now salad = Spree::Product.find_by_name('Salad') - salad.supplier.should == enterprise - salad.on_hand.should == 7 - salad.price.should == 4.50 - salad.unit_value.should == 1 - salad.variant_unit.should == 'items' - salad.variant_unit_scale.should == nil - salad.on_demand.should_not == true - salad.variants.first.import_date.should be_within(1.minute).of DateTime.now + expect(salad.supplier).to eq enterprise + expect(salad.on_hand).to eq 7 + expect(salad.price).to eq 4.50 + expect(salad.unit_value).to eq 1 + expect(salad.variant_unit).to eq 'items' + expect(salad.variant_unit_scale).to eq nil + expect(salad.on_demand).to_not eq true + expect(salad.variants.first.import_date).to be_within(1.minute).of Time.zone.now buns = Spree::Product.find_by_name('Hot Cross Buns') - buns.supplier.should == enterprise - #buns.on_hand.should == Infinity - buns.price.should == 3.50 - buns.unit_value.should == 1 - buns.variant_unit.should == 'items' - buns.variant_unit_scale.should == nil - buns.on_demand.should == true - buns.variants.first.import_date.should be_within(1.minute).of DateTime.now + expect(buns.supplier).to eq enterprise + # buns.on_hand).to eq Infinity + expect(buns.price).to eq 3.50 + expect(buns.unit_value).to eq 1 + expect(buns.variant_unit).to eq 'items' + expect(buns.variant_unit_scale).to eq nil + expect(buns.on_demand).to eq true + expect(buns.variants.first.import_date).to be_within(1.minute).of Time.zone.now end end @@ -132,7 +132,7 @@ describe ProductImporter do File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') settings = {enterprise.id.to_s => {'import_into' => 'product_list'}} - @importer = ProductImporter.new(file, admin, {start: 1, end: 100, settings: settings}) + @importer = ProductImport::ProductImporter.new(file, admin, start: 1, end: 100, settings: settings) end after { File.delete('/tmp/test-m.csv') } @@ -154,12 +154,12 @@ describe ProductImporter do expect(@importer.updated_ids.count).to eq 1 carrots = Spree::Product.find_by_name('Good Carrots') - carrots.supplier.should == enterprise - carrots.on_hand.should == 5 - carrots.price.should == 3.20 - carrots.variants.first.import_date.should be_within(1.minute).of DateTime.now + expect(carrots.supplier).to eq enterprise + expect(carrots.on_hand).to eq 5 + expect(carrots.price).to eq 3.20 + expect(carrots.variants.first.import_date).to be_within(1.minute).of Time.zone.now - Spree::Product.find_by_name('Bad Potatoes').should == nil + expect(Spree::Product.find_by_name('Bad Potatoes')).to eq nil end end @@ -173,7 +173,7 @@ describe ProductImporter do File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') settings = {enterprise2.id.to_s => {'import_into' => 'product_list'}} - @importer = ProductImporter.new(file, admin, {start: 1, end: 100, settings: settings}) + @importer = ProductImport::ProductImporter.new(file, admin, start: 1, end: 100, settings: settings) end after { File.delete('/tmp/test-m.csv') } @@ -196,18 +196,17 @@ describe ProductImporter do expect(@importer.updated_ids.count).to eq 2 added_coffee = Spree::Variant.find_by_display_name('Emergent Coffee') - added_coffee.product.name.should == 'Hypothetical Cake' - added_coffee.price.should == 3.50 - added_coffee.on_hand.should == 6 - added_coffee.import_date.should be_within(1.minute).of DateTime.now + expect(added_coffee.product.name).to eq 'Hypothetical Cake' + expect(added_coffee.price).to eq 3.50 + expect(added_coffee.on_hand).to eq 6 + expect(added_coffee.import_date).to be_within(1.minute).of Time.zone.now updated_banana = Spree::Variant.find_by_display_name('Preexisting Banana') - updated_banana.product.name.should == 'Hypothetical Cake' - updated_banana.price.should == 5.50 - updated_banana.on_hand.should == 5 - updated_banana.import_date.should be_within(1.minute).of DateTime.now + expect(updated_banana.product.name).to eq 'Hypothetical Cake' + expect(updated_banana.price).to eq 5.50 + expect(updated_banana.on_hand).to eq 5 + expect(updated_banana.import_date).to be_within(1.minute).of Time.zone.now end - end describe "adding new product and sub-variant at the same time" do @@ -220,7 +219,7 @@ describe ProductImporter do File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') settings = {enterprise.id.to_s => {'import_into' => 'product_list'}} - @importer = ProductImporter.new(file, admin, {start: 1, end: 100, settings: settings}) + @importer = ProductImport::ProductImporter.new(file, admin, start: 1, end: 100, settings: settings) end after { File.delete('/tmp/test-m.csv') } @@ -241,14 +240,14 @@ describe ProductImporter do expect(@importer.updated_ids.count).to eq 2 small_bag = Spree::Variant.find_by_display_name('Small Bag') - small_bag.product.name.should == 'Potatoes' - small_bag.price.should == 3.50 - small_bag.on_hand.should == 5 + expect(small_bag.product.name).to eq 'Potatoes' + expect(small_bag.price).to eq 3.50 + expect(small_bag.on_hand).to eq 5 big_bag = Spree::Variant.find_by_display_name('Big Bag') - big_bag.product.name.should == 'Potatoes' - big_bag.price.should == 5.50 - big_bag.on_hand.should == 6 + expect(big_bag.product.name).to eq 'Potatoes' + expect(big_bag.price).to eq 5.50 + expect(big_bag.on_hand).to eq 6 end end @@ -262,7 +261,7 @@ describe ProductImporter do File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') settings = {enterprise3.id.to_s => {'import_into' => 'product_list'}} - @importer = ProductImporter.new(file, admin, {start: 1, end: 100, settings: settings}) + @importer = ProductImport::ProductImporter.new(file, admin, start: 1, end: 100, settings: settings) end after { File.delete('/tmp/test-m.csv') } @@ -277,11 +276,6 @@ describe ProductImporter do end it "saves and updates" do - - beetroot = Spree::Product.find_by_name('Beetroot').variants.first - pp beetroot - pp beetroot.product - @importer.save_entries expect(@importer.products_created_count).to eq 0 @@ -290,15 +284,12 @@ describe ProductImporter do expect(@importer.updated_ids.count).to eq 2 beetroot = Spree::Product.find_by_name('Beetroot').variants.first - pp beetroot - pp beetroot.product - beetroot.price.should == 3.50 - beetroot.on_demand.should_not == true + expect(beetroot.price).to eq 3.50 + expect(beetroot.on_demand).to_not eq true tomato = Spree::Product.find_by_name('Tomato').variants.first - pp tomato - tomato.price.should == 5.50 - tomato.on_demand.should == true + expect(tomato.price).to eq 5.50 + expect(tomato.on_demand).to eq true end end @@ -313,7 +304,7 @@ describe ProductImporter do File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') settings = {enterprise2.id.to_s => {'import_into' => 'inventories'}} - @importer = ProductImporter.new(file, admin, {start: 1, end: 100, settings: settings}) + @importer = ProductImport::ProductImporter.new(file, admin, start: 1, end: 100, settings: settings) end after { File.delete('/tmp/test-m.csv') } @@ -339,14 +330,14 @@ describe ProductImporter do sprouts_override = VariantOverride.where(variant_id: product3.variants.first.id, hub_id: enterprise2.id).first cabbage_override = VariantOverride.where(variant_id: product4.variants.first.id, hub_id: enterprise2.id).first - Float(beans_override.price).should == 3.20 - beans_override.count_on_hand.should == 5 + expect(Float(beans_override.price)).to eq 3.20 + expect(beans_override.count_on_hand).to eq 5 - Float(sprouts_override.price).should == 6.50 - sprouts_override.count_on_hand.should == 6 + expect(Float(sprouts_override.price)).to eq 6.50 + expect(sprouts_override.count_on_hand).to eq 6 - Float(cabbage_override.price).should == 1.50 - cabbage_override.count_on_hand.should == 2001 + expect(Float(cabbage_override.price)).to eq 1.50 + expect(cabbage_override.count_on_hand).to eq 2001 end end @@ -361,7 +352,7 @@ describe ProductImporter do File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') settings = {enterprise.id.to_s => {'import_into' => 'product_list'}, enterprise2.id.to_s => {'import_into' => 'inventories'}} - @importer = ProductImporter.new(file, admin, {start: 1, end: 100, settings: settings}) + @importer = ProductImport::ProductImporter.new(file, admin, start: 1, end: 100, settings: settings) end after { File.delete('/tmp/test-m.csv') } @@ -387,14 +378,14 @@ describe ProductImporter do sprouts_override = VariantOverride.where(variant_id: product3.variants.first.id, hub_id: enterprise2.id).first garbanzos = Spree::Product.where(name: "Garbanzos").first - Float(beans_override.price).should == 3.20 - beans_override.count_on_hand.should == 5 + expect(Float(beans_override.price)).to eq 3.20 + expect( beans_override.count_on_hand).to eq 5 - Float(sprouts_override.price).should == 6.50 - sprouts_override.count_on_hand.should == 6 + expect(Float(sprouts_override.price)).to eq 6.50 + expect(sprouts_override.count_on_hand).to eq 6 - Float(garbanzos.price).should == 1.50 - garbanzos.count_on_hand.should == 2001 + expect(Float(garbanzos.price)).to eq 1.50 + expect(garbanzos.count_on_hand).to eq 2001 end end @@ -410,7 +401,7 @@ describe ProductImporter do File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') settings = {enterprise.id.to_s => {'import_into' => 'product_list'}, enterprise2.id.to_s => {'import_into' => 'product_list'}} - @importer = ProductImporter.new(file, user, {start: 1, end: 100, settings: settings}) + @importer = ProductImport::ProductImporter.new(file, user, start: 1, end: 100, settings: settings) @importer.validate_entries entries = JSON.parse(@importer.entries_json) @@ -425,8 +416,8 @@ describe ProductImporter do expect(@importer.updated_ids).to be_a(Array) expect(@importer.updated_ids.count).to eq 1 - Spree::Product.find_by_name('My Carrots').should be_a Spree::Product - Spree::Product.find_by_name('Your Potatoes').should == nil + expect(Spree::Product.find_by_name('My Carrots')).to be_a Spree::Product + expect(Spree::Product.find_by_name('Your Potatoes')).to eq nil end it "allows creating inventories for producers that a user's hub has permission for" do @@ -437,7 +428,7 @@ describe ProductImporter do File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') settings = {enterprise2.id.to_s => {'import_into' => 'inventories'}} - @importer = ProductImporter.new(file, user2, {start: 1, end: 100, settings: settings}) + @importer = ProductImport::ProductImporter.new(file, user2, start: 1, end: 100, settings: settings) @importer.validate_entries entries = JSON.parse(@importer.entries_json) @@ -453,7 +444,7 @@ describe ProductImporter do expect(@importer.updated_ids.count).to eq 1 beans = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first - beans.count_on_hand.should == 777 + expect(beans.count_on_hand).to eq 777 end it "does not allow creating inventories for producers that a user's hubs don't have permission for" do @@ -465,7 +456,7 @@ describe ProductImporter do File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') settings = {enterprise.id.to_s => {'import_into' => 'inventories'}} - @importer = ProductImporter.new(file, user2, {start: 1, end: 100, settings: settings}) + @importer = ProductImport::ProductImporter.new(file, user2, start: 1, end: 100, settings: settings) @importer.validate_entries entries = JSON.parse(@importer.entries_json) @@ -494,7 +485,7 @@ describe ProductImporter do File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') settings = {enterprise.id.to_s => {'import_into' => 'product_list', 'reset_all_absent' => true}} - @importer = ProductImporter.new(file, admin, {start: 1, end: 100, settings: settings}) + @importer = ProductImport::ProductImporter.new(file, admin, start: 1, end: 100, settings: settings) @importer.validate_entries entries = JSON.parse(@importer.entries_json) @@ -515,11 +506,11 @@ describe ProductImporter do expect(@importer.products_reset_count).to eq 2 - Spree::Product.find_by_name('Carrots').on_hand.should == 5 # Present in file, added - Spree::Product.find_by_name('Beans').on_hand.should == 6 # Present in file, updated - Spree::Product.find_by_name('Sprouts').on_hand.should == 0 # In enterprise, not in file - Spree::Product.find_by_name('Cabbage').on_hand.should == 0 # In enterprise, not in file - Spree::Product.find_by_name('Lettuce').on_hand.should == 100 # In different enterprise; unchanged + expect(Spree::Product.find_by_name('Carrots').on_hand).to eq 5 # Present in file, added + expect(Spree::Product.find_by_name('Beans').on_hand).to eq 6 # Present in file, updated + expect(Spree::Product.find_by_name('Sprouts').on_hand).to eq 0 # In enterprise, not in file + expect(Spree::Product.find_by_name('Cabbage').on_hand).to eq 0 # In enterprise, not in file + expect(Spree::Product.find_by_name('Lettuce').on_hand).to eq 100 # In different enterprise; unchanged end it "can reset all inventory items for an enterprise that are not present in the uploaded file to zero stock" do @@ -531,7 +522,7 @@ describe ProductImporter do File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') settings = {enterprise2.id.to_s => {'import_into' => 'inventories', 'reset_all_absent' => true}} - @importer = ProductImporter.new(file, admin, {start: 1, end: 100, settings: settings}) + @importer = ProductImport::ProductImporter.new(file, admin, start: 1, end: 100, settings: settings) @importer.validate_entries entries = JSON.parse(@importer.entries_json) @@ -548,17 +539,17 @@ describe ProductImporter do @importer.reset_absent(@importer.updated_ids) - #expect(@importer.products_reset_count).to eq 1 + # expect(@importer.products_reset_count).to eq 1 beans = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first sprouts = VariantOverride.where(variant_id: product3.variants.first.id, hub_id: enterprise2.id).first cabbage = VariantOverride.where(variant_id: product4.variants.first.id, hub_id: enterprise2.id).first lettuce = VariantOverride.where(variant_id: product5.variants.first.id, hub_id: enterprise.id).first - beans.count_on_hand.should == 6 # Present in file, created - sprouts.count_on_hand.should == 7 # Present in file, created - cabbage.count_on_hand.should == 0 # In enterprise, not in file (reset) - lettuce.count_on_hand.should == 96 # In different enterprise; unchanged + expect(beans.count_on_hand).to eq 6 # Present in file, created + expect(sprouts.count_on_hand).to eq 7 # Present in file, created + expect(cabbage.count_on_hand).to eq 0 # In enterprise, not in file (reset) + expect(lettuce.count_on_hand).to eq 96 # In different enterprise; unchanged end it "can overwrite fields with selected defaults when importing to product list" do @@ -574,29 +565,29 @@ describe ProductImporter do 'import_into' => 'product_list', 'defaults' => { 'on_hand' => { - 'active' => true, - 'mode' => 'overwrite_all', - 'value' => '9000' + 'active' => true, + 'mode' => 'overwrite_all', + 'value' => '9000' }, 'tax_category_id' => { - 'active' => true, - 'mode' => 'overwrite_empty', - 'value' => tax_category2.id + 'active' => true, + 'mode' => 'overwrite_empty', + 'value' => tax_category2.id }, 'shipping_category_id' => { - 'active' => true, - 'mode' => 'overwrite_all', - 'value' => shipping_category.id + 'active' => true, + 'mode' => 'overwrite_all', + 'value' => shipping_category.id }, 'available_on' => { - 'active' => true, - 'mode' => 'overwrite_all', - 'value' => '2020-01-01' + 'active' => true, + 'mode' => 'overwrite_all', + 'value' => '2020-01-01' } } }} - @importer = ProductImporter.new(file, admin, {start: 1, end: 100, settings: settings}) + @importer = ProductImport::ProductImporter.new(file, admin, start: 1, end: 100, settings: settings) @importer.validate_entries entries = JSON.parse(@importer.entries_json) @@ -612,16 +603,16 @@ describe ProductImporter do expect(@importer.updated_ids.count).to eq 2 carrots = Spree::Product.find_by_name('Carrots') - carrots.on_hand.should == 9000 - carrots.tax_category_id.should == tax_category.id - carrots.shipping_category_id.should == shipping_category.id - carrots.available_on.should be_within(1.day).of(Time.zone.local(2020, 1, 1)) + expect(carrots.on_hand).to eq 9000 + expect(carrots.tax_category_id).to eq tax_category.id + expect(carrots.shipping_category_id).to eq shipping_category.id + expect(carrots.available_on).to be_within(1.day).of(Time.zone.local(2020, 1, 1)) potatoes = Spree::Product.find_by_name('Potatoes') - potatoes.on_hand.should == 9000 - potatoes.tax_category_id.should == tax_category2.id - potatoes.shipping_category_id.should == shipping_category.id - potatoes.available_on.should be_within(1.day).of(Time.zone.local(2020, 1, 1)) + expect(potatoes.on_hand).to eq 9000 + expect(potatoes.tax_category_id).to eq tax_category2.id + expect(potatoes.shipping_category_id).to eq shipping_category.id + expect(potatoes.available_on).to be_within(1.day).of(Time.zone.local(2020, 1, 1)) end it "can overwrite fields with selected defaults when importing to inventory" do @@ -645,7 +636,7 @@ describe ProductImporter do } }} - @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'inventories', settings: import_settings}) + @importer = ProductImport::ProductImporter.new(file, admin, start: 1, end: 100, import_into: 'inventories', settings: import_settings) @importer.validate_entries entries = JSON.parse(@importer.entries_json) @@ -666,9 +657,9 @@ describe ProductImporter do sprouts_override = VariantOverride.where(variant_id: product3.variants.first.id, hub_id: enterprise2.id).first cabbage_override = VariantOverride.where(variant_id: product4.variants.first.id, hub_id: enterprise2.id).first - beans_override.count_on_hand.should == 9000 - sprouts_override.count_on_hand.should == 7 - cabbage_override.count_on_hand.should == 9000 + expect(beans_override.count_on_hand).to eq 9000 + expect(sprouts_override.count_on_hand).to eq 7 + expect(cabbage_override.count_on_hand).to eq 9000 end end end @@ -677,15 +668,15 @@ private def filter(type, entries) valid_count = 0 - entries.each do |line_number, entry| + entries.each do |_line_number, entry| validates_as = entry['validates_as'] - valid_count += 1 if type == 'valid' and (validates_as != '') - valid_count += 1 if type == 'invalid' and (validates_as == '') - valid_count += 1 if type == 'create_product' and (validates_as == 'new_product' or validates_as == 'new_variant') - valid_count += 1 if type == 'update_product' and validates_as == 'existing_variant' - valid_count += 1 if type == 'create_inventory' and validates_as == 'new_inventory_item' - valid_count += 1 if type == 'update_inventory' and validates_as == 'existing_inventory_item' + valid_count += 1 if type == 'valid' && (validates_as != '') + valid_count += 1 if type == 'invalid' && (validates_as == '') + valid_count += 1 if type == 'create_product' && (validates_as == 'new_product' || validates_as == 'new_variant') + valid_count += 1 if type == 'update_product' && validates_as == 'existing_variant' + valid_count += 1 if type == 'create_inventory' && validates_as == 'new_inventory_item' + valid_count += 1 if type == 'update_inventory' && validates_as == 'existing_inventory_item' end valid_count -end \ No newline at end of file +end From 7da97be7d22231d1336b8b777fcc09eefedc578c Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Mon, 15 Jan 2018 19:04:56 +0000 Subject: [PATCH 123/206] Create separate angular module --- app/assets/javascripts/admin/all.js | 1 + .../product_import/controllers/dropdown_panels.js.coffee | 2 +- .../product_import/controllers/import_feedback.js.coffee | 2 +- .../controllers/import_form_controller.js.coffee | 2 +- .../product_import/controllers/import_options_form.js.coffee | 2 +- .../admin/product_import/filters/filter_entries.js.coffee | 4 ++-- .../javascripts/admin/product_import/product_import.js.coffee | 3 +++ .../product_import/services/product_import_service.js.coffee | 2 +- app/views/admin/product_import/_upload_form.html.haml | 2 +- app/views/admin/product_import/import.html.haml | 2 +- app/views/admin/product_import/save.html.haml | 2 +- 11 files changed, 14 insertions(+), 10 deletions(-) create mode 100644 app/assets/javascripts/admin/product_import/product_import.js.coffee diff --git a/app/assets/javascripts/admin/all.js b/app/assets/javascripts/admin/all.js index 688494f870..5cb8af0ebe 100644 --- a/app/assets/javascripts/admin/all.js +++ b/app/assets/javascripts/admin/all.js @@ -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 diff --git a/app/assets/javascripts/admin/product_import/controllers/dropdown_panels.js.coffee b/app/assets/javascripts/admin/product_import/controllers/dropdown_panels.js.coffee index 1403611168..91b0c23a53 100644 --- a/app/assets/javascripts/admin/product_import/controllers/dropdown_panels.js.coffee +++ b/app/assets/javascripts/admin/product_import/controllers/dropdown_panels.js.coffee @@ -1,4 +1,4 @@ -angular.module("ofn.admin").controller "DropdownPanelsCtrl", ($scope) -> +angular.module("admin.productImport").controller "DropdownPanelsCtrl", ($scope) -> $scope.active = false $scope.togglePanel = -> diff --git a/app/assets/javascripts/admin/product_import/controllers/import_feedback.js.coffee b/app/assets/javascripts/admin/product_import/controllers/import_feedback.js.coffee index ea23c59d9f..f914b0bb57 100644 --- a/app/assets/javascripts/admin/product_import/controllers/import_feedback.js.coffee +++ b/app/assets/javascripts/admin/product_import/controllers/import_feedback.js.coffee @@ -1,4 +1,4 @@ -angular.module("ofn.admin").controller "ImportFeedbackCtrl", ($scope) -> +angular.module("admin.productImport").controller "ImportFeedbackCtrl", ($scope) -> $scope.count = (items) -> total = 0 diff --git a/app/assets/javascripts/admin/product_import/controllers/import_form_controller.js.coffee b/app/assets/javascripts/admin/product_import/controllers/import_form_controller.js.coffee index 280183376b..b3d3e2d824 100644 --- a/app/assets/javascripts/admin/product_import/controllers/import_form_controller.js.coffee +++ b/app/assets/javascripts/admin/product_import/controllers/import_form_controller.js.coffee @@ -1,4 +1,4 @@ -angular.module("ofn.admin").controller "ImportFormCtrl", ($scope, $http, $filter, ProductImportService, $timeout) -> +angular.module("admin.productImport").controller "ImportFormCtrl", ($scope, $http, $filter, ProductImportService, $timeout) -> $scope.entries = {} $scope.update_counts = {} diff --git a/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee b/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee index 66178b0183..25a8cc7470 100644 --- a/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee +++ b/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee @@ -1,4 +1,4 @@ -angular.module("ofn.admin").controller "ImportOptionsFormCtrl", ($scope, $rootScope, ProductImportService) -> +angular.module("admin.productImport").controller "ImportOptionsFormCtrl", ($scope, $rootScope, ProductImportService) -> $scope.initForm = () -> $scope.settings = {} if $scope.settings == undefined diff --git a/app/assets/javascripts/admin/product_import/filters/filter_entries.js.coffee b/app/assets/javascripts/admin/product_import/filters/filter_entries.js.coffee index 7513384f9e..4a1007b3a9 100644 --- a/app/assets/javascripts/admin/product_import/filters/filter_entries.js.coffee +++ b/app/assets/javascripts/admin/product_import/filters/filter_entries.js.coffee @@ -1,4 +1,4 @@ -angular.module("ofn.admin").filter 'entriesFilterValid', -> +angular.module("admin.productImport").filter 'entriesFilterValid', -> (entries, type) -> if type == 'all' return entries @@ -18,7 +18,7 @@ angular.module("ofn.admin").filter 'entriesFilterValid', -> filtered -angular.module("ofn.admin").filter 'entriesFilterSupplier', -> +angular.module("admin.productImport").filter 'entriesFilterSupplier', -> (entries, supplier) -> if supplier == 'all' return entries diff --git a/app/assets/javascripts/admin/product_import/product_import.js.coffee b/app/assets/javascripts/admin/product_import/product_import.js.coffee new file mode 100644 index 0000000000..5eb83204e1 --- /dev/null +++ b/app/assets/javascripts/admin/product_import/product_import.js.coffee @@ -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, */*" diff --git a/app/assets/javascripts/admin/product_import/services/product_import_service.js.coffee b/app/assets/javascripts/admin/product_import/services/product_import_service.js.coffee index 51b57c8735..af0f464df1 100644 --- a/app/assets/javascripts/admin/product_import/services/product_import_service.js.coffee +++ b/app/assets/javascripts/admin/product_import/services/product_import_service.js.coffee @@ -1,4 +1,4 @@ -angular.module("ofn.admin").factory "ProductImportService", ($rootScope) -> +angular.module("admin.productImport").factory "ProductImportService", ($rootScope) -> new class ProductImportService suppliers: {} resetTotal: 0 diff --git a/app/views/admin/product_import/_upload_form.html.haml b/app/views/admin/product_import/_upload_form.html.haml index 9c18cf47c6..008f027139 100644 --- a/app/views/admin/product_import/_upload_form.html.haml +++ b/app/views/admin/product_import/_upload_form.html.haml @@ -1,4 +1,4 @@ -%div{ng: {app: 'ofn.admin'}} +%div{ng: {app: 'admin.productImport'}} %h5= t('admin.product_import.index.select_file') %br diff --git a/app/views/admin/product_import/import.html.haml b/app/views/admin/product_import/import.html.haml index dddcd69c4f..bb8c85b8e4 100644 --- a/app/views/admin/product_import/import.html.haml +++ b/app/views/admin/product_import/import.html.haml @@ -3,7 +3,7 @@ = render partial: 'spree/admin/shared/product_sub_menu' -.import-wrapper{ng: {app: 'ofn.admin', controller: 'ImportFormCtrl', init: "supplier_product_counts = #{@importer.supplier_products.to_json}"}} +.import-wrapper{ng: {app: 'admin.productImport', controller: 'ImportFormCtrl', init: "supplier_product_counts = #{@importer.supplier_products.to_json}"}} - if @importer.item_count == 0 #and @importer.invalid_count %h5 diff --git a/app/views/admin/product_import/save.html.haml b/app/views/admin/product_import/save.html.haml index ce5f6f57b9..23a1d13d99 100644 --- a/app/views/admin/product_import/save.html.haml +++ b/app/views/admin/product_import/save.html.haml @@ -6,7 +6,7 @@ %h5= t('admin.product_import.save.final_results') %br -%div.post-save-results{ng: {app: 'ofn.admin'}} +%div.post-save-results{ng: {app: 'admin.productImport'}} - if @importer.products_created_count > 0 %p From b42e3eb2c976377cf3b9ea5fc97d22ef590e9c0f Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Sat, 3 Feb 2018 19:29:31 +0000 Subject: [PATCH 124/206] Product Import Guide WIP --- .../controllers/import_options_form.js.coffee | 7 +- .../stylesheets/admin/product_import.css.scss | 35 +++++++ .../admin/product_import_controller.rb | 8 +- app/models/spree/ability_decorator.rb | 2 +- .../admin/product_import/guide.html.haml | 69 ++++++++++++++ .../product_import/guide/_columns.html.haml | 93 +++++++++++++++++++ .../product_import/guide/_units.html.haml | 54 +++++++++++ .../product_import/guide/_variants.html.haml | 54 +++++++++++ .../admin/product_import/index.html.haml | 6 ++ config/routes.rb | 1 + 10 files changed, 325 insertions(+), 4 deletions(-) create mode 100644 app/views/admin/product_import/guide.html.haml create mode 100644 app/views/admin/product_import/guide/_columns.html.haml create mode 100644 app/views/admin/product_import/guide/_units.html.haml create mode 100644 app/views/admin/product_import/guide/_variants.html.haml diff --git a/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee b/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee index 25a8cc7470..2198b830dc 100644 --- a/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee +++ b/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee @@ -26,12 +26,15 @@ angular.module("admin.productImport").controller "ImportOptionsFormCtrl", ($scop , true $scope.toggleResetAbsent = (id) -> - resetAbsent = $scope.settings[id]['reset_all_absent'] + checked = $scope.settings[id]['reset_all_absent'] confirmed = confirm t('js.product_import.confirmation') if resetAbsent - if confirmed or !resetAbsent + if confirmed or !checked ProductImportService.updateResetAbsent($scope.supplierId, $scope.reset_counts[$scope.supplierId], resetAbsent) + if !confirmed and checked + $scope.settings[id]['reset_all_absent'] = false + $scope.resetTotal = ProductImportService.resetTotal $rootScope.$watch 'resetTotal', (newValue) -> diff --git a/app/assets/stylesheets/admin/product_import.css.scss b/app/assets/stylesheets/admin/product_import.css.scss index ebd58fc468..eecd46b2b1 100644 --- a/app/assets/stylesheets/admin/product_import.css.scss +++ b/app/assets/stylesheets/admin/product_import.css.scss @@ -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 { diff --git a/app/controllers/admin/product_import_controller.rb b/app/controllers/admin/product_import_controller.rb index 3b5e9638e2..b8c760a9fc 100644 --- a/app/controllers/admin/product_import_controller.rb +++ b/app/controllers/admin/product_import_controller.rb @@ -2,7 +2,13 @@ require 'roo' module Admin class ProductImportController < Spree::Admin::BaseController - before_filter :validate_upload_presence, except: %i[index validate_data] + before_filter :validate_upload_presence, except: %i[index guide validate_data] + + def guide + @product_categories = Spree::Taxon.order('name ASC').pluck(:name) + @tax_categories = Spree::TaxCategory.order('name ASC').pluck(:name) + @shipping_categories = Spree::ShippingCategory.order('name ASC').pluck(:name) + end def import # Save uploaded file to tmp directory diff --git a/app/models/spree/ability_decorator.rb b/app/models/spree/ability_decorator.rb index ed8d15fb47..d9a99bce7f 100644 --- a/app/models/spree/ability_decorator.rb +++ b/app/models/spree/ability_decorator.rb @@ -177,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, :save_data, :validate_data, :reset_absent_products], ProductImport::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 diff --git a/app/views/admin/product_import/guide.html.haml b/app/views/admin/product_import/guide.html.haml new file mode 100644 index 0000000000..aab0350606 --- /dev/null +++ b/app/views/admin/product_import/guide.html.haml @@ -0,0 +1,69 @@ +- content_for :page_title do + Product Import Guide + +- content_for :page_actions do + %div.toolbar{ 'data-hook' => "toolbar" } + %ul.actions.header-action-links.inline-menu + %li + = button_link_to 'Back to Import', main_app.admin_product_import_path + += render partial: 'spree/admin/shared/product_sub_menu' + +.product-import-introduction + + %h5 Spreadsheet Columns + + = render 'admin/product_import/guide/columns' + + %h5 Weight, volume, or single items + + %p + When creating a product you can specify a number of options. If the product is measured + by weight, unit_value should be set to a number and unit_type should be set to either + g for grams, Kg for kilograms, or T for tonnes. + + %p + For items such as liquids, the unit_type can be ml for millilitres, L for litres or + Kl for kilolitres. + + %p + For other items that are sold in different units such as "a bunch of carrots", you can + enter 1 for units, leave unit_type blank, and enter a name in the variant_unit_name + column such as "loaf" or "bunch". + + %h6 Example spreadsheet data: + + = render 'admin/product_import/guide/units' + + + %h5 Product Variants + + %p + Variants are distinguished by the units and display_name fields and grouped by product name. + The example below shows a salad that comes in 500g and 750g variants, and a cake that comes in + multiple flavours. + + %h6 Example spreadsheet data: + + = render 'admin/product_import/guide/variants' + + + %h5 Category Values + + %p + Listed below are the available Product categories, as well as Tax and Shipping categories. + + %h6 Product Categories + + - @product_categories.each do |pc| + %span.category= pc + + %h6 Tax Categories + + - @tax_categories.each do |tc| + %span.category= tc + + %h6 Shipping Categories + + - @shipping_categories.each do |sc| + %span.category= sc diff --git a/app/views/admin/product_import/guide/_columns.html.haml b/app/views/admin/product_import/guide/_columns.html.haml new file mode 100644 index 0000000000..d5aa454670 --- /dev/null +++ b/app/views/admin/product_import/guide/_columns.html.haml @@ -0,0 +1,93 @@ +%table.product-import-columns + %thead + %tr + %th Column Title + %th Required + %th Examples + %th Description + %th Notes + %tbody + %tr + %td + %strong name + %td Yes + %td Potatoes + %td The name of the product + %td + %tr + %td + %strong supplier + %td Yes + %td Pennylane Farm + %td The name of the product's supplier + %td + %tr + %td + %strong category + %td Yes + %td Vegetables + %td The product category + %td See below for a list of available categories + %tr + %td + %strong on_hand + %td Yes + %td 15 + %td The amount currently in stock + %td + %tr + %td + %strong price + %td Yes + %td 2.50 + %td The price of the product + %td + %tr + %td + %strong units + %td Yes + %td 750 + %td The weight or volume value. + %td + %tr + %td + %strong unit_type + %td Yes + %td g + %td The unit type, i.e. g for grams, Kg for Kilograms, ml for millilitres. Can be blank (see notes). + %td If unit_type is left blank, a variant_unit_name must be given. + %tr + %td + %strong variant_unit_name + %td Maybe + %td bunch + %td If the product is sold as an item such as "bunch", "loaf" or "case", that goes here. + %td Used for products that don't use a unit_type for weight or volume. + %tr + %td + %strong display_name + %td No + %td Orange + %td Used to name product variants, if they have a distinct name. + %td + %tr + %td + %strong on_demand + %td No + %td 1 + %td Flag the product as "made on demand". The product will not use stock levels. + %td 1 for active, 0 for disabled, or leave blank to ignore. + %tr + %td + %strong tax_category + %td No + %td (Various, see notes) + %td Sets the product tax category + %td See below for a list of available categories + %tr + %td + %strong shipping_category + %td No + %td (Various, see notes) + %td Sets the product shipping category + %td See below for a list of available categories diff --git a/app/views/admin/product_import/guide/_units.html.haml b/app/views/admin/product_import/guide/_units.html.haml new file mode 100644 index 0000000000..07161bdb1c --- /dev/null +++ b/app/views/admin/product_import/guide/_units.html.haml @@ -0,0 +1,54 @@ +%table + %thead + %tr + %th + %th name + %th category + %th supplier + %th on_hand + %th price + %th units + %th unit_type + %th variant_unit_name + %tbody + %tr + %td 1 + %td Salad Bag + %td Salads + %td Sue's Salads + %td 26 + %td 3.50 + %td 500 + %td g + %td + %tr + %td 2 + %td Fruit Juice + %td Drinks + %td Country Juices + %td 12 + %td 3.50 + %td 300 + %td ml + %td + %tr + %td 3 + %td Potatoes + %td Vegetables + %td Fernwell Farm + %td 67 + %td 4.20 + %td 1 + %td kg + %td + + %tr + %td 3 + %td Wholemeal Bread + %td Baked goods + %td Tim's Bakery + %td 66 + %td 3.00 + %td 1 + %td + %td loaf diff --git a/app/views/admin/product_import/guide/_variants.html.haml b/app/views/admin/product_import/guide/_variants.html.haml new file mode 100644 index 0000000000..dfd95879c1 --- /dev/null +++ b/app/views/admin/product_import/guide/_variants.html.haml @@ -0,0 +1,54 @@ +%table + %thead + %tr + %th + %th name + %th category + %th supplier + %th on_hand + %th price + %th units + %th unit_type + %th display_name + %tbody + %tr + %td 1 + %td Salad Bag + %td Salads + %td Sue's Salads + %td 26 + %td 3.50 + %td 500 + %td g + %td + %tr + %td 2 + %td Salad Bag + %td Salads + %td Sue's Salads + %td 44 + %td 5.50 + %td 750 + %td g + %td + %tr + %td 3 + %td Cake + %td Baked goods + %td Tim's Cakes + %td 10 + %td 4 + %td 500 + %td g + %td Banana and Walnut + %tr + %td 4 + %td Cake + %td Baked goods + %td Tim's Cakes + %td 18 + %td 4 + %td 500 + %td g + %td Carrot + diff --git a/app/views/admin/product_import/index.html.haml b/app/views/admin/product_import/index.html.haml index fd17684ef0..f2e8e5208b 100644 --- a/app/views/admin/product_import/index.html.haml +++ b/app/views/admin/product_import/index.html.haml @@ -1,6 +1,12 @@ - content_for :page_title do #{t('admin.product_import.title')} +- content_for :page_actions do + %div.toolbar{ 'data-hook' => "toolbar" } + %ul.actions.header-action-links.inline-menu + %li + = button_link_to 'View Guide', main_app.admin_product_import_guide_path + = render partial: 'spree/admin/shared/product_sub_menu' = render 'upload_form' diff --git a/config/routes.rb b/config/routes.rb index fe62513e2a..8a00086f4f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -134,6 +134,7 @@ Openfoodnetwork::Application.routes.draw do get '/inventory', to: 'variant_overrides#index' get '/product_import', to: 'product_import#index' + get '/product_import/guide', to: 'product_import#guide', as: 'product_import_guide' post '/product_import', to: 'product_import#import' post '/product_import/validate_data', to: 'product_import#validate_data', as: 'product_import_process_async' post '/product_import/save_data', to: 'product_import#save_data', as: 'product_import_save_async' From 58e9f0266a9ba2ab83661305ef5c4b465814e323 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Fri, 16 Feb 2018 11:43:53 +0000 Subject: [PATCH 125/206] User guide check --- spec/features/admin/product_import_spec.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/spec/features/admin/product_import_spec.rb b/spec/features/admin/product_import_spec.rb index e85db8753f..d50f2dd57c 100644 --- a/spec/features/admin/product_import_spec.rb +++ b/spec/features/admin/product_import_spec.rb @@ -343,6 +343,14 @@ feature "Product Import", js: true do end end + describe "viewing the user guide" do + it "displays the guide" do + quick_login_as_admin + visit main_app.admin_product_import_guide_path + expect(page).to have_content "See below for a list of available categories" + end + end + private def import_data From c5b7cec19f937a3feabde5c68bfe16bcd10ff973 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Fri, 23 Feb 2018 14:05:08 +0000 Subject: [PATCH 126/206] Change checkbox confirmation behaviour --- .../controllers/import_options_form.js.coffee | 7 +++---- app/controllers/admin/product_import_controller.rb | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee b/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee index 2198b830dc..965692c86d 100644 --- a/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee +++ b/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee @@ -27,12 +27,11 @@ angular.module("admin.productImport").controller "ImportOptionsFormCtrl", ($scop $scope.toggleResetAbsent = (id) -> checked = $scope.settings[id]['reset_all_absent'] - confirmed = confirm t('js.product_import.confirmation') if resetAbsent + confirmed = confirm t('js.product_import.confirmation') if checked if confirmed or !checked - ProductImportService.updateResetAbsent($scope.supplierId, $scope.reset_counts[$scope.supplierId], resetAbsent) - - if !confirmed and checked + ProductImportService.updateResetAbsent($scope.supplierId, $scope.reset_counts[$scope.supplierId], checked) + else $scope.settings[id]['reset_all_absent'] = false $scope.resetTotal = ProductImportService.resetTotal diff --git a/app/controllers/admin/product_import_controller.rb b/app/controllers/admin/product_import_controller.rb index b8c760a9fc..4037bcd85b 100644 --- a/app/controllers/admin/product_import_controller.rb +++ b/app/controllers/admin/product_import_controller.rb @@ -5,7 +5,7 @@ module Admin before_filter :validate_upload_presence, except: %i[index guide validate_data] def guide - @product_categories = Spree::Taxon.order('name ASC').pluck(:name) + @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 From e65bdf35fad5e77b5a638475d46b7a71d6abe2ab Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Fri, 23 Feb 2018 14:19:43 +0000 Subject: [PATCH 127/206] Change wording on default import settings text --- config/locales/en.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index 242eddb396..94aebdef0b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -523,10 +523,10 @@ en: reset_absent?: Reset absent products? overwrite_all: Overwrite all overwrite_empty: Overwrite if empty - default_stock: Set default stock level - default_tax_cat: Set default tax category - default_shipping_cat: Set default shipping category - default_available_date: Set default available date + default_stock: Set stock level + default_tax_cat: Set tax category + default_shipping_cat: Set shipping category + default_available_date: Set available date validation_overview: Import validation overview entries_found: Entries found in imported file entries_with_errors: Items contain errors and will not be imported From 73f7bc3db02cc554462fc0ac91846bafb4f2ab3c Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Tue, 13 Mar 2018 22:08:35 +0000 Subject: [PATCH 128/206] Remove use of before_filter --- .../spree/admin/products_controller_decorator.rb | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/controllers/spree/admin/products_controller_decorator.rb b/app/controllers/spree/admin/products_controller_decorator.rb index 5e4c9b805a..1e38a65da6 100644 --- a/app/controllers/spree/admin/products_controller_decorator.rb +++ b/app/controllers/spree/admin/products_controller_decorator.rb @@ -5,7 +5,6 @@ Spree::Admin::ProductsController.class_eval do include OpenFoodNetwork::SpreeApiKeyLoader include OrderCyclesHelper include EnterprisesHelper - before_filter :latest_import, only: [:bulk_edit] before_filter :load_form_data, :only => [:bulk_edit, :new, :create, :edit, :update] before_filter :load_spree_api_key, :only => [:bulk_edit, :variant_overrides] before_filter :strip_new_properties, only: [:create, :update] @@ -25,6 +24,10 @@ Spree::Admin::ProductsController.class_eval do def product_distributions end + def bulk_edit + @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}) @@ -94,10 +97,6 @@ Spree::Admin::ProductsController.class_eval do private - def latest_import - @show_latest_import = params[:latest_import] || false - end - def load_form_data @producers = OpenFoodNetwork::Permissions.new(spree_current_user).managed_product_enterprises.is_primary_producer.by_name @taxons = Spree::Taxon.order(:name) From d9c720e89a38906da0cde47ebd7ba6dadf88dd11 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Tue, 13 Mar 2018 22:53:11 +0000 Subject: [PATCH 129/206] Tidy up import_date --- app/controllers/admin/variant_overrides_controller.rb | 7 ++++--- .../spree/admin/products_controller_decorator.rb | 11 ++++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/app/controllers/admin/variant_overrides_controller.rb b/app/controllers/admin/variant_overrides_controller.rb index 4f8b39f1f4..da2006ec2b 100644 --- a/app/controllers/admin/variant_overrides_controller.rb +++ b/app/controllers/admin/variant_overrides_controller.rb @@ -63,11 +63,12 @@ module Admin options = [{ id: '0', name: 'All' }] import_dates = VariantOverride. - select('variant_overrides.import_date'). + select(:import_date). where('variant_overrides.hub_id IN (?) - AND variant_overrides.import_date IS NOT NULL', editable_enterprises.collect(&:id)) + AND variant_overrides.import_date IS NOT NULL', editable_enterprises.collect(&:id)). + order('import_date DESC') - import_dates.uniq.collect(&:import_date).sort.reverse.map { |i| options.push(id: i.to_date, name: i.to_date.to_formatted_s(:long)) } + import_dates.collect(&:import_date).uniq.map { |i| options.push(id: i.to_date, name: i.to_date.to_formatted_s(:long)) } options end diff --git a/app/controllers/spree/admin/products_controller_decorator.rb b/app/controllers/spree/admin/products_controller_decorator.rb index 1e38a65da6..07b818d1ea 100644 --- a/app/controllers/spree/admin/products_controller_decorator.rb +++ b/app/controllers/spree/admin/products_controller_decorator.rb @@ -109,12 +109,13 @@ Spree::Admin::ProductsController.class_eval do import_dates = Spree::Variant. select('spree_variants.import_date'). joins(:product). - where('spree_products.supplier_id IN (?) - AND spree_variants.is_master = false - AND spree_variants.import_date IS NOT NULL - AND spree_variants.deleted_at IS NULL', editable_enterprises.collect(&:id)) + 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') - import_dates.uniq.collect(&:import_date).sort.reverse + import_dates.collect(&:import_date).uniq end def strip_new_properties From 6f6db9384f835f43e1410026c186f115cc3eccba Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Thu, 15 Mar 2018 20:39:44 +0000 Subject: [PATCH 130/206] Tidy up SCSS and coffeescript --- .../filters/import_date_filter.js.coffee | 6 ++--- .../stylesheets/admin/product_import.css.scss | 22 ------------------- 2 files changed, 3 insertions(+), 25 deletions(-) diff --git a/app/assets/javascripts/admin/variant_overrides/filters/import_date_filter.js.coffee b/app/assets/javascripts/admin/variant_overrides/filters/import_date_filter.js.coffee index 4392d6b3f6..b46b996128 100644 --- a/app/assets/javascripts/admin/variant_overrides/filters/import_date_filter.js.coffee +++ b/app/assets/javascripts/admin/variant_overrides/filters/import_date_filter.js.coffee @@ -4,9 +4,9 @@ angular.module("admin.variantOverrides").filter "importDate", ($filter, variantO return $filter('filter')(products, (product) -> return true if date == 0 or date == undefined or date == '0' or date == '' - for variant in product.variants - for vo in variantOverrides + angular.forEach product.variants (variant) -> + angular.forEach variantOverrides (vo) -> if vo.variant_id == variant.id and vo.import_date == date return true false - , true) \ No newline at end of file + , true) diff --git a/app/assets/stylesheets/admin/product_import.css.scss b/app/assets/stylesheets/admin/product_import.css.scss index eecd46b2b1..4210439b78 100644 --- a/app/assets/stylesheets/admin/product_import.css.scss +++ b/app/assets/stylesheets/admin/product_import.css.scss @@ -50,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; @@ -103,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; @@ -122,8 +119,6 @@ div.panel-section { white-space: nowrap; } tr.error { - //background-color: #ffe6e4; - //color: #ee4728; color: #c84C4c; } tr:hover td.invalid { @@ -154,9 +149,7 @@ div.panel-section { margin-bottom: 0.2em; } } - } - } br.panels.clearfix { @@ -167,12 +160,6 @@ table.import-settings { background-color: transparent !important; width: auto; - //select { - // width: 100%; - //} - tr { - - } tbody tr:hover td { background-color: #f3f3f3; } @@ -194,11 +181,9 @@ 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 { @@ -215,7 +200,6 @@ table.import-settings { border-color: transparent; color: white !important; } - } .panel-section.import-settings { @@ -238,8 +222,6 @@ table.import-settings { } } - - .post-save-results { p { font-size: 1.25em; @@ -296,10 +278,6 @@ div.import-wrapper { text-align: center; transition: all linear 0.25s; - button { - - } - button:disabled { background: #ccc !important; } From 91521dc2b0877a1d96ef3b192e462df65cd0e9ac Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Thu, 15 Mar 2018 21:39:15 +0000 Subject: [PATCH 131/206] Improve import_date queries --- app/controllers/admin/variant_overrides_controller.rb | 7 +++---- .../spree/admin/products_controller_decorator.rb | 11 ++++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/controllers/admin/variant_overrides_controller.rb b/app/controllers/admin/variant_overrides_controller.rb index da2006ec2b..4a49f53c7c 100644 --- a/app/controllers/admin/variant_overrides_controller.rb +++ b/app/controllers/admin/variant_overrides_controller.rb @@ -60,15 +60,14 @@ module Admin end def inventory_import_dates - options = [{ id: '0', name: 'All' }] - import_dates = VariantOverride. - select(:import_date). + 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') - import_dates.collect(&:import_date).uniq.map { |i| options.push(id: i.to_date, name: i.to_date.to_formatted_s(:long)) } + 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 diff --git a/app/controllers/spree/admin/products_controller_decorator.rb b/app/controllers/spree/admin/products_controller_decorator.rb index 07b818d1ea..7d58e94dfe 100644 --- a/app/controllers/spree/admin/products_controller_decorator.rb +++ b/app/controllers/spree/admin/products_controller_decorator.rb @@ -100,14 +100,12 @@ 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 = [{id: '0', name: ''}] - product_import_dates.map { |i| import_dates.push(id: i.to_date, name: i.to_date.to_formatted_s(:long)) } - @import_dates = import_dates.uniq.to_json + @import_dates = product_import_dates.uniq.to_json end def product_import_dates import_dates = Spree::Variant. - select('spree_variants.import_date'). + 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'). @@ -115,7 +113,10 @@ Spree::Admin::ProductsController.class_eval do where(spree_variants: {deleted_at: nil}). order('spree_variants.import_date DESC') - import_dates.collect(&:import_date).uniq + 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 From a503b5e2d0b1a703f1bc5527a45241d7bb7776e4 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Mon, 19 Mar 2018 15:47:44 +0000 Subject: [PATCH 132/206] Add not_master scope to Spree::Variant --- app/models/product_import/entry_processor.rb | 6 +++--- app/models/spree/variant_decorator.rb | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/models/product_import/entry_processor.rb b/app/models/product_import/entry_processor.rb index 9be32f7e98..509a21fd43 100644 --- a/app/models/product_import/entry_processor.rb +++ b/app/models/product_import/entry_processor.rb @@ -42,10 +42,10 @@ module ProductImport 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 (?) - AND spree_variants.is_master = false - AND spree_variants.deleted_at IS NULL', supplier_id). + where('spree_products.supplier_id IN (?)', supplier_id). count end diff --git a/app/models/spree/variant_decorator.rb b/app/models/spree/variant_decorator.rb index c0a90500cf..2947efc282 100644 --- a/app/models/spree/variant_decorator.rb +++ b/app/models/spree/variant_decorator.rb @@ -32,6 +32,8 @@ Spree::Variant.class_eval do 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). From a4a2bc438a6358c93ac17e7814eacd67247c4d71 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Tue, 8 May 2018 00:40:43 +0100 Subject: [PATCH 133/206] Import settings spec --- .../product_import/_options_form.html.haml | 17 +++-- spec/features/admin/product_import_spec.rb | 73 +++++++++++++++++++ 2 files changed, 83 insertions(+), 7 deletions(-) diff --git a/app/views/admin/product_import/_options_form.html.haml b/app/views/admin/product_import/_options_form.html.haml index 190967b664..5e4383de35 100644 --- a/app/views/admin/product_import/_options_form.html.haml +++ b/app/views/admin/product_import/_options_form.html.haml @@ -1,5 +1,5 @@ %table.import-settings{ng: {controller: 'ImportOptionsFormCtrl', init: "supplierId = #{supplier_id}; initForm()"}} - %tr + %tr.import-into %td.description Import Into: %td @@ -7,7 +7,7 @@ = select_tag "settings[#{supplier_id}][import_into]", options_for_select({"Product List" => :product_list, "Inventories" => :inventories}), {class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['import_into']", 'ng-change' => "updateImportInto()"} %td - %tr{ng: {show: 'import_into == "inventories"'}} + %tr.stock-level.inventory{ng: {show: 'import_into == "inventories"'}} %td.description = t('admin.product_import.import.default_stock') %td @@ -17,7 +17,7 @@ %td = number_field_tag "settings[#{supplier_id}][defaults][count_on_hand][value]", 0, 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['count_on_hand']['active']", 'ng-model' => "settings[#{supplier_id}]['defaults']['count_on_hand']['value']" - %tr{ng: {show: 'import_into == "product_list"'}} + %tr.stock-level.productlist{ng: {show: 'import_into == "product_list"'}} %td.description = t('admin.product_import.import.default_stock') %td @@ -26,7 +26,8 @@ = select_tag "settings[#{supplier_id}][defaults][on_hand][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['on_hand']['mode']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['on_hand']['active']"} %td = number_field_tag "settings[#{supplier_id}][defaults][on_hand][value]", 0, 'ng-model' => "settings[#{supplier_id}]['defaults']['on_hand']['value']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['on_hand']['active']" - %tr{ng: {show: 'import_into == "product_list"'}} + + %tr.tax-category{ng: {show: 'import_into == "product_list"'}} %td.description = t('admin.product_import.import.default_tax_cat') %td @@ -35,7 +36,8 @@ = select_tag "settings[#{supplier_id}][defaults][tax_category_id][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['tax_category_id']['mode']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['tax_category_id']['active']"} %td = select_tag "settings[#{supplier_id}][defaults][tax_category_id][value]", options_for_select(@tax_categories.map {|tc| [tc.name, tc.id]}), {prompt: 'None', class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['tax_category_id']['value']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['tax_category_id']['active']"} - %tr{ng: {show: 'import_into == "product_list"'}} + + %tr.shipping-category{ng: {show: 'import_into == "product_list"'}} %td.description = t('admin.product_import.import.default_shipping_cat') %td @@ -44,7 +46,8 @@ = select_tag "settings[#{supplier_id}][defaults][shipping_category_id][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['shipping_category_id']['mode']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['shipping_category_id']['active']"} %td = select_tag "settings[#{supplier_id}][defaults][shipping_category_id][value]", options_for_select(@shipping_categories.map {|sc| [sc.name, sc.id]}), {prompt: 'None', class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['shipping_category_id']['value']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['shipping_category_id']['active']"} - %tr{ng: {show: 'import_into == "product_list"'}} + + %tr.available-date{ng: {show: 'import_into == "product_list"'}} %td.description = t('admin.product_import.import.default_available_date') %td @@ -54,7 +57,7 @@ %td = text_field_tag "settings[#{supplier_id}][defaults][available_on][value]", nil, {class: 'datepicker', placeholder: 'Today', 'ng-model' => "settings[#{supplier_id}]['defaults']['available_on']['value']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['available_on']['active']"} - %tr + %tr.reset-absent %td.description = t('admin.product_import.import.reset_absent?') %td diff --git a/spec/features/admin/product_import_spec.rb b/spec/features/admin/product_import_spec.rb index d50f2dd57c..efca92374b 100644 --- a/spec/features/admin/product_import_spec.rb +++ b/spec/features/admin/product_import_spec.rb @@ -262,6 +262,79 @@ feature "Product Import", js: true do expect(page).to have_content 'Cabbage' end end + + it "can override import fields via the import settings tab" do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "category", "on_hand", "price", "units", "unit_type", "tax_category", "shipping_category"] + csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "g", tax_category.name, shipping_category.name] + csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "6.50", "1", "kg", "Unknown Tax Category", shipping_category.name] + csv << ["Peas", "User Enterprise", "Vegetables", "7", "2.50", "1", "kg", tax_category2.name, "Unknown Shipping Category"] + csv << ["Pumpkin", "User Enterprise", "Vegetables", "3", "3.50", "1", "kg", tax_category.name, ""] + csv << ["Spinach", "User Enterprise", "Vegetables", "7", "3.60", "1", "kg", "", shipping_category.name] + end + File.write('/tmp/test.csv', csv_data) + + visit main_app.admin_product_import_path + + expect(page).to have_content "Select a spreadsheet to upload" + attach_file 'file', '/tmp/test.csv' + click_button 'Upload' + + within 'div.import-settings' do + find('div.panel-header').click + + within 'tr.stock-level.productlist' do + find('input[type="checkbox"]').click + select 'Overwrite all', from: "settings_#{enterprise.id}_defaults_on_hand_mode", visible: false + fill_in "settings_#{enterprise.id}_defaults_on_hand_value", with: 9000 + end + + within 'tr.tax-category' do + find('input[type="checkbox"]').click + select 'Overwrite if empty', from: "settings_#{enterprise.id}_defaults_tax_category_id_mode", visible: false + select tax_category2.name, from: "settings_#{enterprise.id}_defaults_tax_category_id_value", visible: false + end + + within 'tr.shipping-category' do + find('input[type="checkbox"]').click + select 'Overwrite all', from: "settings_#{enterprise.id}_defaults_shipping_category_id_mode", visible: false + select shipping_category.name, from: "settings_#{enterprise.id}_defaults_shipping_category_id_value", visible: false + end + end + + expect(page).to have_selector 'a.button.proceed', visible: true + click_link 'Proceed' + + import_data + + expect(page).to have_selector '.item-count', text: "5" + expect(page).to have_selector '.invalid-count', text: "2" + expect(page).to have_selector '.create-count', text: "3" + expect(page).to_not have_selector '.update-count' + + expect(page).to have_selector 'a.button.proceed', visible: true + click_link 'Proceed' + + save_data + + expect(page).to have_selector '.created-count', text: '3' + expect(page).to_not have_selector '.updated-count' + + carrots = Spree::Product.find_by_name('Carrots') + expect(carrots.tax_category).to eq tax_category + expect(carrots.shipping_category).to eq shipping_category + expect(carrots.count_on_hand).to eq 9000 + + pumpkin = Spree::Product.find_by_name('Pumpkin') + expect(pumpkin.tax_category).to eq tax_category + expect(pumpkin.shipping_category).to eq shipping_category + expect(pumpkin.count_on_hand).to eq 9000 + + spinach = Spree::Product.find_by_name('Spinach') + expect(spinach.tax_category).to eq tax_category2 + expect(spinach.shipping_category).to eq shipping_category + expect(spinach.count_on_hand).to eq 9000 + end end describe "when dealing with uploaded files" do From b440d0520586012e3a887785bc88b70635a7cb01 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Sat, 26 May 2018 19:30:46 +0100 Subject: [PATCH 134/206] Tidy up unnecessary PI code --- config/routes.rb | 1 - spec/features/admin/product_import_spec.rb | 8 -------- 2 files changed, 9 deletions(-) diff --git a/config/routes.rb b/config/routes.rb index 8a00086f4f..8e4b0e1495 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -139,7 +139,6 @@ Openfoodnetwork::Application.routes.draw do post '/product_import/validate_data', to: 'product_import#validate_data', as: 'product_import_process_async' post '/product_import/save_data', to: 'product_import#save_data', as: 'product_import_save_async' post '/product_import/reset_absent', to: 'product_import#reset_absent_products', as: 'product_import_reset_async' - #post '/product_import/save', to: 'product_import#save', as: 'product_import_save' resources :variant_overrides do post :bulk_update, on: :collection diff --git a/spec/features/admin/product_import_spec.rb b/spec/features/admin/product_import_spec.rb index efca92374b..35ebee5b1a 100644 --- a/spec/features/admin/product_import_spec.rb +++ b/spec/features/admin/product_import_spec.rb @@ -416,14 +416,6 @@ feature "Product Import", js: true do end end - describe "viewing the user guide" do - it "displays the guide" do - quick_login_as_admin - visit main_app.admin_product_import_guide_path - expect(page).to have_content "See below for a list of available categories" - end - end - private def import_data From d1e9d52acdfac84676ba3c580c5ebfbc9bdf6282 Mon Sep 17 00:00:00 2001 From: Transifex-Openfoodnetwork Date: Tue, 29 May 2018 14:51:07 +1000 Subject: [PATCH 135/206] Updating translations for config/locales/fr.yml --- config/locales/fr.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/config/locales/fr.yml b/config/locales/fr.yml index be35f826cd..2006bc146b 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -61,6 +61,10 @@ fr: Email / mot de passe incorrect. Créez votre compte ou réinitialisez votre mot de passe. unconfirmed: "Veuillez valider le lien envoyé par email pour pouvoir continuer." + already_registered: "Cet email est déjà associé à un utilisateur et a déjà été validé. Veuillez vous connecter pour continuer, ou utiliser un autre email." + user_passwords: + spree_user: + updated_not_active: "Votre mot de passe a bien été réinitialisé, mais votre email n'a pas encore été confirmé." enterprise_mailer: confirmation_instructions: subject: "Confirmez l'adresse email pour %{enterprise}" @@ -234,7 +238,7 @@ fr: clear_filters: Annuler les filtres clear: Annuler show_more: Afficher plus - show_n_more: Montrer %{num} supplémentaires + show_n_more: Montrer + %{num} choose: "Choisir..." please_select: Veuillez choisir... columns: Colonnes @@ -658,7 +662,7 @@ fr: welcome_title: Bienvenue sur Open Food France ! welcome_text: 'Vous avez créé avec succès ' next_step: Etape suivante - choose_starting_point: 'Choisissez par où commencer:' + choose_starting_point: 'Choisir votre type d''entreprise:' invite_manager: user_already_exists: "Le compte existe déjà" error: "Un problème est survenu" @@ -921,9 +925,9 @@ fr: register: "Démarrez ici" shop: messages: - login: "se connecter" + login: "Se connecter" register: "s'inscrire" - contact: "contact" + contact: "contacter" require_customer_login: "La boutique est réservée aux membres." require_login_html: "Déjà inscrit? %{login}. Sinon, %{register} pour pouvoir faire vos achats." require_customer_html: "Veuillez %{contact} %{enterprise} pour devenir membre." @@ -1458,6 +1462,7 @@ fr: november: "Novembre" december: "Décembre" email_not_found: "Adresse email non trouvée" + email_unconfirmed: "Vous devez confirmer votre adresse email avant de pouvoir réinitiatliser votre mot de passe." email_required: "Vous devez saisir une adresse email" logging_in: "Veuillez patienter, connexion en cours" signup_email: "Votre email" @@ -1747,12 +1752,7 @@ fr: spree_admin_enterprises_fees: "Marges et commissions" spree_admin_enterprises_none_create_a_new_enterprise: "CRÉER UNE NOUVELLE ENTREPRISE" spree_admin_enterprises_none_text: "Vous n'avez pas encore d'entreprise" - spree_admin_enterprises_producers_name: "Nom" - spree_admin_enterprises_producers_total_products: "Total produits " - spree_admin_enterprises_producers_active_products: "Produits actifs" - spree_admin_enterprises_producers_order_cycles: "Produits dans le cycle de vente" spree_admin_enterprises_tabs_hubs: "HUBS" - spree_admin_enterprises_tabs_producers: "PRODUCTEURS" spree_admin_enterprises_producers_manage_products: "GÉRER LES PRODUITS" spree_admin_enterprises_any_active_products_text: "Vous n'avez aucun produit actif." spree_admin_enterprises_create_new_product: "CRÉER UN NOUVEAU PRODUIT" From 6d450aeae23a75d0042255c0cd9231dc46f72dc9 Mon Sep 17 00:00:00 2001 From: Transifex-Openfoodnetwork Date: Tue, 29 May 2018 14:53:01 +1000 Subject: [PATCH 136/206] Updating translations for config/locales/pt.yml --- config/locales/pt.yml | 1264 ++++++++++++++++++++--------------------- 1 file changed, 632 insertions(+), 632 deletions(-) diff --git a/config/locales/pt.yml b/config/locales/pt.yml index ff22fc192d..e25c72939b 100644 --- a/config/locales/pt.yml +++ b/config/locales/pt.yml @@ -3,11 +3,11 @@ pt: activerecord: attributes: spree/order: - payment_state: Estado do pagamento + payment_state: Estado do Pagamento shipment_state: Estado do envio - completed_at: Completado em + completed_at: Concluído Em number: Número - email: Email do/a consumidor/a + email: Email do/a Consumidor/a spree/payment: amount: Quantia order_cycle: @@ -17,7 +17,7 @@ pt: spree/user: attributes: email: - taken: "Já existe uma conta associada a este email. Por favor faça login ou defina uma nova senha." + taken: "Já existe uma conta associada a este email. Por favor faça login ou defina uma nova palavra-passe." spree/order: no_card: Não há cartões de crédito válidos disponíveis order_cycle: @@ -58,9 +58,13 @@ pt: signed_up_but_unconfirmed: "Foi enviada uma mensagem para o seu endereço de email com um link de confirmação. Por favor clique nesse link para activar a sua conta." failure: invalid: | - Email ou senha incorrecto. - Entrou como visitante da última vez? Talvez precise de criar uma conta ou de redefinir a sua senha. + Email ou palavra-passe incorrectos. + Entrou como visitante da última vez? Talvez precise de criar uma conta ou de redefinir a sua palavra-passe. unconfirmed: "Tem de confirmar a sua conta antes de continuar." + already_registered: "Este endereço de email já está registado. Por favor faça login para continuar, ou volte atrás e use outro endereço de email." + user_passwords: + spree_user: + updated_not_active: "A sua palavra-passe foi redefinida, mas o seu email ainda não foi confirmado." enterprise_mailer: confirmation_instructions: subject: "Por favor confirme o endereço de email de %{enterprise}" @@ -108,38 +112,38 @@ pt: explainer: 'O processamento automático destas encomendas falhou devido a uma razão desconhecida. Isto não deveria estar a acontecer, por favor contacte-nos se estiver a ver esta mensagem. ' home: "OFN" title: Open Food Network - welcome_to: 'Bem vindo a' + welcome_to: 'Bem-vindo à' site_meta_description: "Começamos a partir da terra. Com agricultores e produtores prontos a contarem as suas histórias com um brilho nos olhos. Com distribuidores prontos a estabelecerem ligações entre pessoas e produtos de forma justa e honesta. Com consumidores que acreditam que melhores decisões no momento da compra..." search_by_name: Procurar por nome ou localidade... producers_join: Produtores e produtoras de proximidade, estão convidados a juntarem-se à Open Food Network! - charges_sales_tax: Cobra GST? + charges_sales_tax: Cobra IVA? print_invoice: "Imprimir factura" - print_ticket: "Imprimir bilhetes" + print_ticket: "Imprimir bilhete" select_ticket_printer: "Seleccionar impressora" send_invoice: "Enviar factura" resend_confirmation: "Reenviar confirmação" - view_order: "Ver encomenda" - edit_order: "Editar encomenda" - ship_order: "Enviar encomenda" - cancel_order: "Cancelar encomenda" - confirm_send_invoice: "Vai ser enviada uma factura desta encomenda ao cliente. Tem a certeza que deseja continuar?" + view_order: "Ver Encomenda" + edit_order: "Editar Encomenda" + ship_order: "Enviar Encomenda" + cancel_order: "Cancelar Encomenda" + confirm_send_invoice: "Vai ser enviada uma factura desta encomenda ao consumidor. Tem a certeza que deseja continuar?" confirm_resend_order_confirmation: "Tem a certeza que deseja enviar novamente o email de confirmação de encomenda? " - must_have_valid_business_number: "%{enterprise_name}tem de ter um ABN válido antes de se poder enviar facturas." + must_have_valid_business_number: "%{enterprise_name}tem de ter um NIPC/NIF válido antes de se poder enviar facturas." invoice: "Factura" percentage_of_sales: "%{percentage} de vendas" capped_at_cap: "limitado em %{cap}" per_month: "por mês" free: "gratuito" free_trial: "experimentar sem pagar" - plus_tax: "mais taxas" + plus_tax: "mais IVA" min_bill_turnover_desc: "assim que o volume de negócios exceder %{mbt_amount}" say_no: "Não" say_yes: "Sim" then: então ongoing: A decorrer - bill_address: Endereço de Facturação - ship_address: Endereço de Envio - sort_order_cycles_on_shopfront_by: "Ordenar Ciclos de Encomendas no Mercado Por" + bill_address: Morada de Faturação + ship_address: Morada de Envio + sort_order_cycles_on_shopfront_by: "Ordenar Ciclos de Encomendas na Loja Por" required_fields: Os campos obrigatórios estão indicados com um asterisco select_continue: Seleccionar e continuar remove: Remover @@ -155,7 +159,7 @@ pt: clone: Clonar distributors: Distribuidores distribution: Distribuição - bulk_order_management: Gestão de encomendas por atacado + bulk_order_management: Gestão de Encomendas por Atacado enterprise_groups: Grupos reports: Relatórios variant_overrides: Inventário @@ -204,7 +208,7 @@ pt: on_hand: Disponível on_demand: Sob Encomenda on_demand?: Sob Encomenda? - order_cycle: Ciclo de encomendas + order_cycle: Ciclo de Encomendas payment: Pagamento payment_method: Método de Pagamento phone: Telefone @@ -218,7 +222,7 @@ pt: shipping_method: Método de Envio shop: Loja sku: SKU - status_state: Estado + status_state: Região tags: Etiquetas variant: Variante weight: Peso @@ -269,25 +273,25 @@ pt: default_payment_method: deve estar definido se desejar criar facturas para organizações default_shipping_method: deve estar definido se desejar criar facturas para organizações registadas shopfront_settings: - embedded_shopfront_settings: "Definições de loja incorporadas" - enable_embedded_shopfronts: "Permitir lojas incorporadas" - embedded_shopfronts_whitelist: "Whitelist de domínios externos" + embedded_shopfront_settings: "Definições de Loja Embutida" + enable_embedded_shopfronts: "Permitir Lojas Embutidas" + embedded_shopfronts_whitelist: "Whitelist de Domínios Externos" number_localization: number_localization_settings: "Definições de Localização de Números" enable_localized_number: "Utilizar a lógica do separador de milhares/décimas internacional" business_model_configuration: edit: - business_model_configuration: "Modelo de negócio" - business_model_configuration_tip: "Configure a taxa de quais lojas serão cobradas mensalmente por usar o Open Food Network" + business_model_configuration: "Modelo de Negócio" + business_model_configuration_tip: "Configure a taxa a que as lojas serão cobradas mensalmente por usar o Open Food Network" bill_calculation_settings: "Configurações de cálculo de conta" bill_calculation_settings_tip: "Configure a quantia que será cobrada mensalmente às organizações pela utilização da OFN" shop_trial_length: "Duração da Loja Experimental (Dias)" shop_trial_length_tip: "Duração (em número de dias) do período experimental para as organizações definidas como lojas." - fixed_monthly_charge: "Taxa mensal fixa" + fixed_monthly_charge: "Taxa Mensal Fixa" fixed_monthly_charge_tip: "Uma taxa mensal fixa para todas as organizações definidas como loja e que tenham excedido o volume de negócios mínimo facturável (se definido)." percentage_of_turnover: "Percentagem do volume de negócios" percentage_of_turnover_tip: "Quando maior que zero, esta taxa (0,0 - 1,0) será aplicada ao volume de negócios total de cada loja e somada a qualquer taxa fixa (à esquerda) para calcular a conta mensal." - monthly_cap_excl_tax: "tecto mensal (excluindo GST)" + monthly_cap_excl_tax: "tecto mensal (excluindo IVA)" monthly_cap_excl_tax_tip: "Quando maior do que zero, este valor será usado como o limite na quantia cobrada às lojas em cada mês." tax_rate: "Taxa de Impostos" tax_rate_tip: "Taxa de impostos que se aplica à conta mensal que é cobrada às organizações por usarem o sistema." @@ -301,7 +305,7 @@ pt: cap_reached?_tip: "Se o \"tecto\" (limite definido à esquerda) foi atingido, dadas as definições e o volume de negócios indicado." included_tax: "Taxa incluída" included_tax_tip: "A taxa total incluída no exemplo de conta mensal, dadas as definições e o volume de negócios indicado." - total_monthly_bill_incl_tax: "Total de conta mensal (taxa incluída)" + total_monthly_bill_incl_tax: "Total da Conta Mensal (taxa incluída)" total_monthly_bill_incl_tax_tip: "O exemplo de conta mensal total incluindo taxas, dadas as definições e o volume de negócios indicado." customers: index: @@ -312,17 +316,17 @@ pt: add_a_new_customer_for: Adicionar novo/a consumidor/a a %{shop_name} code: Código duplicate_code: "Este código já está a ser usado." - bill_address: "Endereço de Facturação" - ship_address: "Endereço de Entrega" - update_address_success: 'Endereço actualizado com sucesso' + bill_address: "Morada de Faturação" + ship_address: "Morada de Envio" + update_address_success: 'Morada actualizada com sucesso' update_address_error: 'Perdão! Por favor preencha todos os campos obrigatórios!' - edit_bill_address: 'Editar Endereço de Facturação' - edit_ship_address: 'Editar Endereço de Entrega' + edit_bill_address: 'Editar Morada de Faturação' + edit_ship_address: 'Editar Morada de Envio' required_fileds: 'Os campos obrigatórios estão marcados com um asterisco' select_country: 'Seleccionar País' select_state: 'Seleccionar Região' edit: 'Editar' - update_address: 'Actualizar Endereço' + update_address: 'Actualizar Morada' confirm_delete: 'De certeza que é para apagar? ' search_by_email: "Pesquisar por e-mail/código" destroy: @@ -349,9 +353,9 @@ pt: index: title: Taxas de Organização enterprise: Organização - fee_type: Tipo de taxa + fee_type: Tipo de Taxa name: Nome - tax_category: Categoria de taxa + tax_category: Categoria de Imposto calculator: Calculadora calculator_values: Valores da calculadora enterprise_groups: @@ -368,7 +372,7 @@ pt: unit: Unidade display_as: Mostrar como category: Categoria - tax_category: Categoria de imposto + tax_category: Categoria de Imposto inherits_properties?: Herda Propriedades? available_on: Disponível em av_on: "Disp. em" @@ -395,7 +399,7 @@ pt: enable_reset?: Activar Reposição do Stock? inherit?: Herdar? add: Adicionar - hide: Esconder + hide: Ocultar select_a_shop: Seleccionar Uma Loja review_now: Rever Agora new_products_alert_message: Há %{new_product_count} novos produtos disponíveis para adicionar ao seu inventário. @@ -407,7 +411,7 @@ pt: no_matching_new_products: Nenhum produto novo corresponde à sua pesquisa inventory_powertip: Este é o seu inventário de produtos. Para adicionar produtos ao seu inventário, seleccione 'Novos Produtos' no menu Visualizar hidden_powertip: Estes produtos foram escondidos no seu inventário e não estarão disponíveis para serem adicionados à sua loja. Pode clicar em 'Adicionar' para adicionar um produto ao seu inventário. - new_powertip: 'Estes produtos estão disponíveis para serem adicionados ao seu inventário. Clique em ''Adicionar'' para adicionar um produto em seu inventário, ou ''Ocultar'' para escondê-lo. Pode voltar atrás quando quiser! ' + new_powertip: 'Estes produtos estão disponíveis para serem adicionados ao seu inventário. Clique em ''Adicionar'' para adicionar um produto ao seu inventário, ou ''Ocultar'' para escondê-lo. Pode voltar atrás quando quiser! ' controls: back_to_my_inventory: Voltar ao inventário orders: @@ -417,28 +421,28 @@ pt: invoice_email_sent: 'Email de facturação enviado' order_email_resent: 'Email de encomenda reenviado' bulk_management: - tip: "Use esta página para alterar a quantidade de produtos entre múltiplos pedidos. Produtos podem ser removidos completamente dos pedidos, se necessário" + tip: "Use esta página para alterar a quantidade de produtos entre múltiplas encomendas. Os produtos podem ser removidos completamente das encomendas, se necessário" shared: "Recurso Compartilhado?" - order_no: "Pedido nº" - order_date: "Data do Pedido" + order_no: "Encomenda Nº" + order_date: "Data da Encomenda" max: "Máximo" product_unit: "Produto: Unidade" weight_volume: "Peso/Volume" ask: "Perguntar?" - page_title: "Gestão de Pedidos a Granel" - actions_delete: "Apagar Selecionado" + page_title: "Gestão de Encomendas por Atacado" + actions_delete: "Apagar o Selecionado" loading: "Carregando encomendas" no_results: "Nenhuma encomenda encontrada. " group_buy_unit_size: "Tamanho de Unidade para Compras de Grupo" - total_qtt_ordered: "Quantidade Total do Pedido" - max_qtt_ordered: "Quantidade Máxima do Pedido" + total_qtt_ordered: "Quantidade Total da Encomenda" + max_qtt_ordered: "Quantidade Máxima da Encomenda" current_fulfilled_units: "Unidades Completas no Momento" max_fulfilled_units: "Máximo de Unidades Completas " - order_error: "Alguns erros devem ser corrigidos antes de actualizar os pedidos.\nOs campos com bordas vermelhas contêm erros." + order_error: "Alguns erros devem ser corrigidos antes de actualizar encomendas.\nOs campos com bordas vermelhas contêm erros." variants_without_unit_value: "AVISO: Algumas variantes não possuem unidade de valor" select_variant: "Selecionar uma variante" enterprise: - select_outgoing_oc_products_from: 'Selecione a saída do ciclo de pedidos ' + select_outgoing_oc_products_from: Selecione produtos de saída do ciclo de encomendas enterprises: index: title: Organizações @@ -449,14 +453,14 @@ pt: manage: Gerir form: about_us: - desc_short: Descrição curta + desc_short: Descrição Curta desc_short_placeholder: Conte-nos sobre a sua organização em uma ou duas frases desc_long: Sobre nós desc_long_placeholder: Conte um pouco de si aos consumidores. Esta informação aparece no seu perfil público. business_details: - abn: ABN + abn: NIPC abn_placeholder: 'ex: 99 123 456 789' - acn: ACN + acn: NIF acn_placeholder: 'ex: 123 456 789' display_invoice_logo: Exibir logotipo nas facturas invoice_text: 'Adicionar texto customizado no final das facturas ' @@ -468,17 +472,17 @@ pt: email_address_tip: "Este endereço de email será apresentado no seu perfil público" phone: Telefone phone_placeholder: 'ex: 987654321' - website: Site - website_placeholder: 'ex: www.trufas.com.br' + website: Website + website_placeholder: 'ex: www.cogumelos.pt' enterprise_fees: name: Nome - fee_type: Tipo de taxa - manage_fees: Gerir taxas da organização + fee_type: Tipo de Taxa + manage_fees: Gerir Taxas da Organização no_fees_yet: Ainda não tem nenhuma taxa de organização definida. create_button: Criar uma agora images: logo: Logo - promo_image_placeholder: 'Essa imagem aparece em "Sobre nós"' + promo_image_placeholder: 'Esta imagem aparece em "Sobre nós"' promo_image_note1: 'POR FAVOR OBSERVE:' promo_image_note2: Qualquer imagem promocional carregada aqui será cortada para 1200 x 260. promo_image_note3: A imagem promocional é exibida no topo da página de perfil da organização e em janelas pop-up. @@ -490,14 +494,14 @@ pt: produtos adicionados pelos seus fornecedores precisam de ser adicionados ao seu inventário antes de entrarem em stock. Se não está a usar o inventário para gerir os seus produtos, deve selecionar a opção 'recomendado' abaixo. - preferred_product_selection_from_inventory_only_yes: Novos produtos podem ser colocados na minha montra (recomendado) - preferred_product_selection_from_inventory_only_no: Novos produtos devem ser adicionados ao meu inventário antes de serem colocados na minha montra + preferred_product_selection_from_inventory_only_yes: Novos produtos podem ser colocados na minha loja (recomendado) + preferred_product_selection_from_inventory_only_no: Novos produtos têm de ser adicionados ao meu inventário antes de serem colocados na minha loja payment_methods: name: Nome applies: Aplica? manage: Gerir métodos de pagamento - not_method_yet: Ainda não tem nenhum método de pagamento. - create_button: Criar novo método de pagamento + not_method_yet: Não tem nenhum método de pagamento. + create_button: Criar Novo Método de Pagamento create_one_button: 'Criar um agora ' primary_details: name: 'Nome ' @@ -524,53 +528,54 @@ pt: shipping_methods: name: Nome applies: Aplica? - manage: Gerir formas de envio - create_button: Criar nova forma de envio + manage: Gerir Métodos de Envio + create_button: Criar Nova Forma de Envio create_one_button: Criar um agora no_method_yet: Ainda não tem nenhuma forma de envio. shop_preferences: shopfront_requires_login: "Mercado visível publicamente?" - shopfront_requires_login_tip: "Escolha se os consumidores vão precisar de fazer login para ver a montra da loja, ou se esta estará visível para toda a gente." + shopfront_requires_login_tip: "Escolha se os consumidores vão precisar de fazer login para ver a loja, ou se esta estará visível para toda a gente." shopfront_requires_login_false: "Público" - shopfront_requires_login_true: "Disponível somente para clientes registados" + shopfront_requires_login_true: "Visível somente para consumidores registados" recommend_require_login: "Recomendamos que peça login dos utilizadores quando é permitido que as encomendas sejam alteradas. " allow_guest_orders: "Encomendas de convidados" allow_guest_orders_tip: "Permitir o checkout como convidado, ou requisitar um utilizador registado. " - allow_guest_orders_false: "Requisitar o registo para fazer encomendas" + allow_guest_orders_false: "Exigir o registo para fazer encomendas" allow_guest_orders_true: "Permitir checkout de convidados" allow_order_changes: "Alterar encomendas" - allow_order_changes_tip: "Permitir que os consumidores alterem os seus pedidos enquanto o ciclo de encomendas estiver aberto." + allow_order_changes_tip: "Permitir que os consumidores alterem as suas encomendas enquanto o ciclo de encomendas estiver aberto." allow_order_changes_false: "Encomendas submetidas não podem ser alteradas / canceladas." - allow_order_changes_true: "Os consumidores pode alterar / cancelar encomendas enquanto o ciclo de encomendas estiver aberto." + allow_order_changes_true: "Os consumidores podem alterar / cancelar encomendas enquanto o ciclo de encomendas estiver aberto." enable_subscriptions: "Subscrições" - enable_subscriptions_tip: "Activar funcionalidade das subscrições?" + enable_subscriptions_tip: "Activar a funcionalidade das subscrições?" enable_subscriptions_false: "Desactivado" enable_subscriptions_true: "Activo" - shopfront_message: Mensagem da montra da loja + shopfront_message: Mensagem da Loja shopfront_message_placeholder: > Uma explicação breve e opcional para os consumidores a explicar como - funciona a loja, a ser exibida acima da lista de produtos na sua montra. - shopfront_closed_message: Mensagem de loja fechada + funciona a sua loja, a ser exibida acima da lista de produtos na sua + loja. + shopfront_closed_message: Mensagem de Loja Fechada shopfront_closed_message_placeholder: > - Uma mensagem que forneça uma explicação detalhada sobre o motivo da - loja estar fechada e quando é que os consumidores podem esperar que - abra novamente, a ser exibida quando não houver nenhum ciclo de encomendas - activo. + Uma mensagem que forneça uma explicação mais detalhada sobre o motivo + da sua loja estar fechada e quando é que os consumidores podem esperar + que abra novamente. A ser exibida quando não houver nenhum ciclo de + encomendas activo. shopfront_category_ordering: Categoria de pedidos da loja - open_date: Dia de abertura - close_date: Dia de fecho + open_date: Dia de Abertura + close_date: Data de fecho social: twitter_placeholder: 'ex: @o_prof' stripe_connect: connect_with_stripe: "Conectar com o Stripe" stripe_connect_intro: "Para aceitar pagamentos com cartão de crédito, vai ser necessário ligar a sua conta Stripe à Open Food Network. Use o botão à direita para começar." stripe_account_connected: "Conta Stripe conectada" - disconnect: "Desligar conta" + disconnect: "Desconectar conta" confirm_modal: title: Conectar com o Stripe - part1: O Stripe é um serviço de processamento de pagamentos que permite às lojas OFN aceitarem pagamentos dos seus clientes com cartão de crédito. + part1: O Stripe é um serviço de processamento de pagamentos que permite às lojas OFN aceitarem pagamentos dos seus consumidores com cartão de crédito. part2: Para usar esta funcionalidade, tem de ligar a sua conta Stripe à OFN. Ao clicar em 'Concordo' abaixo vai ser redirecionado para o website Stripe onde pode estabelecer ligação com uma conta existente, ou criar uma conta nova caso ainda não tenha uma. - part3: Isto vai permitir à Open Food Network aceitar pagamentos dos seus clientes com cartão de crédito em seu nome. Tenha em conta que do seu lado vai ser necessário manter a sua própria conta Stripe, pagar as taxas à Stripe e lidar com quaisquer rejeições ou serviços ao cliente. + part3: Isto vai permitir à Open Food Network aceitar pagamentos dos seus consumidores com cartão de crédito em seu nome. Tenha em conta que do seu lado vai ser necessário manter a sua própria conta Stripe, pagar as taxas à Stripe e lidar com quaisquer rejeições ou serviços ao cliente. i_agree: Concordo cancel: Cancelar tag_rules: @@ -584,17 +589,17 @@ pt: add_new_rule: '+ Adicionar nova regra' add_new_tag: '+ Adicionar nova etiqueta' users: - email_confirmation_notice_html: "A confirmação de e-mail está pendente. Enviamos um e-mail de confirmação para %{email}." + email_confirmation_notice_html: "A confirmação do email está pendente. Enviamos um email de confirmação para %{email}." resend: Reenviar owner: 'Proprietário' contact: "Contacto" - contact_tip: "O coordenador que vai receber emails da organização, como encomendas e notificações. Tem de ter um endereço de email confirmado." + contact_tip: "O gestor que vai receber emails da organização, como encomendas e notificações. Tem de ter um endereço de email confirmado." owner_tip: O utilizador principal responsável por esta organização. notifications: Notificações - notifications_tip: Notificações sobre pedidos serão enviadas para este endereço de e-mail. - notifications_placeholder: 'ex: gustavo@trufas.com.br' - notifications_note: 'Nota: um novo endereço de e-mail pode precisar ser confirmado antes do uso' - managers: Administradores + notifications_tip: Notificações sobre encomendas serão enviadas para este email. + notifications_placeholder: 'ex: gustavo@trufas.pt' + notifications_note: 'Nota: um novo endereço de email pode precisar de ser confirmado antes do uso' + managers: Gestores managers_tip: Os outros utilizadores com permissão para gerir esta organização. invite_manager: "Convidar Gestor/a" invite_manager_tip: "Convidar um/a utilizador/a não-registado/a a inscrever-se e tornar-se gestor/a desta organização." @@ -602,29 +607,29 @@ pt: email_confirmed: "Email confirmado" email_not_confirmed: "Email não confirmado" actions: - edit_profile: Editar perfil + edit_profile: Editar Perfil properties: Propriedades - payment_methods: Formas de pagamento + payment_methods: Métodos de pagamento payment_methods_tip: Esta organização não tem formas de pagamento definidas - shipping_methods: Formas de entrega + shipping_methods: Métodos de Envio shipping_methods_tip: Esta organização tem formas de entrega definidas - enterprise_fees: Taxas da organização + enterprise_fees: Taxas da Organização enterprise_fees_tip: Esta organização não cobra taxas admin_index: name: Nome - role: Cargo + role: Papeis sells: Vende visible: Visível? owner: Proprietário producer: Produtor change_type_form: - producer_profile: Perfil do produtor + producer_profile: Perfil do Produtor connect_ofn: Conectar através da Open Food Network always_free: SEMPRE GRATUITO producer_description_text: Adicione os seus produtos à Open Food Network, permitindo que os hubs os comercializem nas suas lojas. - producer_shop: Loja de produtor + producer_shop: Loja de Produtor sell_your_produce: 'Venda os seus próprios produtos ' - producer_shop_description_text: Venda os seus produtos directamente aos seus clientes através da sua própria montra Open Food Network. + producer_shop_description_text: Venda os seus produtos directamente aos seus consumidores através da sua própria montra Open Food Network. producer_shop_description_text2: Uma Loja de Produtor/a é somente para os seus produtos, se quiser vender produtos de outro local, selecione Hub de Produtores. producer_hub: Hub de Produtores producer_hub_text: Venda os seus próprios produtos e também os de outros produtores @@ -632,7 +637,7 @@ pt: profile: Somente perfil get_listing: Obter uma listagem profile_description_text: As pessoas podem encontrá-lo e contactá-lo na Open Food Network. A sua organização ficará visível no mapa e poderá ser encontrada através do motor de pesquisa. - hub_shop: Loja do hub + hub_shop: Loja do distribuidor hub_shop_text: Comercialize produtos de outros hub_shop_description_text: A sua organização é a espinha dorsal de um sistema de produção e consumo local. Através dela, pode agregar produtos de outras organizações e produtores, e comercializá-los na sua loja na Open Food Network. choose_option: Por favor escolha uma das opções acima. @@ -640,13 +645,13 @@ pt: enterprise_user_index: loading_enterprises: CARREGANDO ORGANIZAÇÕES no_enterprises_found: Nenhuma organização encontrada. - search_placeholder: Busca por nome - manage: Administrar + search_placeholder: Procurar por nome + manage: Gerir new_form: owner: Proprietário owner_tip: O utilizador principal responsável por esta organização. i_am_producer: Sou um produtor - contact_name: Nome para contato + contact_name: Nome do contacto edit: editing: 'Edição:' back_link: Voltar à lista de organizações @@ -654,35 +659,35 @@ pt: title: Nova Organização back_link: Voltar à lista de organizações welcome: - welcome_title: Bem-vindo ao Open Food Network! + welcome_title: Bem-vindo à Open Food Network! welcome_text: Você criou com sucesso uma - next_step: Próximo passo - choose_starting_point: 'Escolha o seu ponto de partida:' + next_step: Passo seguinte + choose_starting_point: 'Escolha o seu pacote:' invite_manager: user_already_exists: "O/A utilizador/a já existe" error: "Algo correu mal" order_cycles: edit: - advanced_settings: Configurações avançadas + advanced_settings: Configurações Avançadas update_and_close: Atualizar e fechar choose_products_from: 'Escolha produtos de:' exchange_form: - pickup_time_tip: Quando é que as encomendas deste OC ficam prontas para o cliente + pickup_time_tip: Quando as encomendas deste ciclo ficam prontas para o consumidor pickup_instructions_placeholder: "Instruções para levantamento" - pickup_instructions_tip: Estas instruções são mostradas aos clientes depois de finalizarem as suas encomendas + pickup_instructions_tip: Estas instruções são mostradas aos consumidores depois de finalizarem as suas encomendas pickup_time_placeholder: "Pronto para (dia/hora)" - receival_instructions_placeholder: "Instruções de recepção" + receival_instructions_placeholder: "Instruções de recebimento" add_fee: 'Acrescentar taxa' selected: 'selecionado' add_exchange_form: add_supplier: 'Acrescentar fornecedor' - add_distributor: 'Acrescentar distribuidor' + add_distributor: 'Adicionar distribuidor' advanced_settings: - title: Configurações avançadas - choose_product_tip: 'Você pode optar por restringir todos os produtos disponíveis (tanto de entrada, quanto de saída) somente para aqueles do inventário %{inventory}. ' + title: Configurações Avançadas + choose_product_tip: 'Pode optar por restringir todos os produtos disponíveis (tanto de entrada como de saída) apenas aos produtos do inventário %{inventory}. ' preferred_product_selection_from_coordinator_inventory_only_here: Apenas inventário do coordenador - preferred_product_selection_from_coordinator_inventory_only_all: Todos os produtos disponíveis - save_reload: Salvar e atualizar página + preferred_product_selection_from_coordinator_inventory_only_all: Todos os Produtos Disponíveis + save_reload: Guardar e recarregar página coordinator_fees: add: Adicionar taxa de coordenador form: @@ -695,48 +700,48 @@ pt: products: Produtos tags: Etiquetas add_a_tag: Acrescentar uma etiqueta - delivery_details: Detalhes de entrega/retirada + delivery_details: Detalhes de entrega/levantamento debug_info: Informação de depuração index: involving: Envolvendo schedule: Horário schedules: Horários - adding_a_new_schedule: Adicionar um novo horário - updating_a_schedule: Actualizar um horário - new_schedule: Novo horário - create_schedule: Criar horário - update_schedule: Actualizar horário - delete_schedule: Apagar horário + adding_a_new_schedule: Adicionar um novo Horário + updating_a_schedule: Actualizar um Horário + new_schedule: Novo Horário + create_schedule: Criar Horário + update_schedule: Actualizar Horário + delete_schedule: Apagar Horário created_schedule: Horário criado updated_schedule: Horário actualizado deleted_schedule: Horário apagado - schedule_name_placeholder: Nome do horário + schedule_name_placeholder: Nome do Horário name_required_error: Por favor atribua um nome a este horário no_order_cycles_error: Por favor selecione pelo menos um ciclo de encomendas (arrastar e largar) name_and_timing_form: name: Nome - orders_open: Pedidos abrem às + orders_open: Encomendas abrem às coordinator: Coordenador - order_closes: Pedidos fecham + order_closes: Encomendas fecham row: suppliers: fornecedores distributors: distribuidores variants: variantes simple_form: ready_for: Pronto para - ready_for_placeholder: Dia/hora + ready_for_placeholder: Data / hora customer_instructions: Instruções para o consumidor - customer_instructions_placeholder: Notas de entrega ou retirada + customer_instructions_placeholder: Notas de entrega ou levantamento products: Produtos fees: Taxas destroy_errors: - orders_present: Esse ciclo de encomendas foi selecionado por um/a consumidor/a e não pode ser apagado. Para evitar que os clientes acedam, por favor feche-o. + orders_present: Esse ciclo de encomendas foi selecionado por um/a consumidor/a e não pode ser apagado. Para evitar que os consumidores acedam, por favor feche-o. schedule_present: Esse ciclo de encomendas está ligado a um horário e não pode ser apagado. Por favor elimine a ligação ou apague primeiro o horário. bulk_update: no_data: Hmmm, algo correu mal. Não foram encontrados dados do ciclo de encomendas. producer_properties: index: - title: Propriedades do produtor + title: Propriedades do Produtor proxy_orders: cancel: could_not_cancel_the_order: Não foi possível cancelar a encomenda @@ -744,12 +749,12 @@ pt: could_not_resume_the_order: Não foi possível prosseguir com a encomenda shared: user_guide_link: - user_guide: Guia do usuário + user_guide: Manual do Utilizador invoice_settings: edit: - title: Configuração da fatura + title: Configuração de Faturas invoice_style2?: Use o modelo alternativo de fatura que inclui o total de impostos dividido por taxa e taxa de imposto por item (ainda não disponível para países que exibem preços sem taxas) - enable_receipt_printing?: Mostrar opções para imprimir recibos usando impressoras térmicas em pedidos suspensos? + enable_receipt_printing?: Mostrar opções para imprimir recibos usando impressoras térmicas no selector de encomendas? overview: enterprises_header: ofn_with_tip: As Organizações são Produtores e/ou Hubs e representam a unidade básica de organização dentro da Open Food Network. @@ -762,7 +767,7 @@ pt: resend_email: Reenviar Email has_no_payment_methods: "%{enterprise} actualmente não tem métodos de pagamento" has_no_shipping_methods: "%{enterprise} actualmente não tem métodos de envio" - email_confirmation: "O email de confirmação está pendente. Enviámos um email de confirmação para %{email}." + email_confirmation: "A confirmação do email está pendente. Enviámos um email de confirmação para %{email}." not_visible: "%{enterprise} não está visível portanto não pode ser encontrada nem no mapa nem nas pesquisas." reports: hidden: ESCONDIDA @@ -772,31 +777,31 @@ pt: supplier_totals: Totais do Fornecedor no Ciclo de Encomendas supplier_totals_by_distributor: Totais do Fornecedor no Ciclo de Encomendas por Distribuidor totals_by_supplier: Totais do Distribuidor no Ciclo de Encomendas por Fornecedor - customer_totals: Totais do Cliente no Ciclo de Encomendas + customer_totals: Totais do Consumidor no Ciclo de Encomendas all_products: Todos os produtos - inventory: Inventário (em mãos) + inventory: Inventário (disponível) lettuce_share: LettuceShare - mailing_list: Mailing List + mailing_list: Listas de Email addresses: Moradas - payment_methods: Relatório dos Métodos de Pagamento + payment_methods: Relatório de Métodos de Pagamento delivery: Relatório de Entregas tax_types: Tipos de Imposto tax_rates: Taxas de Imposto - pack_by_customer: Pacote por Cliente - pack_by_supplier: Pacote por Fornecedor + pack_by_customer: Embalar por Consumidor + pack_by_supplier: Embalar por Fornecedor orders_and_distributors: name: Encomendas e Distribuidores description: Encomendas com detalhes do distribuidor bulk_coop: name: Cooperativa por Atacado - description: Relatórios de encomendas de Cooperativa por Atacado + description: Relatórios de Encomendas de Cooperativas por Atacado payments: - name: Relatórios de Pagamento - description: Relatórios para Pagamentos + name: Relatórios de Pagamentos + description: Relatórios de Pagamentos orders_and_fulfillment: - name: Relatórios de Encomendas & Desempenho + name: Relatórios de Encomendas & Cumprimento customers: - name: Clientes + name: Consumidores/as products_and_inventory: name: Produtos & Inventário sales_total: @@ -808,16 +813,16 @@ pt: order_cycle_management: name: Gestão de Ciclo de Encomendas sales_tax: - name: Imposto de vendas + name: Imposto sobre Vendas xero_invoices: name: Facturas Xero - description: Facturas a importar no Xero + description: Facturas para importar para o Xero packing: - name: Relatórios de embalamento + name: Relatórios de Embalamento subscriptions: subscriptions: Subscrições - new: Nova subscrição - create: Criar subscrição + new: Nova Subscrição + create: Criar Subscrição index: please_select_a_shop: Por favor selecione uma loja edit_subscription: Editar Subscrição @@ -825,14 +830,14 @@ pt: unpause_subscription: Parar pausa da Subscrição cancel_subscription: Cancelar Subscrição setup_explanation: - just_a_few_more_steps: 'Só mais uns passos para poder começar:' + just_a_few_more_steps: 'Só mais uns passos antes de poder começar:' enable_subscriptions: "Activar subscrições para pelo menos uma das suas lojas" enable_subscriptions_step_1_html: 1. Vá à página %{enterprises_link}, encontre a sua loja, e clique em "Gerir" enable_subscriptions_step_2: 2. Em "Preferências da Loja", active a opção de Subscrições set_up_shipping_and_payment_methods_html: 'Definir métodos de %{shipping_link} e %{payment_link} ' - set_up_shipping_and_payment_methods_note_html: Repare que somente pagamentos em Dinheiro ou usando o método Stripe é que podem
ser usados com as subscrições + set_up_shipping_and_payment_methods_note_html: Note que apenas pagamentos em Dinheiro ou usando o método Stripe podem
ser usados com as subscrições ensure_at_least_one_customer_html: Garanta que existe pelo menos um %{customer_link} - create_at_least_one_schedule: Crie pelo menos um horário + create_at_least_one_schedule: Crie pelo menos um Horário create_at_least_one_schedule_step_1_html: 1. Vá à página %{order_cycles_link} create_at_least_one_schedule_step_2: 2. Crie um ciclo de encomendas se ainda não o fez create_at_least_one_schedule_step_3: 3. Clique em '+ Novo Horário', e preencha o formulário @@ -858,7 +863,7 @@ pt: product_already_in_order: Este produto já foi adicionado à encomenda. Por favor edite a quantidade directamente. orders: number: Número - confirm_edit: Tem a certeza que quer editar esta encomenda? Ao fazê-lo pode tornar-se mais difícil sincronizar alterações automaticamente à subscrição no futuro. + confirm_edit: Tem a certeza que quer editar esta encomenda? Ao fazê-lo pode tornar-se mais difícil sincronizar alterações automaticamente com a subscrição no futuro. confirm_cancel_msg: Tem a certeza que pretende cancelar esta subscrição? Esta acção não pode ser desfeita. cancel_failure_msg: 'Desculpe, o cancelamento falhou!' confirm_pause_msg: Tem a certeza que deseja pausar esta subscrição? @@ -866,7 +871,7 @@ pt: confirm_unpause_msg: Tem a certeza que pretende parar a pausa desta subscrição? unpause_failure_msg: 'Desculpe, não foi possível parar a pausa!' confirm_cancel_open_orders_msg: "Algumas encomendas para esta subscrição estão actualmente abertas. O/a consumidor/a foi notificado que a encomenda será processada. Quer cancelar esta(s) encomenda(s) ou mantê-las?" - resume_canceled_orders_msg: "Algumas encomendas desta subscrição podem ser retomadas neste momento. Para retomá-las, selecione no menú dropdown de encomendas." + resume_canceled_orders_msg: "Algumas encomendas desta subscrição podem ser retomadas neste momento. Para retomá-las, selecione no menu dropdown de encomendas." yes_cancel_them: Cancelá-las no_keep_them: Mantê-las yes_i_am_sure: Sim, tenho a certeza @@ -874,7 +879,7 @@ pt: no_results: no_subscriptions: Ainda não existem subscrições.... why_dont_you_add_one: Porque não acrescentar uma? :) - no_matching_subscriptions: Não foram encontradas subscrições a condizer + no_matching_subscriptions: Não foram encontradas subscrições correspondentes schedules: destroy: associated_subscriptions_error: Este horário não pode ser eliminado porque tem subscrições associadas @@ -887,13 +892,13 @@ pt: configuration_explanation_html: Para instruções detalhadas sobre como configurar a integração com Stripe Connect, por favor consulte este guia. status: Estado ok: Ok - instance_secret_key: Chave Secreta da instância - account_id: ID de conta - business_name: Nome do negócio + instance_secret_key: Chave Secreta da Instância + account_id: ID de Conta + business_name: Nome do Negócio charges_enabled: Taxas activas charges_enabled_warning: "Aviso: As taxas não estão activas para a sua conta" - auth_fail_error: A chave API que indicou não é válida - empty_api_key_error_html: Não foi fornecida nenhuma chave API. Para definir a sua chave API, por favor siga estas instruções + auth_fail_error: A chave da API que indicou não é válida + empty_api_key_error_html: Não foi fornecida nenhuma chave de API Stripe. Para definir a sua chave de API, por favor siga estas instruções controllers: enterprises: stripe_connect_cancelled: "A ligação ao Stripe foi cancelada" @@ -903,33 +908,33 @@ pt: resource: Configuração Stripe Connect checkout: already_ordered: - cart: "Carrinho" - message_html: "Você já possui um pedido para esta compra. Verifique o %{cart} para ver os itens já encomendados. Você também pode cancelar itens enquanto o ciclo estiver aberto." + cart: "carrinho" + message_html: "Já tem uma encomenda para este ciclo de encomendas. Verifique o %{cart} para ver os itens já encomendados. Também pode cancelar itens enquanto o ciclo estiver aberto." shops: hubs: show_closed_shops: "Mostrar lojas fechadas" - hide_closed_shops: "Esconder lojas fechadas" - show_on_map: "Mostrar tudo no mapa" + hide_closed_shops: "Ocultar lojas fechadas" + show_on_map: "Mostrar todas as lojas no mapa" shared: menu: cart: checkout: "Finalizar compra agora" - already_ordered_products: "Já encomendado neste ciclo de pedidos" + already_ordered_products: "Já encomendado neste ciclo de encomendas" register_call: - selling_on_ofn: "Tens interesse em participar na Open Food Network?" - register: "Regista-te aqui" + selling_on_ofn: "Tem interesse em participar na Open Food Network?" + register: "Registe-se aqui" shop: messages: login: "Entrar" register: "registo" contact: "contacto" require_customer_login: "Essa loja é somente para clientes." - require_login_html: "Fazer o %{login} se você já possui uma conta. Caso contrário, %{register} para se tornar cliente. " + require_login_html: "Por favor %{login} se já tem uma conta. Caso contrário, %{register} para se tornar consumidor." require_customer_html: "Por favor %{contact} a %{enterprise} para se tornar consumidor/a. " card_could_not_be_saved: o cartão não pode ser guardado spree_gateway_error_flash_for_checkout: "Houve um problema com a sua informação de pagamento: %{error}" - invoice_billing_address: "Endereço de cobrança:" - invoice_column_tax: "GST" + invoice_billing_address: "Morada de faturação:" + invoice_column_tax: "IVA" invoice_column_price: "Preço" invoice_column_item: "Item" invoice_column_qty: "Qtd" @@ -938,18 +943,18 @@ pt: invoice_column_price_with_taxes: "Preço total (incl. taxas)" invoice_column_price_without_taxes: "Preço total (sem taxas)" invoice_column_tax_rate: "Taxa de imposto" - invoice_tax_total: "Total GST:" - tax_invoice: "NOTA FISCAL" - tax_total: "Total de imposto (%{rate}):" - total_excl_tax: "Total (sem imposto):" - total_incl_tax: "Total (com imposto):" - abn: "ABN:" - acn: "ACN:" - invoice_issued_on: "Nota fiscal emitida em:" - order_number: "Número da nota fiscal:" - date_of_transaction: "Dia da transação:" + invoice_tax_total: "Total IVA:" + tax_invoice: "FACTURA FISCAL" + tax_total: "Total de Impostos (%{rate}):" + total_excl_tax: "Total (excl. imposto):" + total_incl_tax: "Total (incl. imposto):" + abn: "NIPC:" + acn: "NIF:" + invoice_issued_on: "Fatura emitida em:" + order_number: "Número da fatura:" + date_of_transaction: "Data da transação:" ticket_column_qty: "Qtd" - ticket_column_item: "Ítem" + ticket_column_item: "Item" ticket_column_unit_price: "Preço Unitário" ticket_column_total_price: "Preço Total" logo: "Logo (640x130)" @@ -967,24 +972,24 @@ pt: footer_email: "Email" footer_links_md: "Links" footer_about_url: "URL Sobre" - footer_tos_url: "URL Termos de Serviço" + footer_tos_url: "URL dos Termos de Serviço" name: Nome first_name: Primeiro Nome last_name: Último Nome email: Email phone: Telefone - next: Próximo + next: Seguinte address: Morada address_placeholder: 'ex: Rua Alta, 123' - address2: Complemento + address2: Morada (cont.) city: Cidade - city_placeholder: 'ex: Porto' + city_placeholder: ex. Famalicão postcode: Código postal postcode_placeholder: 'ex: 4000-125' state: Região country: País unauthorized: Não autorizado - terms_of_service: "Termos de Serviço" + terms_of_service: "Termos de serviço" on_demand: Sob encomenda none: Nenhum not_allowed: Não permitido @@ -1004,9 +1009,9 @@ pt: label_learn: "Aprender" label_blog: "Blog" label_support: "Assistência" - label_shopping: "Compras" + label_shopping: "A comprar" label_login: "Entrar" - label_logout: "Sair" + label_logout: "Terminar sessão" label_signup: "Registe-se" label_administration: "Administração" label_admin: "Admin" @@ -1015,7 +1020,7 @@ pt: label_less: "Mostrar menos" label_notices: "Avisos" cart_items: "itens" - cart_headline: "o seu carrinho de compras" + cart_headline: "O seu carrinho de compras" total: "Total" cart_updating: "Atualizando carrinho" cart_empty: "Carrinho vazio" @@ -1049,14 +1054,14 @@ pt: footer_sites_headline: "Páginas OFN" footer_sites_developer: "Desenvolvimento" footer_sites_community: "Comunidade" - footer_sites_userguide: "Manual de Utilizador" + footer_sites_userguide: "Manual do Utilizador" footer_secure: "Seguro e de confiança." footer_secure_text: "A Open Food Network utiliza a criptografia SSL (2048 bit RSA) para manter as suas informações em segurança. Os nossos servidores não guardam os detalhes do seu cartão de crédito e os pagamentos são processados por serviços compatíveis com PCI." footer_contact_headline: "Ficamos em contacto" footer_contact_email: "Envie-nos um email" footer_nav_headline: "Navegar" footer_join_headline: "Junte-se a nós" - footer_join_body: "Criar uma lista de ofertas, uma loja ou um grupo de consumo na Open Food Network" + footer_join_body: "Crie uma lista de ofertas, uma loja ou um grupo de consumo na Open Food Network" footer_join_cta: "Quero saber mais!" footer_legal_call: "Leia os nossos" footer_legal_tos: "Termos e condições" @@ -1089,80 +1094,80 @@ pt: stats_shops: "lojas" stats_shoppers: "consumidores/as" stats_orders: "encomendas" - checkout_title: Fechar pedido - checkout_now: Fechar pedido agora - checkout_order_ready: Pedido pronto para + checkout_title: Finalizar compra + checkout_now: Finalizar compra agora + checkout_order_ready: Encomenda pronta para checkout_hide: Ocultar checkout_expand: Expandir - checkout_headline: "Ok, pronto para fechar o pedido?" - checkout_as_guest: "Fechar pedido como convidado" - checkout_details: "Seus detalhes" + checkout_headline: "Ok, pronto para finalizar a encomenda?" + checkout_as_guest: "Finalizar encomenda como convidado" + checkout_details: "Os seus detalhes" checkout_billing: "Informações para fatura" - checkout_default_bill_address: "Salvar como endereço de faturamento padrão" + checkout_default_bill_address: "Guardar como morada de faturação por defeito" checkout_shipping: Informações para envio - checkout_default_ship_address: "Salvar como endereço para entrega padrão" + checkout_default_ship_address: "Guardar como morada para entrega por defeito" checkout_method_free: Não custa nada - checkout_address_same: O endereço de entrega é o mesmo endereço da fatura? + checkout_address_same: A morada de entrega é a mesma que a morada de faturação? checkout_ready_for: "Pronto para" checkout_instructions: "Algum comentário ou instruções especiais?" checkout_payment: Pagamento - checkout_send: Fechar pedido agora - checkout_your_order: Seu pedido + checkout_send: Finalizar encomenda agora + checkout_your_order: A sua encomenda checkout_cart_total: Total do carrinho checkout_shipping_price: Envio checkout_total_price: Total - checkout_back_to_cart: "De volta para o Carrinho" - cost_currency: "Moeda de custo" + checkout_back_to_cart: "Voltar ao Carrinho" + cost_currency: "Moeda de Custo" order_paid: PAGO order_not_paid: NÃO PAGO - order_total: Total do pedido + order_total: Total da encomenda order_payment: "Pagando com:" - order_billing_address: Endereço de fatura + order_billing_address: Morada de faturação order_delivery_on: Entrega em - order_delivery_address: Endereço para entrega + order_delivery_address: Morada de entrega order_delivery_time: Tempo de entrega - order_special_instructions: "Suas anotações" - order_pickup_time: Pronto para retirada - order_pickup_instructions: Instruções para retirada + order_special_instructions: "As suas notas" + order_pickup_time: Pronto para levantamento + order_pickup_instructions: Instruções para levantamento order_produce: Produtos order_total_price: Total - order_includes_tax: (inclui taxas) - order_payment_paypal_successful: Seu pagamento via Paypal foi processado com sucesso. - order_hub_info: Informações do distribuidor + order_includes_tax: (inclui impostos) + order_payment_paypal_successful: O seu pagamento via Paypal foi processado com sucesso. + order_hub_info: Informações da Central order_back_to_store: Voltar à loja - order_back_to_cart: Voltar ao carrinho - bom_tip: "Use esta página para alterar as quantidades de produtos em vários pedidos. Os produtos também podem ser inteiramente removidos dos pedidos, se necessário." - unsaved_changes_warning: "Existem modificações não salvas que serão perdidas se você continuar" - unsaved_changes_error: "Campos com bordas vermelhas contem erros. " + order_back_to_cart: Voltar ao Carrinho + bom_tip: "Use esta página para alterar as quantidades de produtos em várias encomendas. Os produtos também podem ser removidos das encomendas, se necessário." + unsaved_changes_warning: "Existem modificações não guardadas que serão perdidas se continuar." + unsaved_changes_error: "Campos com contornos vermelhos contêm erros. " products: "Produtos" products_in: "em %{oc}" products_at: "em %{distributor}" products_elsewhere: "Produtos encontrados em outros lugares" - email_welcome: "Bem vindo" - email_confirmed: "Obrigado por confirmar seu endereço" + email_welcome: "Bem-vindo" + email_confirmed: "Obrigado por confirmar o seu endereço de email." email_registered: "é agora parte de" - email_userguide_html: "O Guia de Usuário com suporte detalhado para gerenciar sua loja está aqui:" - email_admin_html: "Você pode gerenciar sua conta entrando no %{link} ou clicando na engrenagem no canto superior direito da página, e selecionando Administração." + email_userguide_html: "O Manual do Utilizador com suporte detalhado para gerir a sua loja está aqui: %{link}" + email_admin_html: "Pode gerir a sua conta entrando no %{link} ou clicando na roda dentada no canto superior direito da página, e selecionando Administração." email_community_html: "Também temos um fórum online para discussão sobre a plataforma e os desafios de se manter uma iniciativa de produção e consumo local. Está convidado/a a participar! Estamos constantemente a evoluir e as suas ideias vão ajudar-nos a melhorar.\n%{link}" - join_community: "Faça parte da comunidade" + join_community: "Junte-se à comunidade" email_confirmation_activate_account: "Antes de podermos activar a sua conta, precisamos de confirmar o seu endereço de email." email_confirmation_greeting: "Olá, %{contact}!" - email_confirmation_profile_created: "Um perfil para %{name} foi criado com sucesso!\nPara ativar seu Perfil precisamos que você confirme seu endereço de email." - email_confirmation_click_link: "Clique no link abaixo para confirma seu email e continuar criando seu perfil." - email_confirmation_link_label: "Confirme esse endereço de email »" + email_confirmation_profile_created: "Um perfil para %{name} foi criado com sucesso!\nPara ativar o seu Perfil precisamos que confirme este endereço de email." + email_confirmation_click_link: "Clique no link abaixo para confirmar o seu email e continuar a criar o seu perfil." + email_confirmation_link_label: "Confirme este endereço de email »" email_confirmation_help_html: "Depois de confirmar o seu email, pode aceder à conta de administração desta organização. Veja o %{link} para saber mais sobre a %{sitename} e para começar a utilizar o seu perfil ou loja online." - email_social: "Conecte-se com a gente:" + email_social: "Conecte-se connosco:" email_contact: "Envie-nos um email:" - email_signoff: "Olá," - email_signature: "%{sitename} Equipe" + email_signoff: "Obrigado," + email_signature: "Equipa %{sitename} " email_confirm_customer_greeting: "Olá %{name}, " email_confirm_customer_intro_html: "Obrigado por comprar com %{distributor}!" - email_confirm_customer_number_html: "Confirmação de pedido #%{number}" - email_confirm_customer_details_html: "Aqui estão os detalhes de pedido de %{distributor}:" + email_confirm_customer_number_html: "Confirmação de encomenda #%{number}" + email_confirm_customer_details_html: "Aqui estão os detalhes da sua encomenda com %{distributor}:" email_confirm_customer_signoff: "Atenciosamente," email_confirm_shop_greeting: "Olá %{name}, " - email_confirm_shop_order_html: "Parabéns! Você tem um novo pedido para %{distributor}:" - email_confirm_shop_number_html: "Confirmação de pedido #%{number}" + email_confirm_shop_order_html: "Parabéns! Tem uma nova encomenda para %{distributor}:" + email_confirm_shop_number_html: "Confirmação de encomenda #%{number}" email_order_summary_item: "Item" email_order_summary_quantity: "Qtd" email_order_summary_price: "Preço" @@ -1175,65 +1180,65 @@ pt: email_payment_method: "Pagando com:" email_so_placement_intro_html: "Tem uma nova encomenda com %{distributor}" email_so_placement_details_html: "Aqui estão os detalhes da sua encomenda com %{distributor}:" - email_so_placement_changes: "Infelizmente, nem todos os produtos que requisitou estão disponíveis. As quantidades originais que requisitou aparecem abaixo." + email_so_placement_changes: "Infelizmente, nem todos os produtos que requisitou estão disponíveis. As quantidades originais que requisitou aparecem riscadas abaixo." email_so_payment_success_intro_html: "Um pagamento automático foi processado para a sua encomenda com %{distributor}." email_so_placement_explainer_html: "Esta encomenda foi criada automaticamente para si." email_so_edit_true_html: "Pode fazer alterações até ao fecho das encomendas a %{orders_close_at}." email_so_edit_false_html: "Pode ver detalhes desta encomenda em qualquer momento. " email_so_contact_distributor_html: "Se tiver alguma questão pode contactar %{distributor}através de %{email}." email_so_confirmation_intro_html: "A sua encomenda com %{distributor} está agora confirmada" - email_so_confirmation_explainer_html: "Esta encomenda foi feita automaticamente para si, e já está finalizada." + email_so_confirmation_explainer_html: "Esta encomenda foi feita automaticamente para si, e está agora finalizada." email_so_confirmation_details_html: "Aqui está tudo o que precisa de saber sobre a sua encomenda com %{distributor}:" email_so_empty_intro_html: "Tentámos fazer uma nova encomenda com %{distributor}, mas tivemos alguns problemas..." - email_so_empty_explainer_html: "Infelizmente, nenhum dos produtos que requisitou estava disponível, portanto a encomenda não prosseguiu. As quantidades originais que requisitou aparecem abaixo. " + email_so_empty_explainer_html: "Infelizmente, nenhum dos produtos que requisitou estava disponível, portanto a encomenda não prosseguiu. As quantidades originais que requisitou aparecem riscadas abaixo. " email_so_empty_details_html: "Aqui estão os detalhes da encomenda que não prosseguiu com %{distributor}:" email_so_failed_payment_intro_html: "Tentámos processar um pagamento, mas tivemos alguns problemas..." email_so_failed_payment_explainer_html: "O pagamento para a sua subscrição com %{distributor} falhou devido a um problema no seu cartão de crédito. %{distributor} foi notificado desta falha no pagamento. " email_so_failed_payment_details_html: "Aqui estão os detalhes da falha fornecidos pelo portal de pagamento:" email_shipping_delivery_details: Detalhes da entrega - email_shipping_delivery_time: "Entrega " - email_shipping_delivery_address: "Endereço de entrega:" - email_shipping_collection_details: Detalhes para retirada - email_shipping_collection_time: "Pronto para retirada" - email_shipping_collection_instructions: "Instruções para retirada:" - email_special_instructions: "Suas anotações" + email_shipping_delivery_time: "Entrega em:" + email_shipping_delivery_address: "Morada de entrega:" + email_shipping_collection_details: Detalhes para levantamento + email_shipping_collection_time: "Pronto para levantamento" + email_shipping_collection_instructions: "Instruções para levantamento:" + email_special_instructions: "As suas notas:" email_signup_greeting: Olá! - email_signup_welcome: "Bem vindo a %{sitename}!" + email_signup_welcome: "Bem-vindo a %{sitename}!" email_signup_confirmed_email: "Obrigada por confirmar o seu email." - email_signup_shop_html: "Já pode fazer log in em %{link}." + email_signup_shop_html: "Agora pode fazer log in em %{link}." email_signup_text: "Obrigada por juntar-se à rede. Se é consumidor/a, vai ficar a conhecer vários produtores fantásticos, pontos de venda incríveis e comidas deliciosas! Se é produtor/a ou uma organização de consumo local, estamos felizes por tê-lo na nossa rede" - email_signup_help_html: "Dúvidas e comentários são sempre benvindos; você pode usar o botão Enviar Comentário no site, ou enviar um e-mail para %{email}" + email_signup_help_html: "Questões e comentários são sempre bem-vindos; pode usar o botão Enviar Comentário no site, ou enviar um email para %{email}" invite_email: greeting: "Olá!" invited_to_manage: "Foste convidado/a para gerir %{enterprise} em %{instance}." - confirm_your_email: "Deves ter recebido, ou receberás em breve, um email com um link de confirmação. Não vais conseguir aceder ao perfil de %{enterprise} enquanto não tiveres confirmado o teu email. " + confirm_your_email: "Deve ter recebido, ou receberá em breve, um email com um link de confirmação. Não vai conseguir aceder ao perfil de %{enterprise} enquanto não tiver confirmado o seu email. " set_a_password: "Depois ser-te-á pedido que definas uma palavra-passe antes de poderes administrar a organização." mistakenly_sent: "Não tens a certeza porque é que recebeste este email? Por favor contacta %{owner_email}para mais informação. " - producer_mail_greeting: "Querido" - producer_mail_text_before: "Agora temos todos os pedidos do cliente para a próxima entrega." - producer_mail_order_text: "Aqui está um resumo de pedidos para seus produtos:" - producer_mail_delivery_instructions: "Instruções para retirada/entrega do estoque:" + producer_mail_greeting: "Caro/a" + producer_mail_text_before: "Agora temos todos as encomendas para a próxima entrega." + producer_mail_order_text: "Aqui está um resumo das encomendas para os seus produtos:" + producer_mail_delivery_instructions: "Instruções para levantamento/entrega dos produtos:" producer_mail_signoff: "Obrigado e até breve" - shopping_oc_closed: Fechado para pedidos - shopping_oc_closed_description: "Favor aguardar até que o próximo ciclo seja aberto (ou entre em contato diretamente para saber se podemos aceitar pedidos atrasados)" - shopping_oc_last_closed: "A último ciclo fechou a %{distance_of_time} atrás" - shopping_oc_next_open: "A próximo ciclo abre em %{distance_of_time}" + shopping_oc_closed: Fechado para encomendas + shopping_oc_closed_description: "Por favor aguarde até que o próximo ciclo seja aberto (ou entre em contato connosco para saber se podemos aceitar encomendas tardias)" + shopping_oc_last_closed: "O último ciclo fechou à %{distance_of_time} atrás" + shopping_oc_next_open: "O próximo ciclo abre em %{distance_of_time}" shopping_tabs_about: "Sobre %{distributor}" - shopping_tabs_contact: "Contato" - shopping_contact_address: "Endereço" - shopping_contact_web: "Contato" + shopping_tabs_contact: "Contacto" + shopping_contact_address: "Morada" + shopping_contact_web: "Contacto" shopping_contact_social: "Seguir" shopping_groups_part_of: "é parte de:" - shopping_producers_of_hub: "produtores de %{hub}:" - enterprises_next_closing: "Próximo fechamento de pedido" + shopping_producers_of_hub: "Produtores de %{hub}:" + enterprises_next_closing: "As encomendas fecham em" enterprises_ready_for: "Pronto para" - enterprises_choose: "Escolha para quando você quer seu pedido:" + enterprises_choose: "Escolha para quando quer a sua encomenda:" maps_open: "Aberto" maps_closed: "Fechado" - hubs_buy: "Compre por:" - hubs_shopping_here: "Comprando aqui" - hubs_orders_closed: "Fechado para pedidos" - hubs_profile_only: "Somente perfil" + hubs_buy: "Comprar:" + hubs_shopping_here: "Comprar aqui" + hubs_orders_closed: "Fechado para encomendas" + hubs_profile_only: "Apenas perfil" hubs_delivery_options: "Opções de entrega" hubs_pickup: "Levantamento" hubs_delivery: "Entrega" @@ -1242,7 +1247,7 @@ pt: hubs_filter_type: "Tipo" hubs_filter_delivery: "Entrega" hubs_filter_property: "Propriedade" - hubs_matches: "Você quis dizer?" + hubs_matches: "Quis dizer?" hubs_intro: Compre na sua região hubs_distance: Mais próximo a hubs_distance_filter: "Mostrar lojas próximas a %{location}" @@ -1250,94 +1255,94 @@ pt: one: Tem encomendas com actualmente abertas para revisão. Pode fazer alterações até . other: 'Tem %{count}encomendas com %{shop} actualmente abertas para revisão. Pode fazer alterações até %{oc_close}. ' orders_changeable_orders_alert_html: Esta encomenda está confirmada, mas pode fazer alterações até %{oc_close}. - products_clear_all: Apagar tudo + products_clear_all: Limpar tudo products_showing: "Mostrando:" products_with: com products_search: "Procurar por produto ou produtor" products_loading: "Carregando produtos..." products_updating_cart: "Atualizando carrinho..." products_cart_empty: "Carrinho vazio" - products_edit_cart: "Edite seu carrinho" + products_edit_cart: "Edite o seu carrinho" products_from: de - products_change: "Nenhuma modificação a ser salva." - products_update_error: "Falha ao salvar, com os seguintes erros:" - products_update_error_msg: "Falha ao salvar." - products_update_error_data: "Falha no salvamento devido a dados inválidos:" - products_changes_saved: "Modificações salvas." - search_no_results_html: "Desculpe, nenhum resultado encontrado para %{query}. Que tal tentar outra busca?" - components_profiles_popover: "Perfis não possuem mercaods na Open Food Network, mas pode ter suas próprias lojas físicas ou online em outro endereço" + products_change: "Nenhuma modificação a ser guardada." + products_update_error: "Falha ao guardar, com os seguintes erros:" + products_update_error_msg: "Falha ao guardar." + products_update_error_data: "Falha ao guardar devido a dados inválidos:" + products_changes_saved: "Alterações gravadas." + search_no_results_html: "Desculpe, nenhum resultado encontrado para %{query}. Tente fazer outra pesquisa." + components_profiles_popover: "Os Perfis não possuem loja na Open Food Network, mas podem ter as suas próprias lojas físicas ou online noutro endereço." components_profiles_show: "Mostrar perfis" components_filters_nofilters: "Nenhum filtro" - components_filters_clearfilters: "Eliminar filtros" + components_filters_clearfilters: "Limpar filtros" groups_title: Grupos groups_headline: Grupos / Regiões - groups_text: "Todo produtor é único. Todo negócio tem algo de diferente para oferecer. Nossos grupos são coletivos de produtores, armazéns e distribuidores que compartilham algo em comum, como localização, mercado ou filosofia. Isso faz com que sua experiência de compra fique mais fácil. Explore a curadoria feita por cada um de nosso grupos. " - groups_search: "Procurar por nome ou localidade" - groups_no_groups: "Nenhum grupo encontrado. " + groups_text: "Cada produtor é único. Cada negócio tem algo de diferente para oferecer. Os nossos grupos são coletivos de produtores, centrais e distribuidores que partilham algo em comum, como localização, mercado ou filosofia. Isso faz com que a sua experiência de compra seja mais fácil. Explore a curadoria feita por cada um dos nosso grupos. " + groups_search: "Procurar por nome ou termo" + groups_no_groups: "Nenhum grupo encontrado." groups_about: "Sobre nós" - groups_producers: "Nosso produtores" - groups_hubs: "Nossas" - groups_contact_web: Contato + groups_producers: "Os nossos produtores" + groups_hubs: "Nossas centrais" + groups_contact_web: Contacto groups_contact_social: Seguir - groups_contact_address: Endereço + groups_contact_address: Morada groups_contact_email: Envie-nos um email - groups_contact_website: Visite nosso website - groups_contact_facebook: Siga + groups_contact_website: Visite o nosso website + groups_contact_facebook: Siga-nos no Facebook groups_signup_title: Inscreva-se como grupo groups_signup_headline: Inscrição de grupos groups_signup_intro: "Somos uma plataforma incrível para marketing colaborativo: a maneira mais fácil para que seus membros alcancem novos mercados. Não temos fins lucrativos, somos simples e acessíveis." groups_signup_email: Envie-nos um email groups_signup_motivation1: Transformamos o sistema alimentar de maneira justa. - groups_signup_motivation2: 'Trabalhos para isso. Somos uma organização global, sem fins lucrativos, baseada em código open source. Jogamos limpo, sem mistério. ' - groups_signup_motivation3: 'Sabemos que você tem grandes planos, e queremos ajudar. Podemos compartilhar conhecimento, redes e recurso. Sabemos que ninguém muda nada sozinho, por isso queremos você como parceiro. ' + groups_signup_motivation2: Trabalhos para isso. Somos uma organização global sem fins lucrativos, baseada em código open source. Jogamos limpo. + groups_signup_motivation3: 'Sabemos que tem grandes ideias, e queremos ajudar. Podemos partilhar conhecimento, redes e recursos. Sabemos que ninguém muda nada sozinho, por isso queremos esta parceria consigo. ' groups_signup_motivation4: Vamos até onde você está. - groups_signup_motivation5: 'Seja uma cooperativa de produtores, distribuidores, indústria ou governo local. ' - groups_signup_motivation6: 'Seja qual for o seu papel, estamos aqui para ajudar. Entre em contato com a gente e conte-nos sobre suas ideias e projetos. ' - groups_signup_motivation7: 'Queremos dar sentido para os movimentos por boa comida. ' - groups_signup_motivation8: 'Se você precisa engajar sua rede de contatos, nós oferecemos a plataforma para isso. Conectamos todos os agentes e setores envolvidos no sistema alimentar. ' - groups_signup_motivation9: 'Se você precisa de recursos, nós te conectamos a uma rede global de parceiros. ' - groups_signup_pricing: Conta de Grupos + groups_signup_motivation5: 'Pode ser uma cooperativa de produtores, distribuidores, indústria ou governo local. ' + groups_signup_motivation6: Seja qual for o seu papel, estamos prontos para ajudar. Entre em contato connosco para saber como seria ou como está a ser a implementação da Open Food Network na sua região. + groups_signup_motivation7: 'Queremos dar mais sentido aos movimentos por boa comida. ' + groups_signup_motivation8: 'É necessário envolver a sua rede, nós oferecemos uma plataforma para contacto e ação. Nós ajudamos a chegar a todos os agentes, interessados e setores. ' + groups_signup_motivation9: 'Se precisa de recursos, nós traremos a nossa epxeriência. Se precisa de cooperação, nós conectamos a uma rede global de parceiros. ' + groups_signup_pricing: Conta de Grupo groups_signup_studies: Estudos de Caso groups_signup_contact: Pronto para discutir? - groups_signup_contact_text: "Entre em contato para descobrir o que a OFN pode fazer por você" + groups_signup_contact_text: "Entre em contato para descobrir o que a OFN pode fazer por si:" groups_signup_detail: "Aqui está o detalhe. " - login_invalid: "Email ou senha inválidos" + login_invalid: "Email ou palavra-passe inválidos" modal_hubs: "Centrais de Alimentos" - modal_hubs_abstract: Nossas centrais de alimentos são o ponto de contato entre você e as pessoas que produzem sua comida! - modal_hubs_content1: 'Você pode procurar pelo mercado mais próximo, por localização ou por nome. Alguns distribuidores possuem múltiplos pontos de entrega, onde você pode retirar suas compras, e outros ainda entregam na sua casa. Cada mercado é um ponto de venda independente, e por isso as ofertas e maneira de operar podem variar de um para outro. ' - modal_hubs_content2: Você só pode comprar em uma central de alimentos por vez. + modal_hubs_abstract: As nossas centrais de alimentos são o ponto de contato entre si e as pessoas que produzem a sua comida! + modal_hubs_content1: 'Pode procurar por uma central conveniente por localização ou por nome. Algumas centrais têm múltiplos pontos de entrega, onde pode levantar as suas compras, e outros ainda entregam na sua casa. Cada central é um ponto de venda independente, e por isso as ofertas e maneira de operar podem variar de um para outro. ' + modal_hubs_content2: Só pode comprar numa central de alimentos de cada vez. modal_groups: "Grupos / Regiões" - modal_groups_content1: Essas são as organizações e relações entre as centrais que constroem a Open Food Network + modal_groups_content1: Estas são as organizações e relações entre as centrais que constroem a Open Food Network modal_groups_content2: Alguns grupos estão organizados por localização, outros por similaridades não geográficas. modal_how: "Como funciona" - modal_how_shop: Compra na Open Food Network - modal_how_shop_explained: Procure por um mercado próximo e comece suas compras! Em cada mercado você pode ver, em detalhe, quais produtos são oferecidos (você só pode comprar em um mercado de cada vez). + modal_how_shop: Compre na Open Food Network + modal_how_shop_explained: Procure por um mercado próximo e comece as suas compras! Em cada mercado pode ver, em detalhe, quais os produtos que são oferecidos (só pode comprar num mercado de cada vez). modal_how_pickup: 'Custos de coleta e entrega. ' - modal_how_pickup_explained: 'Alguns mercados entregam na sua casa, outros oferecem um local para que você mesmo retire os produtos. É possível ver quais opções estão disponíveis no perfil individual de cada um, e fazer sua escolha no momento do checkout. Provavelmente será cobrada uma taxa de entrega, que pode variar de mercado para mercado. ' + modal_how_pickup_explained: Alguns mercados entregam em sua casa, outros oferecem um local para que levante os produtos. É possível ver quais as opções que estão disponíveis no perfil individual de cada um, e fazer a sua escolha no momento do checkout. Provavelmente será cobrada uma taxa de entrega, que pode variar de mercado para mercado. modal_how_more: Saiba mais - modal_how_more_explained: "Para saber mais sobre a Open Food Network, como funciona, e se envolver:" + modal_how_more_explained: "Para saber mais sobre a Open Food Network, como funciona, e participar, visite:" modal_producers: "Produtores" - modal_producers_explained: "Nosso produtores são quem disponibilizam toda a oferta da Open Food Network" + modal_producers_explained: "Os nosso produtores são quem disponibilizam toda a comida que pode comprar na Open Food Network." producers_about: Sobre nós - producers_buy: Compre por - producers_contact: Contato + producers_buy: Comprar + producers_contact: Contacto producers_contact_phone: Ligue producers_contact_social: Seguir - producers_buy_at_html: "Compre por produtos oferecidos por %{enterprise} em:" + producers_buy_at_html: "Compre produtos oferecidos por %{enterprise} em:" producers_filter: Filtrar por producers_filter_type: Tipo - producers_filter_property: Propriedades + producers_filter_property: Propriedade producers_title: Produtores producers_headline: Encontre produtores locais producers_signup_title: Inscreva-se como produtor producers_signup_headline: Mais liberdade para quem produz comida. - producers_signup_motivation: Comercialize seus produtos e conte sua história para um mercado novo e diferenciado. Economize tempo e dinheiro em comunicação e logística. - producers_signup_send: Cadastre-se agora + producers_signup_motivation: Comercialize seus produtos e conte a sua história num mercado novo e diferenciado. Economize tempo e dinheiro em comunicação e logística. + producers_signup_send: Registe-se agora producers_signup_enterprise: Contas da Organização - producers_signup_studies: Histórias de nossos produtores - producers_signup_cta_headline: Cadastre-se agora! - producers_signup_cta_action: Cadastre-se agora - producers_signup_detail: Aqui está o detalhe/ + producers_signup_studies: Histórias do nossos produtores + producers_signup_cta_headline: Registe-se agora! + producers_signup_cta_action: Registe-se agora + producers_signup_detail: Aqui está o detalhe. products_item: Ítem products_description: Descrição products_variant: Variante @@ -1345,46 +1350,46 @@ pt: products_available: Disponível? products_producer: "Produtor" products_price: "Preço" - register_title: Registro - sell_title: "\bRegistrar" - sell_headline: "Fazer parte da Open Food Network!" - sell_motivation: "Mostre seus produtos deliciosos." + register_title: Registo + sell_title: "\bRegistar" + sell_headline: "Junte-se à Open Food Network!" + sell_motivation: "Mostre os seus produtos deliciosos." sell_producers: "Produtores" sell_hubs: "Centrais" sell_groups: "Grupos" - sell_producers_detail: "Crie um perfil para seu negócio em apenas alguns minutos. A qualquer momento você poderá fazer se tornar um mercado online e vender seus produtos diretamente ao consumidor." + sell_producers_detail: "Crie um perfil para o seu negócio em apenas alguns minutos. A qualquer momento poderá passar o seu perfil a um mercado online e vender os seus produtos diretamente ao consumidor." sell_hubs_detail: "Crie um perfil para a sua organização na OFN. A qualquer momento poderá actualizar o seu perfil para passar a incluir vários outros produtores. " sell_groups_detail: "Configure uma lista personalizada de organizações (produtores, cooperativas, lojas, etc.) para a sua região ou organização. " - sell_user_guide: "Saiba mais acessando nosso guia. " + sell_user_guide: "Saiba mais através do nosso manual de utilizador. " sell_listing_price: "Criar um perfil na OFN não custa nada. Abrir e gerir um ponto de vendas na OFN também não. Organizar um grupo de consumo na OFN é gratuito." - sell_embed: "Você também pode embutir um mercado da OFN no seu próprio site, ou construir um site específico para a sua região. " - sell_ask_services: "Pergunte-nos sobre nossos serviços." + sell_embed: "Também podemos embutir um mercado da OFN no seu próprio site, ou construir um site específico para a sua região. " + sell_ask_services: "Pergunte-nos sobre os nossos serviços." shops_title: Lojas shops_headline: A feira, transformada - shops_text: 'A colheita é feita em ciclos, a comida é produzida em ciclos, e nós fazemos nossos pedidos em ciclos. Se você encontrar um ciclo de pedidos fechado, volte em breve para tentar novamente. ' - shops_signup_title: Registre-se como uma central - shops_signup_headline: 'Um mercado de alimentos sem tamanho. ' + shops_text: A comida é produzida em ciclos, a colheita é feita em ciclos e nós fazemos encomendas em ciclos. Se encontrar um ciclo de encomendas fechado, volte em breve para tentar novamente. + shops_signup_title: Registe-se como uma central + shops_signup_headline: 'Um mercado de alimentos, não corporativo. ' shops_signup_motivation: Seja qual for o seu modelo, nós ajudamos. Se houver mudanças, estamos consigo. Somos sem fins lucrativos, independentes, e open source. Somos os parceiros de software com os quais sonhou. shops_signup_action: Junte-se agora shops_signup_pricing: Contas da Organização shops_signup_stories: Histórias dos nossos membros. - shops_signup_help: Estamos prontos para ajudar + shops_signup_help: Estamos prontos para ajudar. shops_signup_help_text: Você está a precisar de melhor retorno, de novos clientes e parceiros de logística. Está a precisar que a sua história seja contada em grupos de consumo, em retalho e à mesa de jantar. shops_signup_detail: Aqui está o detalhe. orders: Encomendas orders_fees: Taxas... - orders_edit_title: Carrinho de compras + orders_edit_title: Carrinho de Compras orders_edit_headline: O seu carrinho de compras - orders_edit_time: Pedido pronto para + orders_edit_time: Encomenda pronta para orders_edit_continue: Continuar a comprar - orders_edit_checkout: Fechar pedido + orders_edit_checkout: Finalizar compra orders_form_empty_cart: "Carrinho vazio" orders_form_subtotal: Subtotal dos produtos orders_form_admin: Administração & Logística orders_form_total: Total - orders_oc_expired_headline: Este ciclo de encomendas está fechado para pedidos - orders_oc_expired_text: "Desculpe, este ciclo de encomendas fechou há %{time} atrás. Por favor entre em contacto directamente com a sua central para saber se podem aceitar pedidos tardios." - orders_oc_expired_text_others_html: "Desculpe, este ciclo de encomendas fechou há %{time} atrás. Por favor entre em contacto directamente com a sua central para saber se podem aceitar pedidos tardios %{link}." + orders_oc_expired_headline: Este ciclo de encomendas está fechado + orders_oc_expired_text: "Desculpe, este ciclo de encomendas fechou há %{time} atrás. Por favor entre em contacto directamente com a sua central para saber se podem aceitar encomendas tardias." + orders_oc_expired_text_others_html: "Desculpe, este ciclo de encomendas fechou há %{time} atrás. Por favor entre em contacto directamente com a sua central para saber se podem aceitar encomendas tardias %{link}." orders_oc_expired_text_link: "ou veja os outros ciclos de encomendas que estão disponíveis nesta central" orders_oc_expired_email: "Email:" orders_oc_expired_phone: "Telefone:" @@ -1402,46 +1407,46 @@ pt: orders_bought_edit_button: Editar itens confirmados orders_bought_already_confirmed: "* já confirmado" orders_confirm_cancel: Tem a certeza que quer cancelar esta encomenda? - products_cart_distributor_choice: "Distribuidor para seu pedido:" + products_cart_distributor_choice: "Distribuidor para a sua encomenda:" products_cart_distributor_change: "O distribuidor para esta encomenda será alterado para %{name} se adicionar este produto ao carrinho." - products_cart_distributor_is: "O distribuidor para este pedido é %{name}." - products_distributor_error: "Favor completar seu pedido no %{link} antes de comprar com outro distribuidor." - products_oc: "Ciclo de pedido para seu pedido:" - products_oc_change: "O ciclo de pedido para esse pedido será trocada para %{name} se você adicionar este produto ao carrinho." - products_oc_is: "O ciclo de pedido para este pedido é %{name}." - products_oc_error: "Favor completar seu pedido no %{link} antes de comprar em outro ciclo de pedido." + products_cart_distributor_is: "O distribuidor para esta encomenda é %{name}." + products_distributor_error: "Por favor complete a sua encomenda em %{link} antes de comprar com outro distribuidor." + products_oc: "Ciclo de encomendas para a sua encomenda:" + products_oc_change: "O ciclo de encomendas para esta encomenda será trocado para %{name} se adicionar este produto ao carrinho." + products_oc_is: "O ciclo de encomendas para esta encomenda é %{name}." + products_oc_error: "Por favor complete a sua encomenda em %{link} antes de comprar noutro ciclo de encomendas." products_oc_current: "o seu actual ciclo de encomendas" products_max_quantity: Quantidade máxima products_distributor: Distribuidor - products_distributor_info: Quando seleccionar um distribuidor para a sua encomenda, o endereço e data de levantamento serão exibidos aqui. + products_distributor_info: Quando seleccionar um distribuidor para a sua encomenda, a morada e data de levantamento serão exibidos aqui. products_distribution_adjustment_label: "Distribuição de produto por %{distributor} para %{product}." shop_trial_expires_in: "O período de avaliação do mercado termina em " - shop_trial_expired_notice: "Boa notícia! Decidimos extender o período avaliação do mercado até segunda ordem. " - password: Senha - remember_me: Lembre-me - are_you_sure: "Tem certeza?" + shop_trial_expired_notice: "Boa notícia! Decidimos extender o período avaliação do mercado. " + password: Palavra-passe + remember_me: Lembrar meu login + are_you_sure: "Tem a certeza?" orders_open: Encomendas abertas closing: "Fechando" going_back_to_home_page: "Voltando à pagina inicial" creating: Criando updating: Atualizando - failed_to_create_enterprise: "Falha ao criar a sua organização" + failed_to_create_enterprise: "Falha ao criar a sua organização." failed_to_create_enterprise_unknown: "Falha ao criar a sua organização. \nPor favor verifique se todos os campos foram preenchidos correctamente." failed_to_update_enterprise_unknown: "Falha ao actualizar a sua organização. \nPor favor verifique se todos os campos foram preenchidos correctamente." enterprise_confirm_delete_message: "Isto também vai apagar o %{product} que esta organização fornece. Tem a certeza que deseja continuar?" order_not_saved_yet: "A sua encomenda ainda não foi guardada. Só mais uns minutinhos para teminar! " filter_by: "Filtrar por" - hide_filters: "Esconder filtros" + hide_filters: "Ocultar filtros" one_filter_applied: "1 filtro aplicado" x_filters_applied: "filtros aplicados" - submitting_order: "A processar o seu pedido: favor aguarde" - confirm_hub_change: "Tem certeza? Isso irá mudar a central selecionada e remover todos os ítens do carrinho de compras." + submitting_order: "A processar a sua encomenda: por favor aguarde" + confirm_hub_change: "Tem a certeza? Isto mudará a central selecionada e removerá todos os ítens do seu carrinho de compras." confirm_oc_change: "Tem a certeza? Isto mudará o ciclo de encomendas seleccionado e removerá todos os itens do carrinho de compras." location_placeholder: "Digite uma localidade..." - error_required: "Não pode ser vazio" - error_number: "Precisa ser um número" - error_email: "Precisa ser um endereço de email" - error_not_found_in_database: "%{name} não foi encontrado na base de dados." + error_required: "não pode ser vazio" + error_number: "precisa ser um número" + error_email: "precisa ser um endereço de email" + error_not_found_in_database: "%{name} não foi encontrado na base de dados" error_no_permission_for_enterprise: "\"%{name}\": não tem permissão para gerir produtos desta organização" item_handling_fees: "Taxas de Manejo do Produto (incluídas no total do produto)" january: "Janeiro" @@ -1457,20 +1462,20 @@ pt: november: "Novembro" december: "Dezembro" email_not_found: "Endereço de email não encontrado" - email_unconfirmed: "Tem de confirmar o seu endereço de email antes de poder redefinir a sua password." - email_required: "Você precisa providenciar um endereço de email" + email_unconfirmed: "Tem de confirmar o seu endereço de email antes de poder redefinir a sua palavra-passe." + email_required: "Precisa definir um endereço de email" logging_in: "Fazendo o login, aguarde um momento" - signup_email: "Seu email" - choose_password: "Escolha uma senha" - confirm_password: "Confirme a senha" - action_signup: "Cadastre-se agora" + signup_email: "Email" + choose_password: "Escolha uma palavra-passe" + confirm_password: "Confirme a palavra-passe" + action_signup: "Registe-se agora" welcome_to_ofn: "Bem-vindo à Open Food Network!" - signup_or_login: "Faça seu cadastro ou login para começar" - have_an_account: "Já possui um conta?" - action_login: "Entrar agora" - forgot_password: "Esqueceu sua senha?" - password_reset_sent: "Um email foi enviado com instruções para resetar sua senha!" - reset_password: "Resetar password" + signup_or_login: "Comece por fazer login ou registar-se" + have_an_account: "Já possui uma conta?" + action_login: "Entrar agora." + forgot_password: "Esqueceu-se da sua palavra-passe?" + password_reset_sent: "Foi enviado um email com instruções para redefinir a sua palavra-passe!" + reset_password: "Redefinir palavra-passe" who_is_managing_enterprise: "Quem é responsável por gerir %{enterprise}? " update_and_recalculate_fees: "Actualizar e Recalcular Taxas" enterprise: @@ -1479,32 +1484,32 @@ pt: steps: details: title: 'Detalhes' - headline: "Vamos lá começar" + headline: "Vamos começar" enterprise: "Primeiro precisamos de saber um pouco sobre a sua organização:" producer: "Primeiro precisamos de saber um pouco sobre a sua quinta:" enterprise_name_field: "Nome da organização:" - producer_name_field: "Nome da quinta:" + producer_name_field: "Nome da Quinta:" producer_name_field_placeholder: "ex: Quinta da Liliana Espectacular" producer_name_field_error: "Por favor escolha um nome único para a sua organização" - address1_field: "Endereço (primeira linha):" + address1_field: "Morada linha 1:" address1_field_placeholder: "ex: Rua das Framboesas 123" - address1_field_error: "Por favor indique um endereço" - address2_field: "Endereço (segunda linha):" + address1_field_error: "Por favor indique uma morada" + address2_field: "Morada linha 2:" suburb_field: "Localidade:" - suburb_field_placeholder: "ex: Lagarteiro" + suburb_field_placeholder: "ex: Famalicão" suburb_field_error: "Por favor indique uma localidade" postcode_field: "Código postal:" - postcode_field_placeholder: "ex: 3070" + postcode_field_placeholder: "ex: 4000-125" postcode_field_error: "Código postal obrigatório" - state_field: "Região" - state_field_error: "Estado obrigatório" + state_field: "Região:" + state_field_error: "Campo obrigatório" country_field: "País:" country_field_error: "Por favor selecione um país" contact: title: 'Contacto' - contact_field: 'Contacto principal' + contact_field: 'Contacto Principal' contact_field_placeholder: 'Nome do contacto' - contact_field_required: "É obrigatório indicar um contacto principal" + contact_field_required: "É obrigatório indicar um contacto principal." email_field: 'Endereço de email' email_field_placeholder: 'ex: liliana@daquinta.com' phone_field: 'Número de telefone' @@ -1516,8 +1521,8 @@ pt: yes_producer: "Sim, sou produtor/a" no_producer: "Não, não sou produtor/a" producer_field_error: "Por favor escolha uma opção. É produtor/a?" - yes_producer_help: "Produtores/as são quem faz coisas deliciosas para comer e/ou beber. És produtor/a se plantas, crias, fermentas, amassas, munges ou moldas." - no_producer_help: "Se não és produtor/a, provavelmente és alguém que vende e distribui alimentos. Podes ser uma cooperativa, um grupo de consumo, um hub, do retalho, ou outros." + yes_producer_help: "Produtores/as são quem faz coisas deliciosas para comer e/ou beber. É produtor/a se planta, cria, fermenta, amassa, munge ou molda algo." + no_producer_help: "Se não é produtor/a, é provavelmente alguém que vende e distribui alimentos. Pode ser uma cooperativa, um grupo de consumo, um distribuidor, um retalhista, ou outro." about: title: 'Sobre' images: @@ -1526,8 +1531,8 @@ pt: title: 'Social' enterprise_contact: "Contacto Principal" enterprise_contact_placeholder: "Nome do contacto" - enterprise_contact_required: "É preciso adicionar um contacto principal" - enterprise_email_address: "Endereço de e-mail" + enterprise_contact_required: "É obrigatório indicar um contacto principal." + enterprise_email_address: "Endereço de email" enterprise_email_placeholder: "ex: liliana@daquinta.com" enterprise_phone: "Número de telefone" enterprise_phone_placeholder: "ex: 97 1234 5678" @@ -1538,103 +1543,103 @@ pt: limit_reached_text: "Chegou ao número limite de organizações que pode ter na " limit_reached_action: "Voltar à página principal" select_promo_image: "Passo 3. Selecionar Imagem Promocional" - promo_image_tip: "Tamanho preferencial: 1200x260px" + promo_image_tip: "Dica: mostrada como banner, o tamanho preferido é 1200x260px" promo_image_label: "Escolher uma imagem promocional" action_or: "OU" - promo_image_drag: "Arraste e solte sua imagem aqui" - review_promo_image: "Passo 4. Avalie Sua Imagem Promocional" - review_promo_image_tip: "Dica: para melhores resultados, sua imagem deve preencher o espaço disponível" - promo_image_placeholder: "Seu logo aparecerá aqui para avaliação assim que for carregado" + promo_image_drag: "Arraste e solte a sua imagem promocional aqui" + review_promo_image: "Passo 4. Reveja a sua Imagem Promocional" + review_promo_image_tip: "Dica: para melhores resultados, a sua imagem promocional deve preencher o espaço disponível" + promo_image_placeholder: "A sua imagem de perfil aparecerá aqui para revisão assim que for carregada" uploading: "Carregando..." select_logo: "Passo 1. Selecionar imagem de perfil" - logo_tip: "Dica: Imagens quadradas funcionam melhor, com mínimo de 300x300px" + logo_tip: "Dica: Imagens quadradas funcionam melhor, de preferência com pelo menos 300x300px" logo_label: "Escolha uma imagem de perfil" - logo_drag: "Arraste e solte sua imagem aqui" - review_logo: "Passo 2. Avalie sua imagem de perfil" - review_logo_tip: "Dica: para melhores resultados, sua imagem deve preencher o espaço disponível" - logo_placeholder: "Seu logo aparecerá aqui para avaliação assim que for carregado`" + logo_drag: "Arraste e solte a sua imagem de perfil aqui" + review_logo: "Passo 2. Reveja a sua imagem de perfil" + review_logo_tip: "Dica: para melhores resultados, a sua imagem deve preencher o espaço disponível" + logo_placeholder: "A sua imagem de perfil aparecerá aqui para revisão assim que for carregada" enterprise_about_headline: "Boa!" - enterprise_about_message: "Vamos inserir mais detalhes sobre" - enterprise_success: "Sucesso! %{enterprise} foi adicionada a Open Food Network" + enterprise_about_message: "Agora vamos inserir os detalhes sobre" + enterprise_success: "Sucesso! %{enterprise} foi adicionada à Open Food Network" enterprise_description: "Descrição Curta" enterprise_description_placeholder: "Uma frase curta que descreva a sua organização " - enterprise_long_desc: "Descrição completa" + enterprise_long_desc: "Descrição Longa" enterprise_long_desc_placeholder: "Esta é a oportunidade para contar a história da sua organização - o quê que a torna diferente e maravilhosa? Sugerimos um parágrafo com 600 caracteres ou 150 palavras, no máximo. " enterprise_long_desc_length: "%{num} caracteres / recomendamos até 600" enterprise_limit: Limite da Organização - enterprise_abn: "ABN" + enterprise_abn: "NIPC" enterprise_abn_placeholder: "ex: 99 123 456 789" - enterprise_acn: "ACN" + enterprise_acn: "NIF" enterprise_acn_placeholder: "ex: 123 456 789" - enterprise_tax_required: "Você precisa fazer uma seleção." + enterprise_tax_required: "É obrigatório fazer uma seleção." enterprise_final_step: "Último passo!" enterprise_social_text: "Como é que as pessoas podem encontrar a %{enterprise} online?" website: "Website" - website_placeholder: "eg. openfoodnetwork.com.br" + website_placeholder: "eg. openfoodnetwork.com" facebook: "Facebook" - facebook_placeholder: "ex. www.facebook.com/suapagina" + facebook_placeholder: "ex. www.facebook.com/asuapagina" linkedin: "LinkedIn" - linkedin_placeholder: "ex. www.linkedin.com/seunome" + linkedin_placeholder: "ex. www.linkedin.com/oseunome" twitter: "Twitter" twitter_placeholder: "ex. @conta_twitter" instagram: "Instagram" instagram_placeholder: "ex. @conta_instagram" registration_greeting: "Olá!" - registration_intro: "Você pode criar um perfil para seu Produtor ou Distribuidor" + registration_intro: "Agora pode criar um perfil para seu Produtor ou Distribuidor" registration_action: "Vamos começar!" - registration_checklist: "Você vai precisar" + registration_checklist: "Vai precisar de" registration_time: "5-10 minutos" registration_enterprise_address: "Morada da organização" - registration_contact_details: "Informações para contato" - registration_logo: "Sua imagem de perfil" - registration_promo_image: "Imagem horizontal para seu perfil" - registration_about_us: "'Sobre Nós'" - registration_outcome_headline: "O que eu ganho?" - registration_outcome1_html: "Seu perfil ajuda as pessoas a te encontrarem e entrarem em contato com você na Open Food Network" + registration_contact_details: "Informações para contacto" + registration_logo: "Imagem de perfil" + registration_promo_image: "Imagem horizontal para o seu perfil" + registration_about_us: "Texto 'Sobre Nós'" + registration_outcome_headline: "O que ganho?" + registration_outcome1_html: "O seu perfil ajuda as pessoas a o encontrarem e entrarem em contacto consigo na Open Food Network" registration_outcome2: "Use este espaço para contar a história da sua organização, de forma a gerar ligações à sua presença social e online. " - registration_outcome3: "Esse é também o primeiro passo para começar a comercializar na Open Food Network, ou abrir uma loja online" - registration_finished_headline: "Pronto!" + registration_outcome3: "É também o primeiro passo para comercializar na Open Food Network, ou abrir uma loja online." + registration_finished_headline: "Terminado!" registration_finished_thanks: "Obrigado por preencher os detalhes de %{enterprise}." - registration_finished_login: "Pode alterar ou actualizar as informações da sua organização a qualquer momento fazendo login na Open Food Network e entrando na secção Admin." + registration_finished_login: "Pode alterar ou actualizar as informações da sua organização a qualquer momento fazendo login na Open Food Network e entrando na secção de Administração." registration_finished_action: "Página principal" registration_contact_name: 'Nome do contacto' registration_type_headline: "Último passo para adicionar %{enterprise}!" - registration_type_question: "Você é um produtor?" - registration_type_producer: "Sim, sou um produtor" - registration_type_no_producer: "Não, não sou um produtor" - registration_type_error: "Favor escolher um. Você é um produtor?" - registration_type_producer_help: "Produtores fazem coisas deliciosas de comer e beber. " - registration_type_no_producer_help: "Se você não é um produtor, você provavelmente é alguém que vende e distribui comida. Você pode ser uma central, cooperativa, grupo de compras, lojista, etc." - create_profile: "Crir perfil" + registration_type_question: "É produtor/a?" + registration_type_producer: "Sim, sou produtor/a" + registration_type_no_producer: "Não, não sou produtor/a" + registration_type_error: "Por favor escolha uma opção. É produtor/a?" + registration_type_producer_help: "Produtores/as são quem faz coisas deliciosas para comer e/ou beber. É produtor/a se planta, cria, fermenta, amassa, munge ou molda algo." + registration_type_no_producer_help: "Se não é produtor/a, é provavelmente alguém que vende e distribui alimentos. Pode ser uma cooperativa, um grupo de consumo, um distribuidor, um retalhista, ou outro." + create_profile: "Criar perfil" registration_images_headline: "Obrigado!" - registration_images_description: "Vamos adicionar umas belas imagens para seu perfil ficar lindão!" + registration_images_description: "Vamos adicionar umas boas imagens para o seu perfil ficar impecável!" registration_detail_headline: "Vamos Começar" registration_detail_enterprise: "Primeiro precisamos saber mais sobre a sua organização:" registration_detail_producer: "Primeiro precisamos saber mais sobre sua produção:" registration_detail_name_enterprise: "Nome da Organização:" - registration_detail_name_producer: "Nome da Produção" - registration_detail_name_placeholder: "ex. Fazenda da Nina" + registration_detail_name_producer: "Nome da Quinta" + registration_detail_name_placeholder: "ex: Quinta da Liliana Espectacular" registration_detail_name_error: "Escolha um nome único para a sua organização" - registration_detail_address1: "Linha de endereço 1:" - registration_detail_address1_placeholder: "ex. Rua Mármore, 123" - registration_detail_address1_error: "Favor inserir um endereço" - registration_detail_address2: "Linha de endereço 2:" - registration_detail_suburb: "Localidade" - registration_detail_suburb_placeholder: "ex. Contagem" - registration_detail_suburb_error: "Favor inserir uma localidade" + registration_detail_address1: "Morada linha 1:" + registration_detail_address1_placeholder: "ex: Rua das Framboesas 123" + registration_detail_address1_error: "Por favor indique uma morada" + registration_detail_address2: "Morada linha 2:" + registration_detail_suburb: "Localidade:" + registration_detail_suburb_placeholder: "ex. Famalicão" + registration_detail_suburb_error: "Por favor indique uma localidade" registration_detail_postcode: "Código postal" - registration_detail_postcode_placeholder: "ex. 3070" - registration_detail_postcode_error: "Código postal requisitado" - registration_detail_state: "Estado" - registration_detail_state_error: "É obrigatório inserir o Estado" - registration_detail_country: "País" - registration_detail_country_error: "Favor inserir um país" + registration_detail_postcode_placeholder: "ex: 4000-125" + registration_detail_postcode_error: "Código postal obrigatório" + registration_detail_state: "Região:" + registration_detail_state_error: "Campo obrigatório" + registration_detail_country: "País:" + registration_detail_country_error: "Por favor selecione um país" shipping_method_destroy_error: "Este método de envio não pode ser apagado porque está referenciado por uma encomenda: %{number}." accounts_and_billing_task_already_running_error: "Já está a decorrer uma tarefa, por favor aguarde que esteja terminada" - accounts_and_billing_start_task_notice: "Tarefa em fila de espera" + accounts_and_billing_start_task_notice: "Tarefa colocada em fila" fees: "Taxas" item_cost: "Custo da unidade" - bulk: "Atacado" + bulk: "Por Atacado" shop_variant_quantity_min: "mín." shop_variant_quantity_max: "max." follow: "Seguir" @@ -1642,61 +1647,61 @@ pt: change_shop: "Mudar loja para:" shop_at: "Compre agora em:" price_breakdown: "Preço detalhado" - admin_fee: "Taxa de manejo" + admin_fee: "Taxa de administração" sales_fee: "Taxa de venda" packing_fee: "Taxa de embalagem" transport_fee: "Taxa de transporte" - fundraising_fee: "Taxa de poupança" + fundraising_fee: "Taxa de financiamento" price_graph: "Gráfico de preços" included_tax: "Taxas incluídas" - balance: "Balanço" + balance: "Saldo" transaction: "Transação" - transaction_date: "DataData" - payment_state: "Status do Pagamento" - shipping_state: "Status da entrega" + transaction_date: "Data" + payment_state: "Estado do Pagamento" + shipping_state: "Estado do envio" value: "Valor" - balance_due: "saldo devedor" + balance_due: "Saldo pendente" credit: "Crédito" Paid: "Pago" Ready: "Pronto" ok: OK not_visible: não visível you_have_no_orders_yet: "Ainda não tem encomendas" - running_balance: "Balanço corrente" - outstanding_balance: "Saldo devedor" + running_balance: "Saldo corrente" + outstanding_balance: "Saldo pendente" admin_entreprise_relationships: "Relações da Organização" admin_entreprise_relationships_everything: "Tudo" admin_entreprise_relationships_permits: "permite" - admin_entreprise_relationships_seach_placeholder: "Busca" + admin_entreprise_relationships_seach_placeholder: "Procurar" admin_entreprise_relationships_button_create: "Criar" admin_entreprise_groups: "Grupos da Organização" admin_entreprise_groups_name: "Nome" - admin_entreprise_groups_owner: "Dono" + admin_entreprise_groups_owner: "Proprietário" admin_entreprise_groups_on_front_page: "Na página inicial?" admin_entreprise_groups_entreprise: "Organizações" - admin_entreprise_groups_data_powertip: "Usuário responsável pelo grupo" + admin_entreprise_groups_data_powertip: "O utilizador responsável por este grupo." admin_entreprise_groups_data_powertip_logo: "Esse é o logo do grupo" - admin_entreprise_groups_data_powertip_promo_image: "Essa é a imagem que aparecerá no topo do perfil do Grupo" - admin_entreprise_groups_contact: "Contato" + admin_entreprise_groups_data_powertip_promo_image: "Esta imagem aparecerá no topo do perfil do Grupo" + admin_entreprise_groups_contact: "Contacto" admin_entreprise_groups_contact_phone_placeholder: "ex: 987654321" admin_entreprise_groups_contact_address1_placeholder: "ex: Rua Alta, 123" admin_entreprise_groups_contact_city: "Localidade" - admin_entreprise_groups_contact_city_placeholder: "ex. Contagem" + admin_entreprise_groups_contact_city_placeholder: "ex. Famalicão" admin_entreprise_groups_contact_zipcode: "Código Postal " - admin_entreprise_groups_contact_zipcode_placeholder: "ex. 3078" - admin_entreprise_groups_contact_state_id: "Estadi" + admin_entreprise_groups_contact_zipcode_placeholder: "ex: 4000-125" + admin_entreprise_groups_contact_state_id: "Região" admin_entreprise_groups_contact_country_id: "País" admin_entreprise_groups_web: "Recursos Web" admin_entreprise_groups_web_twitter: "ex. @nome_perfil" - admin_entreprise_groups_web_website_placeholder: "ex. www.cogumelos.com.br" + admin_entreprise_groups_web_website_placeholder: "ex. www.cogumelos.pt" admin_order_cycles: "Ciclos de Encomendas do Administrador" open: "Aberto" close: "Fechado" create: "Criar" search: "Procurar" supplier: "Fornecedor" - product_name: "Nome do produto" - product_description: "Descrição do produto" + product_name: "Nome do Produto" + product_description: "Descrição do Produto" units: "Tamanho unitário" coordinator: "Coordenador" distributor: "Distribuidor" @@ -1705,7 +1710,7 @@ pt: delivery_instructions: Instruções de Entrega delivery_method: Método de Entrega fee_type: "Tipo de Taxa" - tax_category: "Categoria de taxa" + tax_category: "Categoria de Imposto" calculator: "Calculadora" calculator_values: "Valores da calculadora" flat_percent_per_item: "Percentual (por unidade)" @@ -1713,9 +1718,9 @@ pt: flat_rate_per_order: "Taxa fixa (por encomenda)" flexible_rate: "Taxa flexível" price_sack: "Saco de Preços" - new_order_cycles: "Novos Ciclo de Encomendas" - new_order_cycle: "Novo ciclo de encomendas" - select_a_coordinator_for_your_order_cycle: "Escolher um coordenador para o seu ciclo de encomendas" + new_order_cycles: "Novos Ciclos de Encomendas" + new_order_cycle: "Novo Ciclo de Encomendas" + select_a_coordinator_for_your_order_cycle: "Escolha um coordenador para o seu ciclo de encomendas" notify_producers: 'Notificar produtores' edit_order_cycle: "Editar Ciclo de Encomendas" roles: "Papeis" @@ -1724,48 +1729,43 @@ pt: add_producer_property: "Adicionar produtor" in_progress: "Em andamento" started_at: "Começou em " - queued: "Aguardando" + queued: "Em fila" scheduled_for: "Agendado para" - customers: "Clientes" - please_select_hub: "Favor selecionar uma Central" - loading_customers: "Carregando Clientes" - no_customers_found: "Nenhum cliente encontrado" + customers: "Consumidores/as" + please_select_hub: "Por favor selecione uma Central" + loading_customers: "Carregando Consumidores" + no_customers_found: "Nenhum consumidor encontrado" go: "Ir" hub: "Central" producer: "Produtor" product: "Produto" price: "Preço" on_hand: "Disponível" - save_changes: "Salvar Modificações" - order_saved: "Pedido guardado" + save_changes: "Guardar Modificações" + order_saved: "Encomenda Guardada" no_products: Sem Produtos spree_admin_overview_enterprises_header: "As minhas Organizações" spree_admin_overview_enterprises_footer: "GERIR AS MINHAS ORGANIZAÇÕES" spree_admin_enterprises_hubs_name: "Nome" spree_admin_enterprises_create_new: "CRIAR NOVA" - spree_admin_enterprises_shipping_methods: "Métodos de Entrega" + spree_admin_enterprises_shipping_methods: "Métodos de Envio" spree_admin_enterprises_fees: "Taxas da Organização" spree_admin_enterprises_none_create_a_new_enterprise: "CRIAR UMA NOVA ORGANIZAÇÃO" spree_admin_enterprises_none_text: "Ainda não tem nenhuma organização" - spree_admin_enterprises_producers_name: "Nome" - spree_admin_enterprises_producers_total_products: "Total de Produtos" - spree_admin_enterprises_producers_active_products: "Produtos Ativos" - spree_admin_enterprises_producers_order_cycles: "Produtos em ciclos de pedidos" spree_admin_enterprises_tabs_hubs: "CENTRAIS" - spree_admin_enterprises_tabs_producers: "PRODUTORES" - spree_admin_enterprises_producers_manage_products: "GERENCIAR PRODUTOS" - spree_admin_enterprises_any_active_products_text: "Você ainda não tem nenhum produto ativo." + spree_admin_enterprises_producers_manage_products: "GERIR PRODUTOS" + spree_admin_enterprises_any_active_products_text: "Não tem nenhum produto ativo." spree_admin_enterprises_create_new_product: "CRIAR UM NOVO PRODUTO" - spree_admin_single_enterprise_alert_mail_confirmation: "Favor confirmar o endereço de email para" - spree_admin_single_enterprise_alert_mail_sent: "Enviamos um e-mail para" - spree_admin_overview_action_required: "Medida Solicitada" - spree_admin_overview_check_your_inbox: "Por favor, cheque sua caixa de entrada para obter mais instruções. Obrigada!" + spree_admin_single_enterprise_alert_mail_confirmation: "Por favor confirme o endereço de email para" + spree_admin_single_enterprise_alert_mail_sent: "Enviamos um email para" + spree_admin_overview_action_required: "Ação Requerida" + spree_admin_overview_check_your_inbox: "Por favor, verifique a sua caixa de correio para mais instruções. Obrigada!" spree_admin_unit_value: Valor unitário spree_admin_unit_description: Descrição Unitária spree_admin_variant_unit: Unidade variante spree_admin_variant_unit_scale: Escala de unidade variante spree_admin_supplier: Fornecedor - spree_admin_product_category: Categoria de produto + spree_admin_product_category: Categoria de Produto spree_admin_variant_unit_name: Nome de unidade variante change_package: "Modificar Embalagem" spree_admin_single_enterprise_hint: "Dica: Para permitir que as pessoas te encontrem, ative sua visibilidade em" @@ -1775,55 +1775,55 @@ pt: spree_order_availability_error: "O distribuidor ou ciclo de encomendas não pode fornecer os produtos do seu carrinho" spree_order_populator_error: "Esse distribuidor ou ciclo de encomendas não pode fornecer todos os produtos do seu carrinho. Por favor escolha outro." spree_order_populator_availability_error: "Esse produto não está disponível no distribuidor ou ciclo de encomendas selecionado." - spree_distributors_error: "Tem de selecionar pelo menos um Hub." + spree_distributors_error: "Tem de selecionar pelo menos uma Central" spree_user_enterprise_limit_error: "^%{email} não tem autorização para ter mais organizações (o limite é %{enterprise_limit})." spree_variant_product_error: tem de ter pelo menos uma variante - your_profil_live: "Seu perfil online" - on_ofn_map: "O mapa da Open Food Network" + your_profil_live: "O seu perfil online" + on_ofn_map: "no mapa da Open Food Network" see: "Ver" live: "online" - manage: "Gerenciar" - resend: "Re-enviar" + manage: "Gerir" + resend: "Reenviar" trial: Experiência - add_and_manage_products: "Adicionar e gerenciar produtos" + add_and_manage_products: "Adicionar e gerir produtos" add_and_manage_order_cycles: "Adicionar e gerir ciclos de encomendas" manage_order_cycles: "Gerir ciclos de encomendas" - manage_products: "Gerenciar produtos" + manage_products: "Gerir produtos" edit_profile_details: "Editar detalhes de perfil " - edit_profile_details_etc: "Modificar a descrição dos seu perfil, imagem, etc." - order_cycle: "Ciclo de Pedidos" - order_cycles: "Ciclos de pedidos" + edit_profile_details_etc: "Modificar o seu perfil: descrição, imagem, etc." + order_cycle: "Ciclo de Encomendas" + order_cycles: "Ciclos de Encomendas" enterprises: "Organizações" enterprise_relationships: "Relações da Organização" - remove_tax: "Remover taxa" + remove_tax: "Remover imposto" enterprise_terms_of_service: "Termos de Serviço da Organização" enterprises_require_tos: "As organizações têm de aceitar os Termos de Serviço" enterprise_tos_link: "Ligação para Termos de Serviço da Organização" enterprise_tos_message: "Queremos trabalhar com pessoas que partilham os nossos objectivos e valores. Por isso pedimos às organizações novas que concordem com os nossos" enterprise_tos_link_text: "Termos de Serviço." enterprise_tos_agree: "Concordo com os Termos e Serviço acima" - tax_settings: "Configurações de Taxas" - products_require_tax_category: "produtos necessitam uma categoria de taxa" - admin_shared_address_1: "Endereço" - admin_shared_address_2: "Endereço (cont.)" + tax_settings: "Configurações de Impostos" + products_require_tax_category: "produtos necessitam uma categoria de imposto" + admin_shared_address_1: "Morada" + admin_shared_address_2: "Morada (cont.)" admin_share_city: "Cidade" admin_share_zipcode: "Código Postal " admin_share_country: "País" - admin_share_state: "Estadi" + admin_share_state: "Região" hub_sidebar_hubs: "Centrais" hub_sidebar_none_available: "Nada Disponível" hub_sidebar_manage: "Gerir" - hub_sidebar_at_least: "Ao menos uma central deve ser selecionada" + hub_sidebar_at_least: "Tem de selecionar pelo menos uma central" hub_sidebar_blue: "azul" hub_sidebar_red: "vermelho" shop_trial_in_progress: "O período de avaliação do mercado termina em %{days}." report_customers_distributor: "Distribuidor" report_customers_supplier: "Fornecedor" - report_customers_cycle: "Ciclo de Pedidos" - report_customers_type: "Relatar Tipo" - report_customers_csv: "Fazer download como csv" + report_customers_cycle: "Ciclo de Encomendas" + report_customers_type: "Tipo de Relatório" + report_customers_csv: "Descarregar como csv" report_producers: "Produtores:" - report_type: "Relatar Tipo:" + report_type: "Tipo de Relatório" report_hubs: "Centrais:" report_payment: "Métodos de Pagamento" report_distributor: "Distribuidor:" @@ -1831,12 +1831,12 @@ pt: report_itemised_payment: 'Totais dos Pagamentos Discriminados' report_payment_totals: 'Totais dos Pagamanetos' report_all: 'todos' - report_order_cycle: "Ciclo de Pedidos:" + report_order_cycle: "Ciclo de Encomendas:" report_entreprises: "Organizações:" - report_users: "Usuários:" + report_users: "Utilizadores:" report_tax_rates: Taxas de imposto - report_tax_types: Tipos e imposto - report_header_order_cycle: Ciclo de encomendas + report_tax_types: Tipos de imposto + report_header_order_cycle: Ciclo de Encomendas report_header_user: Utilizador report_header_email: Email report_header_status: Status @@ -1846,13 +1846,13 @@ pt: report_header_phone: Telefone report_header_suburb: Localidade report_header_address: Morada - report_header_billing_address: Morada de facturação + report_header_billing_address: Morada de faturação report_header_relationship: Relação report_header_hub: Hub - report_header_hub_address: Morada do Hub - report_header_to_hub: Para o Hub - report_header_hub_code: Código do Hub - report_header_code: CódigoCódigo + report_header_hub_address: Morada da Central + report_header_to_hub: Para a Central + report_header_hub_code: Código da Central + report_header_code: Código report_header_paid: Pago? report_header_delivery: Entrega? report_header_shipping: Envio @@ -1862,35 +1862,35 @@ pt: report_header_ship_street_2: Rua de Envio 2 report_header_ship_city: Cidade de Envio report_header_ship_postcode: Código postal de Envio - report_header_ship_state: Estado de Envio + report_header_ship_state: Região de Envio report_header_billing_street: Rua de Facturação report_header_billing_street_2: Rua de Facturação 2 report_header_billing_street_3: Rua de Facturação 3 report_header_billing_street_4: Rua de Facturação 4 report_header_billing_city: Cidade de Facturação - report_header_billing_postcode: Código postal de facturação - report_header_billing_state: Estado de Facturação + report_header_billing_postcode: Código Postal de Facturação + report_header_billing_state: Região de Facturação report_header_incoming_transport: Transporte vindouro - report_header_special_instructions: Instruções especiais - report_header_order_number: Número de encomenda + report_header_special_instructions: Instruções Especiais + report_header_order_number: Número da encomenda report_header_date: Data - report_header_confirmation_date: Data de confirmação + report_header_confirmation_date: Data de Confirmação report_header_tags: Etiquetas report_header_items: Itens report_header_items_total: "Total de itens %{currency_symbol}" - report_header_taxable_items_total: "Total de itens taxáveis (%{currency_symbol})" - report_header_sales_tax: "Taxa de Vendas (%{currency_symbol})" - report_header_delivery_charge: "Cobrança de entrega (%{currency_symbol})" - report_header_tax_on_delivery: "Taxa sobre a entrega (%{currency_symbol})" - report_header_tax_on_fees: "Taxa sobre tarifas (%{currency_symbol})" - report_header_total_tax: "Taxa total (%{currency_symbol})" + report_header_taxable_items_total: "Total de itens tributáveis (%{currency_symbol})" + report_header_sales_tax: "Imposto sobre Vendas (%{currency_symbol})" + report_header_delivery_charge: "Taxa de Entrega (%{currency_symbol})" + report_header_tax_on_delivery: "Imposto sobre a Entrega (%{currency_symbol})" + report_header_tax_on_fees: "Imposto sobre honorários (%{currency_symbol})" + report_header_total_tax: "Total de Impostos (%{currency_symbol})" report_header_enterprise: Organização - report_header_customer: Cliente - report_header_customer_code: Código de Cliente + report_header_customer: Consumidor + report_header_customer_code: Código de Consumidor report_header_product: Produto report_header_product_properties: Propriedades do Produto report_header_quantity: Quantidade - report_header_max_quantity: Quantidade máxima + report_header_max_quantity: Quantidade Máxima report_header_variant: Variante report_header_variant_value: Valor da Variante report_header_variant_unit: Unidade da Variante @@ -1904,104 +1904,104 @@ pt: report_header_unit: Unidade report_header_group_buy_unit_quantity: Quantidade unitária da Compra em Grupo report_header_cost: Custo - report_header_shipping_cost: Custo de envio + report_header_shipping_cost: Custo de Envio report_header_curr_cost_per_unit: Custo por Unidade Act. - report_header_total_shipping_cost: Custo total de envio + report_header_total_shipping_cost: Custo Total de Envio report_header_payment_method: Método de Pagamento report_header_sells: Vende report_header_visible: Visível report_header_price: Preço report_header_unit_size: Tamanho unitário report_header_distributor: Distribuidor - report_header_distributor_address: Endereço do distribuidor + report_header_distributor_address: Morada do distribuidor report_header_distributor_city: Cidade do distribuidor report_header_distributor_postcode: Código postal do distribuidor - report_header_delivery_address: Endereço de entrega - report_header_delivery_postcode: Código postal da entrega - report_header_bulk_unit_size: Tamanho unitário por atacado + report_header_delivery_address: Morada de Entrega + report_header_delivery_postcode: Código Postal da Entrega + report_header_bulk_unit_size: Tamanho Unitário por Atacado report_header_weight: Peso - report_header_sum_total: Soma total - report_header_date_of_order: Data de encomenda + report_header_sum_total: Soma Total + report_header_date_of_order: Data da encomenda report_header_amount_owing: Quantia em dívida - report_header_amount_paid: Quantia paga - report_header_units_required: Unidades requisitadas + report_header_amount_paid: Quantia Paga + report_header_units_required: Unidades Requisitadas report_header_remainder: Restante - report_header_order_date: Data de encomenda - report_header_order_id: ID de encomenda + report_header_order_date: Data da encomenda + report_header_order_id: Id de Encomenda report_header_item_name: Nome do item - report_header_temp_controlled_items: Itens de temperatura controlada? - report_header_customer_name: Nome do cliente - report_header_customer_email: Email do cliente - report_header_customer_phone: Telefone do cliente - report_header_customer_city: Cidade do cliente - report_header_payment_state: Estado do pagamento - report_header_payment_type: Tipo de pagamento + report_header_temp_controlled_items: Itens de Temperatura Controlada? + report_header_customer_name: Nome do Consumidor + report_header_customer_email: Email do Consumidor + report_header_customer_phone: Telefone do Consumidor + report_header_customer_city: Cidade do Consumidor + report_header_payment_state: Estado do Pagamento + report_header_payment_type: Tipo de Pagamento report_header_item_price: "Item (%{currency})" report_header_item_fees_price: "Item + Taxas (%{currency})" report_header_admin_handling_fees: "Administração & Handling (%{currency})" report_header_ship_price: "Enviar (%{currency})" report_header_pay_fee_price: "Pagar taxa (%{currency})" report_header_total_price: "Total (%{currency})" - report_header_product_total_price: "Total do produto (%{currency})" - report_header_shipping_total_price: "Total do envio (%{currency})" + report_header_product_total_price: "Total do Produto (%{currency})" + report_header_shipping_total_price: "Total do Envio (%{currency})" report_header_outstanding_balance_price: "Saldo pendente (%{currency})" report_header_eft_price: "EFT (%{currency})" report_header_paypal_price: "Paypal (%{currency})" report_header_sku: SKU report_header_amount: Quantia report_header_balance: Saldo - report_header_total_cost: "Custo total" - report_header_total_ordered: Total encomendado + report_header_total_cost: "Custo Total" + report_header_total_ordered: Total Encomendado report_header_total_max: Máx. Total report_header_total_units: Unidades Totais - report_header_sum_max_total: "Somar Máximo Total" + report_header_sum_max_total: "Soma do Máximo Total" report_header_total_excl_vat: "Total excl. impostos (%{currency_symbol})" report_header_total_incl_vat: "Total incl. impostos (%{currency_symbol})" report_header_temp_controlled: TempControlada? report_header_is_producer: Produtor/a? - report_header_not_confirmed: Não confirmado - report_header_gst_on_income: GST sobre rendimentos - report_header_gst_free_income: Rendimentos Livres de GST - report_header_total_untaxable_produce: Total de produtos não taxáveis (sem impostos) - report_header_total_taxable_produce: Total de produtos taxáveis (inclui impostos) - report_header_total_untaxable_fees: Total de taxas não taxáveis (sem impostos) - report_header_total_taxable_fees: Total de taxas taxáveis (com impostos) + report_header_not_confirmed: Não Confirmado + report_header_gst_on_income: IVA sobre rendimentos + report_header_gst_free_income: Rendimentos Livres de IVA + report_header_total_untaxable_produce: Total não tributável de produtos (sem impostos) + report_header_total_taxable_produce: Total tributável de produtos (inclui impostos) + report_header_total_untaxable_fees: Total não tributável de taxas (sem impostos) + report_header_total_taxable_fees: Total tributável de taxas (com impostos) report_header_delivery_shipping_cost: Custo de Envio e Entrega (inclui impostos) report_header_transaction_fee: Taxa de transacção (sem impostos) - report_header_total_untaxable_admin: Total de ajustamentos de administração não taxáveis (sem impostos) - report_header_total_taxable_admin: Total de ajustamentos de administração taxáveis (inclui impostos) - initial_invoice_number: "Número de factura inicial:" - invoice_date: "Data de factura:" - due_date: "Data limite:" + report_header_total_untaxable_admin: Total de ajustes de administração não taxáveis (sem impostos) + report_header_total_taxable_admin: Total de ajustes de administração taxáveis (inclui impostos) + initial_invoice_number: "Número da factura inicial:" + invoice_date: "Data da factura:" + due_date: "Data de vencimento:" account_code: "Código de conta:" - equals: "Igual a:" - contains: "contém:" + equals: "Igual a" + contains: "contém" discount: "Desconto" filter_products: "Filtrar Produtos" - delete_product_variant: "A última variante não pode ser deletada!" + delete_product_variant: "A última variante não pode ser apagada!" progress: "progresso" - saving: "Salvando.." - success: "Sucesso" + saving: "A guardar.." + success: "sucesso" failure: "falha" - unsaved_changes_confirmation: "Modificações não salvas serão perdidas. Continuar mesmo assim?" - one_product_unsaved: "Modificações para um produto permanecem não salvas." - products_unsaved: "Modificações para %{n} produtos permanecem não salvas." + unsaved_changes_confirmation: "Modificações não guardadas serão perdidas. Continuar mesmo assim?" + one_product_unsaved: "Modificações para um produto permanecem não guardadas." + products_unsaved: "Modificações para %{n} produtos permanecem não guardadas." is_already_manager: "já é um gestor!" - no_change_to_save: "Nenhuma modificação a ser salva" + no_change_to_save: "Nenhuma modificação a ser guardada" user_invited: "%{email} foi convidado/a para gerir esta organização" add_manager: "Adicionar um/a utilizador/a existente" - users: "Usuários" + users: "Utilizadores" about: "Sobre" images: "Imagens" web: "Web" - primary_details: "Detalhes principais" - adrdress: "Endereço" - contact: "Contato" + primary_details: "Detalhes Principais" + adrdress: "Morada" + contact: "Contacto" social: "Social" - business_details: "Detalhes do negócio" + business_details: "Detalhes do Negócio" properties: "Propriedades" shipping: "Envio" - shipping_methods: "Métodos de Entrega" + shipping_methods: "Métodos de Envio" payment_methods: "Métodos de Pagamento" payment_method_fee: "Taxa de transação" inventory_settings: "Configurações de Inventário" @@ -2012,12 +2012,12 @@ pt: validation_msg_relationship_already_established: "^Essa relação já foi estabelecida." validation_msg_at_least_one_hub: "^Pelo menos uma central deve ser selecionada" validation_msg_product_category_cant_be_blank: "^A Categoria do Produto deve ser preenchida" - validation_msg_tax_category_cant_be_blank: "^A Categoria da taxa deve ser preenchida" - validation_msg_is_associated_with_an_exising_customer: "está associado com um cliente existente" + validation_msg_tax_category_cant_be_blank: "^A Categoria de Imposto deve ser preenchida" + validation_msg_is_associated_with_an_exising_customer: "está associado com um consumidor existente" content_configuration_pricing_table: "(AFAZER: Tabela de preços)" content_configuration_case_studies: "(AFAZER: Casos de estudo)" content_configuration_detail: "(AFAZER: Detalhe)" - enterprise_name_error: "já está a ser usado. Se essa for a sua organização e quiser reclamar propriedade, por favor contacte o actual gestor de perfil em %{email}." + enterprise_name_error: "já está tomado. Se esta organização for sua e quiser solicitar direito de propriedade, ou se quiser estabelecer uma colaboração com esta organização, por favor contacte quem actualmente gere o perfil: %{email}." enterprise_owner_error: "^%{email} não tem autorização para ter mais organizações (o limite é %{enterprise_limit})." enterprise_role_uniqueness_error: "^Essa função já está presente." inventory_item_visibility_error: tem de ser verdadeiro ou falso @@ -2026,17 +2026,17 @@ pt: product_importer_products_save_error: não gravou nenhum produto com sucesso product_import_file_not_found_notice: 'O ficheiro não foi encontrado ou não pôde ser aberto' product_import_no_data_in_spreadsheet_notice: 'Não foram encontrados dados na tabela' - order_choosing_hub_notice: O seu hub foi selecionado + order_choosing_hub_notice: A sua central foi selecionada order_cycle_selecting_notice: O seu ciclo de encomendas foi selecionado. - adjustments_tax_rate_error: "^Por favor verifique se a taxa de impostos para este ajustamento está correcta." + adjustments_tax_rate_error: "^Por favor verifique se a taxa de impostos para este ajuste está correcta." active_distributors_not_ready_for_checkout_message_singular: >- O hub %{distributor_names} está listado num ciclo de encomendas activo, mas não tem métodos de envio e pagamento válidos. Enquanto isto não for definido, - os clientes não conseguirão fazer compras neste hub. + os consumidores não conseguirão fazer compras neste hub. active_distributors_not_ready_for_checkout_message_plural: >- Os hubs %{distributor_names} estão listados num ciclo de encomendas activo, mas não tem métodos de envio e pagamento válidos. Enquanto isto não for definido, - os clientes não conseguirão fazer compras nestes hubs. + os consumidores não conseguirão fazer compras nestes hubs. enterprise_fees_update_notice: As suas taxas de organização foram actualizadas. enterprise_fees_destroy_error: "Essa taxa de organização não pode ser apagada porque está referenciada numa distribuição de produto: %{id} - %{name}." enterprise_register_package_error: "Por favor selecione um pacote" @@ -2048,17 +2048,17 @@ pt: order_cycles_update_notice: 'O seu ciclo de encomendas foi actualizado.' order_cycles_bulk_update_notice: 'Os ciclos de encomendas foram actualizados.' order_cycles_clone_notice: "O seu ciclo de encomendas %{name} foi clonado." - order_cycles_email_to_producers_notice: 'Os email a enviar aos produtores foram postos na fila de espera para envio.' + order_cycles_email_to_producers_notice: 'Os email a enviar aos produtores foram postos na fila para envio.' order_cycles_no_permission_to_coordinate_error: "Nenhuma das suas organizações tem permissão para coordenar um ciclo de encomendas." order_cycles_no_permission_to_create_error: "Não tem permissão para criar um ciclo de encomendas coordenado por essa organização." back_to_orders_list: "Voltar à lista de encomendas" order_information: "Informação da Encomenda" - date_completed: "Data Concluído" + date_completed: "Data de Conclusão" amount: "Quantia" state_names: ready: Pronta pending: Pendente - shipped: Enviada + shipped: Enviado js: saving: 'A guardar....' changes_saved: 'Alterações guardadas.' @@ -2071,7 +2071,7 @@ pt: error: Erro unavailable: Indisponível profile: Perfil - hub: Hub + hub: Central shop: Loja choose: Escolha resolve_errors: Por favor resolva os seguintes erros @@ -2105,7 +2105,7 @@ pt: saved: GUARDADO saving: A GUARDAR enterprise_package: - hub_profile: Perfil de Hub + hub_profile: Perfil de Central hub_profile_cost: "CUSTO: SEMPRE GRATUITO" hub_profile_text1: > As pessoas podem encontrá-lo e contactá-lo através da Open Food Network. @@ -2113,7 +2113,7 @@ pt: hub_profile_text2: > Ter um perfil e estabelecer ligações com o seu sistema alimentar local através da Open Food Network não custa nada. - hub_shop: Loja do hub + hub_shop: Loja da Central hub_shop_text1: > A sua organização é a espinha dorsal do seu sistema de produção e consumo local. Pode agregar produtos de outras organizações e vendê-los através @@ -2143,7 +2143,7 @@ pt: profile_only_text3: > Adicione os seus produtos na Open Food Network, permitindo assim que os hubs os associem aos seus pontos de venda. - producer_shop: Loja do produtor + producer_shop: Loja do Produtor producer_shop_text1: > Venda os seus produtos diretamente aos consumidores através da sua própria loja no Open Food Network. @@ -2151,7 +2151,7 @@ pt: Uma loja de produtor é individual, somente para comercialização de seus produtos. Se quiser vender bens produzidos em outros lugares e/ou pessoas, por favor selecione hub de produtor. - producer_hub: Hub de produtor + producer_hub: Hub de Produtor producer_hub_text1: > A sua organização é a espinha dorsal do seu sistema de produção e consumo local. Através da sua loja no Open Food Network, poderá vender os seus @@ -2167,62 +2167,62 @@ pt: get_listing: Obter uma listagem always_free: SEMPRE GRATUITO sell_produce_others: Comercialize produtos de outros - sell_own_produce: 'Venda seus próprios produtos ' - sell_both: Venda seus próprios produtos e de outros produtores + sell_own_produce: 'Venda os seus próprios produtos ' + sell_both: Venda os seus próprios produtos e de outros produtores enterprise_producer: producer: Produtor producer_text1: > Produtores/as são quem faz coisas deliciosas para comer e/ou beber. - És produtor/a se plantas, crias, fermentas, amassas, munges ou moldas. + É produtor/a se planta, cria, fermenta, amassa, munge ou molda algo. producer_text2: > Os Produtores também podem ter outras funções, como agregar alimentos de outras organizações e vendê-los através de uma loja na Open Food Network. - non_producer: Não produtor + non_producer: Não-produtor non_producer_text1: > Os Não-Produtores não produzem alimentos, o que quer dizer que não podem criar os seus próprios produtos para venda através da Open Food Network. non_producer_text2: > - Por outro lado, os não-produtores são especializados em estabelecer - ligações entre produtores e "comedores" finais, através da agregação, + Em vez disso, os não-produtores são especializados em estabelecer ligações + entre produtores e consumidores finais, através da agregação, classificação, empacotamento, venda ou distribuição de alimentos. producer_desc: Produtores/as de alimentos producer_example: 'ex: AGRICULTORES, PADEIRAS, ALQUIMISTAS, FAZEDORES' non_producer_desc: Todas as outras organizações de produção e consumo local de alimentos - non_producer_example: 'ex: Mercearias, cooperativas de consumo, grupos de compras' + non_producer_example: 'ex: mercearias, cooperativas de consumo, grupos de compras' enterprise_status: status_title: "%{name} está configurado e pronto para a acção!" severity: Severidade description: Descrição resolve: Resolver new_tag_rule_dialog: - select_rule_type: "Selecionar um tipo d regra:" + select_rule_type: "Selecionar um tipo de regra:" out_of_stock: reduced_stock_available: Stock reduzido disponível out_of_stock_text: > - Enquanto andavas às compras, o nível de stock de um ou mais produtos no - teu carrinho baixou. Aqui está o que mudou: - now_out_of_stock: está agora sem stock + Enquanto estava a comprar, o nível de stock de um ou mais produtos no seu + carrinho baixou. Aqui está o que mudou: + now_out_of_stock: está agora sem stock. only_n_remainging: "agora só tem %{num}restantes." variant_overrides: inventory_products: "Produtos de Inventário" - hidden_products: "Produtos escondidos" - new_products: "Novos produtos" + hidden_products: "Produtos Escondidos" + new_products: "Novos Produtos" reset_stock_levels: Voltar a definir os níveis de stock para os valores por defeito. changes_to: Muda para one_override: uma substituição overrides: substituições remain_unsaved: está por guardar. no_changes_to_save: Sem alterações a guardar.' - no_authorisation: "Não consegui autorização para guardar essas alterações, portanto mantêm-se por guardar." - some_trouble: "Tive alguns problemas a guardar: %{errors}" - changing_on_hand_stock: A alterar níveis de stock à mão... + no_authorisation: "Sem autorização para guardar as alterações, mantêm-se por guardar." + some_trouble: "Alguns problemas a guardar: %{errors}" + changing_on_hand_stock: A alterar níveis de stock disponíveis... stock_reset: Redefinir os stocks para valores por defeito. tag_rules: - show_hide_variants: 'Mostrar ou esconder variantes na minha montra' - show_hide_shipping: 'Mostrar ou Esconder métodos de envio à saída' - show_hide_payment: 'Mostrar ou Esconder métodos de pagamento à saída' - show_hide_order_cycles: 'Mostrar ou Esconder ciclos de encomendas na minha montra' + show_hide_variants: 'Mostrar ou esconder variantes na minha loja' + show_hide_shipping: 'Mostrar ou Esconder métodos de envio na finalização das encomendas' + show_hide_payment: 'Mostrar ou Esconder métodos de pagamento na finalização das encomendas' + show_hide_order_cycles: 'Mostrar ou Esconder ciclos de encomendas na minha loja' visible: VISÍVEL not_visible: NÃO VISÍVEL services: @@ -2234,13 +2234,13 @@ pt: edit_profile: "editar perfil" add_products_to_inventory: "acrescente produtos ao inventário" resources: - could_not_delete_customer: 'Não foi possível eliminar o cliente' + could_not_delete_customer: 'Não foi possível eliminar o consumidor' product_import: confirmation: | Isto colocará a zero o stock de todos os produtos desta organização que não estejam presentes no ficheiro carregado. order_cycles: update_success: 'O seu ciclo de encomendas foi actualizado.' - no_distributors: Não existem distribuidores neste ciclo. Este ciclo de encomendas só ficará visível para os clientes quando um distribuidor for adicionado. Gostaria de continuar a guardar este ciclo de encomendas? + no_distributors: Não existem distribuidores neste ciclo de encomendas. Este ciclo de encomendas só ficará visível para os consumidores quando um distribuidor for adicionado. Gostaria de continuar a guardar este ciclo de encomendas? enterprises: producer: "Produtor" non_producer: "Não-Produtor" @@ -2253,7 +2253,7 @@ pt: close_date_not_set: Data de fecho não definida producers: signup: - start_free_profile: "Começa com um perfil gratuito e expande quando estiveres pronto/a!" + start_free_profile: "Comece com um perfil gratuito e expanda quando estivere pronto/a!" spree: email: Email account_updated: "Conta actualizada!" @@ -2264,7 +2264,7 @@ pt: orders: invoice: issued_on: Emitido em - tax_invoice: FACTURA COM IMPOSTOS + tax_invoice: FACTURA FISCAL code: Código from: De to: Para @@ -2272,16 +2272,16 @@ pt: distribution_fields: title: Distribuição distributor: "Distribuidor:" - order_cycle: "Ciclo de encomenda:" + order_cycle: "Ciclo de encomendas:" overview: order_cycles: - order_cycles: "Ciclos de encomenda" - order_cycles_tip: "Os ciclos de encomenda determinam quando e onde é que os seus produtos estão disponíveis para os consumidores." + order_cycles: "Ciclos de Encomendas" + order_cycles_tip: "Os ciclos de encomendas determinam quando e onde é que os seus produtos estão disponíveis para os consumidores." you_have_active: zero: "Não tem nenhum ciclo de encomendas activo." one: "Tem um ciclo de encomendas activo." - other: "Tem %{count}ciclos de encomenda activos." - manage_order_cycles: "GERIR CICLOS DE ENCOMENDA" + other: "Tem %{count}ciclos de encomendas activos." + manage_order_cycles: "GERIR CICLOS DE ENCOMENDAS" payment_methods: stripe_connect: enterprise_select_placeholder: Escolher... @@ -2291,15 +2291,15 @@ pt: account_missing_msg: 'Não existem contas Stripe associadas a esta organização. ' connect_one: Ligar Uma access_revoked_msg: O acesso a esta conta Stripe foi revogado, por favor volte a ligar a sua conta. - status: EstadoEstado + status: Estado connected: Ligado - account_id: ID de conta - business_name: Nome do negócio + account_id: ID de Conta + business_name: Nome do Negócio charges_enabled: Taxas activas payments: source_forms: stripe: - no_payment_via_admin_backend: Neste momento não é possível criar pagamentos baseados em Stripe a partir do painel de administrador. + no_payment_via_admin_backend: Neste momento não é possível criar pagamentos baseados em Stripe a partir do painel de administrador products: new: title: 'Novo Produto' @@ -2316,16 +2316,16 @@ pt: unit: Unidade display_as: Mostrar como category: Categoria - tax_category: Categoria de Taxa + tax_category: Categoria de Imposto inherits_properties?: Herda Propriedades? available_on: Disponível em av_on: "Disp. em" products_variant: variant_has_n_overrides: "Esta variante tem %{n}substituição(ões)" new_variant: "Nova variante" - product_name: Nome do produto + product_name: Nome do Produto primary_taxon_form: - product_category: Categoria de produto + product_category: Categoria de Produto group_buy_form: group_buy: "Compra em grupo?" bulk_unit_size: Tamanho unitário por atacado @@ -2336,7 +2336,7 @@ pt: bulk_coop_supplier_report: 'Cooperativa por Atacado - Totais por Fornecedor' bulk_coop_allocation: 'Cooperativa por Atacado - Alocação' bulk_coop_packing_sheets: 'Cooperativa por Atacado - Folhas de Empacotamento' - bulk_coop_customer_payments: 'Cooperativa por Atacado - Pagamentos do Cliente' + bulk_coop_customer_payments: 'Cooperativa por Atacado - Pagamentos do Consumidor' shared: configuration_menu: stripe_connect: Ligar ao Stripe @@ -2363,7 +2363,7 @@ pt: hi: "Olá %{name}" invoice_attached_text: Pode encontrar em anexo um recibo da encomenda que fez recentemente a order_state: - address: endereço + address: morada adjustments: ajustes awaiting_return: aguardando retorno canceled: cancelado @@ -2384,18 +2384,18 @@ pt: paused: em pausa canceled: cancelado payment_states: - balance_due: saldo devedor + balance_due: saldo pendente completed: completado - checkout: fechar pedido + checkout: finalizar compra credit_owed: crédito devido - failed: falha + failed: falhou paid: pago pending: pendente processing: em processamento void: vazio invalid: inválido shipment_states: - backorder: atrasos + backorder: rutura de stock partial: parcial pending: pendente ready: pronto @@ -2403,12 +2403,12 @@ pt: user_mailer: reset_password_instructions: request_sent_text: | - Recebemos um pedido para redefinir a sua senha. + Recebemos um pedido para redefinir a sua palavra-passe. Se não fez este pedido, simplesmente ignore esta mensagem. link_text: > - Se fez este pedido, clique na ligação abaixo: + Se fez este pedido, clique no link abaixo: issue_text: | - Se o endereço URL acima não funcionar, tente copiá-lo e colá-lo directamente no browser. Se continuar a ter problemas por favor entre em contacto. + Se o endereço URL acima não funcionar, tente copiá-lo e colá-lo directamente no browser. Se continuar a ter problemas por favor entre em contacto connosco. confirmation_instructions: subject: Por favor confirme a sua conta OFN weight: Peso (por kg) @@ -2439,9 +2439,9 @@ pt: closed: Fechado until: Até past_orders: - order: Encomendar - shop: Comprar - completed_at: Completado em + order: Encomenda + shop: Loja + completed_at: Concluído Em items: Itens total: Total paid?: Pago? From 02c479564028bcadd3ad645efdcb5a59a84e6b4d Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Tue, 29 May 2018 16:47:23 +1000 Subject: [PATCH 137/206] Remove default dates from reports Closes https://github.com/openfoodfoundation/openfoodnetwork/issues/2212 --- .../admin/reports_controller_decorator.rb | 37 +++---------------- 1 file changed, 5 insertions(+), 32 deletions(-) diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index 5556488424..52abed6fde 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -68,7 +68,7 @@ Spree::Admin::ReportsController.class_eval do end def order_cycle_management - prepare_date_params params + params[:q] ||= {} # -- Prepare form options my_distributors = Enterprise.is_distributor.managed_by(spree_current_user) @@ -91,8 +91,7 @@ Spree::Admin::ReportsController.class_eval do 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) @@ -115,7 +114,6 @@ Spree::Admin::ReportsController.class_eval do end def orders_and_distributors - prepare_date_params params @report = OpenFoodNetwork::OrderAndDistributorReport.new spree_current_user, params, render_content? @search = @report.search csv_file_name = "orders_and_distributors_#{timestamp}.csv" @@ -123,7 +121,6 @@ Spree::Admin::ReportsController.class_eval do 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, render_content? @@ -131,8 +128,6 @@ Spree::Admin::ReportsController.class_eval do end def bulk_coop - prepare_date_params params - # -- Prepare form options @distributors = Enterprise.is_distributor.managed_by(spree_current_user) @report_type = params[:report_type] @@ -147,9 +142,6 @@ 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] @@ -164,8 +156,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) @@ -206,12 +197,8 @@ Spree::Admin::ReportsController.class_eval do 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') @@ -263,20 +250,6 @@ Spree::Admin::ReportsController.class_eval do end end - 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 - 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 # Load distributors either owned by the user or selling their enterprises products. my_distributors = Enterprise.is_distributor.managed_by(spree_current_user) From 72ae6f2af6a239ada8b97c5f09dd3af85f9c3b8a Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Tue, 29 May 2018 16:49:00 +1000 Subject: [PATCH 138/206] Add spec helper to make spec run on its own --- spec/lib/open_food_network/xero_invoices_report_spec.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/lib/open_food_network/xero_invoices_report_spec.rb b/spec/lib/open_food_network/xero_invoices_report_spec.rb index f2dce1fb5a..a8c45bd7d7 100644 --- a/spec/lib/open_food_network/xero_invoices_report_spec.rb +++ b/spec/lib/open_food_network/xero_invoices_report_spec.rb @@ -1,3 +1,4 @@ +require 'spec_helper' require 'open_food_network/xero_invoices_report' module OpenFoodNetwork From 54bdcf7679e0a26db10896bb7748ec80148ddd58 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Tue, 29 May 2018 16:53:18 +1000 Subject: [PATCH 139/206] Convert specs to RSpec 3.7.0 syntax with Transpec This conversion is done by Transpec 3.3.0 with the following command: transpec spec/lib/open_food_network/xero_invoices_report_spec.rb * 15 conversions from: obj.stub(:message) to: allow(obj).to receive(:message) * 10 conversions from: obj.should to: expect(obj).to * 4 conversions from: == expected to: eq(expected) * 3 conversions from: obj.should_not to: expect(obj).not_to For more details: https://github.com/yujinakayama/transpec#supported-conversions --- .../xero_invoices_report_spec.rb | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/spec/lib/open_food_network/xero_invoices_report_spec.rb b/spec/lib/open_food_network/xero_invoices_report_spec.rb index a8c45bd7d7..d119d26964 100644 --- a/spec/lib/open_food_network/xero_invoices_report_spec.rb +++ b/spec/lib/open_food_network/xero_invoices_report_spec.rb @@ -13,10 +13,10 @@ module OpenFoodNetwork around { |example| Timecop.travel(Time.zone.local(2015, 5, 5, 14, 0, 0)) { example.run } } it "uses defaults when blank params are passed" do - report.instance_variable_get(:@opts).should == {invoice_date: Date.civil(2015, 5, 5), + expect(report.instance_variable_get(:@opts)).to eq({invoice_date: Date.civil(2015, 5, 5), due_date: Date.civil(2015, 6, 5), account_code: 'food sales', - report_type: 'summary'} + report_type: 'summary'}) end end @@ -26,53 +26,53 @@ module OpenFoodNetwork let(:summary_rows) { report.send(:summary_rows_for_order, order, 1, {}) } before do - report.stub(:produce_summary_rows) { ['produce'] } - report.stub(:fee_summary_rows) { ['fee'] } - report.stub(:shipping_summary_rows) { ['shipping'] } - report.stub(:payment_summary_rows) { ['payment'] } - report.stub(:admin_adjustment_summary_rows) { ['admin'] } - order.stub(:account_invoice?) { false } + allow(report).to receive(:produce_summary_rows) { ['produce'] } + allow(report).to receive(:fee_summary_rows) { ['fee'] } + allow(report).to receive(:shipping_summary_rows) { ['shipping'] } + allow(report).to receive(:payment_summary_rows) { ['payment'] } + allow(report).to receive(:admin_adjustment_summary_rows) { ['admin'] } + allow(order).to receive(:account_invoice?) { false } end it "displays produce summary rows when summary report" do - report.stub(:detail?) { false } - summary_rows.should include 'produce' + allow(report).to receive(:detail?) { false } + expect(summary_rows).to include 'produce' end it "does not display produce summary rows when detail report" do - report.stub(:detail?) { true } - summary_rows.should_not include 'produce' + allow(report).to receive(:detail?) { true } + expect(summary_rows).not_to include 'produce' end it "displays fee summary rows when summary report" do - report.stub(:detail?) { false } - order.stub(:account_invoice?) { true } - summary_rows.should include 'fee' + allow(report).to receive(:detail?) { false } + allow(order).to receive(:account_invoice?) { true } + expect(summary_rows).to include 'fee' end it "displays fee summary rows when this is not an account invoice" do - report.stub(:detail?) { true } - order.stub(:account_invoice?) { false } - summary_rows.should include 'fee' + allow(report).to receive(:detail?) { true } + allow(order).to receive(:account_invoice?) { false } + expect(summary_rows).to include 'fee' end it "does not display fee summary rows when this is a detail report for an account invoice" do - report.stub(:detail?) { true } - order.stub(:account_invoice?) { true } - summary_rows.should_not include 'fee' + allow(report).to receive(:detail?) { true } + allow(order).to receive(:account_invoice?) { true } + expect(summary_rows).not_to include 'fee' end it "always displays shipping summary rows" do - summary_rows.should include 'shipping' + expect(summary_rows).to include 'shipping' end it "displays admin adjustment summary rows when summary report" do - summary_rows.should include 'admin' + expect(summary_rows).to include 'admin' end it "does not display admin adjustment summary rows when detail report" do - report.stub(:detail?) { true } - summary_rows.should_not include 'admin' + allow(report).to receive(:detail?) { true } + expect(summary_rows).not_to include 'admin' end end @@ -85,12 +85,12 @@ module OpenFoodNetwork let!(:adj_shipping) { create(:adjustment, adjustable: order, label: "Shipping", originator: shipping_method) } it "returns BillablePeriod adjustments only" do - report.send(:account_invoice_adjustments, order).should == [adj_invoice] + expect(report.send(:account_invoice_adjustments, order)).to eq([adj_invoice]) end it "excludes adjustments where the source is missing" do billable_period.destroy - report.send(:account_invoice_adjustments, order).should be_empty + expect(report.send(:account_invoice_adjustments, order)).to be_empty end end @@ -99,7 +99,7 @@ module OpenFoodNetwork describe "when no initial invoice number is given" do it "returns the order number" do - subject.send(:invoice_number_for, order, 123).should == 'R731032860' + expect(subject.send(:invoice_number_for, order, 123)).to eq('R731032860') end end @@ -107,7 +107,7 @@ module OpenFoodNetwork subject { XeroInvoicesReport.new user, {initial_invoice_number: '123'} } it "increments the number by the index" do - subject.send(:invoice_number_for, order, 456).should == 579 + expect(subject.send(:invoice_number_for, order, 456)).to eq(579) end end end From e6ef43c91d00d91c63a0b15f51fe862b7dd82e14 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Tue, 29 May 2018 16:55:30 +1000 Subject: [PATCH 140/206] Give a spec some style --- .rubocop_todo.yml | 11 ----------- .../xero_invoices_report_spec.rb | 18 +++++++++--------- 2 files changed, 9 insertions(+), 20 deletions(-) diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 3e49ab57d3..bc47b9054c 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -193,7 +193,6 @@ Layout/EmptyLines: - 'lib/open_food_network/sales_tax_report.rb' - 'lib/open_food_network/scope_product_to_hub.rb' - 'lib/open_food_network/scope_variant_to_hub.rb' - - 'lib/open_food_network/xero_invoices_report.rb' - 'lib/spree/core/controller_helpers/order_decorator.rb' - 'lib/tasks/cache.rake' - 'lib/tasks/dev.rake' @@ -395,7 +394,6 @@ Layout/ExtraSpacing: - 'spec/features/consumer/shopping/shopping_spec.rb' - 'spec/lib/open_food_network/enterprise_fee_calculator_spec.rb' - 'spec/lib/open_food_network/reports/rule_spec.rb' - - 'spec/lib/open_food_network/xero_invoices_report_spec.rb' - 'spec/models/enterprise_fee_spec.rb' - 'spec/models/enterprise_spec.rb' - 'spec/models/order_cycle_spec.rb' @@ -669,7 +667,6 @@ Layout/SpaceAroundEqualsInParameterDefault: - 'lib/open_food_network/permissions.rb' - 'lib/open_food_network/scope_variant_to_hub.rb' - 'lib/open_food_network/tag_rule_applicator.rb' - - 'lib/open_food_network/xero_invoices_report.rb' - 'lib/spree/money_decorator.rb' - 'spec/features/admin/enterprise_relationships_spec.rb' - 'spec/features/admin/reports_spec.rb' @@ -693,7 +690,6 @@ Layout/SpaceAroundOperators: - 'app/overrides/remove_side_bar.rb' - 'app/overrides/replace_shipping_address_form_with_distributor_details.rb' - 'app/serializers/api/enterprise_serializer.rb' - - 'lib/open_food_network/xero_invoices_report.rb' - 'lib/spree/product_filters.rb' - 'spec/controllers/admin/enterprises_controller_spec.rb' - 'spec/controllers/cart_controller_spec.rb' @@ -862,7 +858,6 @@ Layout/SpaceInsideHashLiteralBraces: - 'lib/open_food_network/reports/rule.rb' - 'lib/open_food_network/sales_tax_report.rb' - 'lib/open_food_network/variant_and_line_item_naming.rb' - - 'lib/open_food_network/xero_invoices_report.rb' - 'lib/tasks/users.rake' - 'spec/controllers/admin/accounts_and_billing_settings_controller_spec.rb' - 'spec/controllers/admin/business_model_configuration_controller_spec.rb' @@ -901,7 +896,6 @@ Layout/SpaceInsideHashLiteralBraces: - 'spec/lib/open_food_network/reports/report_spec.rb' - 'spec/lib/open_food_network/reports/rule_spec.rb' - 'spec/lib/open_food_network/tag_rule_applicator_spec.rb' - - 'spec/lib/open_food_network/xero_invoices_report_spec.rb' - 'spec/lib/stripe/account_connector_spec.rb' - 'spec/models/customer_spec.rb' - 'spec/models/enterprise_fee_spec.rb' @@ -1089,7 +1083,6 @@ Lint/UnusedBlockArgument: - 'lib/open_food_network/reports/bulk_coop_allocation_report.rb' - 'lib/open_food_network/reports/bulk_coop_supplier_report.rb' - 'lib/open_food_network/sales_tax_report.rb' - - 'lib/open_food_network/xero_invoices_report.rb' - 'spec/lib/open_food_network/order_grouper_spec.rb' - 'spec/support/cancan_helper.rb' - 'spec/support/delayed_job_helper.rb' @@ -1273,7 +1266,6 @@ Naming/UncommunicativeMethodParamName: - 'app/services/subscription_validator.rb' - 'lib/open_food_network/property_merge.rb' - 'lib/open_food_network/reports/bulk_coop_report.rb' - - 'lib/open_food_network/xero_invoices_report.rb' - 'spec/lib/open_food_network/reports/report_spec.rb' - 'spec/mailers/producer_mailer_spec.rb' @@ -1695,7 +1687,6 @@ Style/BracesAroundHashParameters: - 'lib/open_food_network/order_cycle_form_applicator.rb' - 'lib/open_food_network/reports/rule.rb' - 'lib/open_food_network/variant_and_line_item_naming.rb' - - 'lib/open_food_network/xero_invoices_report.rb' - 'spec/controllers/admin/accounts_and_billing_settings_controller_spec.rb' - 'spec/controllers/admin/business_model_configuration_controller_spec.rb' - 'spec/controllers/admin/enterprises_controller_spec.rb' @@ -1727,7 +1718,6 @@ Style/BracesAroundHashParameters: - 'spec/lib/open_food_network/feature_toggle_spec.rb' - 'spec/lib/open_food_network/order_cycle_form_applicator_spec.rb' - 'spec/lib/open_food_network/subscription_summarizer_spec.rb' - - 'spec/lib/open_food_network/xero_invoices_report_spec.rb' - 'spec/models/billable_period_spec.rb' - 'spec/models/product_distribution_spec.rb' - 'spec/models/spree/ability_spec.rb' @@ -2308,7 +2298,6 @@ Style/NumericPredicate: - 'app/models/spree/order_decorator.rb' - 'lib/open_food_network/integrity_checker.rb' - 'lib/open_food_network/rack_request_blocker.rb' - - 'lib/open_food_network/xero_invoices_report.rb' - 'lib/spree/money_decorator.rb' # Offense count: 2 diff --git a/spec/lib/open_food_network/xero_invoices_report_spec.rb b/spec/lib/open_food_network/xero_invoices_report_spec.rb index d119d26964..e5ae74d8d0 100644 --- a/spec/lib/open_food_network/xero_invoices_report_spec.rb +++ b/spec/lib/open_food_network/xero_invoices_report_spec.rb @@ -8,20 +8,20 @@ module OpenFoodNetwork let(:user) { create(:user) } describe "option defaults" do - let(:report) { XeroInvoicesReport.new user, {initial_invoice_number: '', invoice_date: '', due_date: '', account_code: ''} } + let(:report) { XeroInvoicesReport.new user, initial_invoice_number: '', invoice_date: '', due_date: '', account_code: '' } around { |example| Timecop.travel(Time.zone.local(2015, 5, 5, 14, 0, 0)) { example.run } } it "uses defaults when blank params are passed" do - expect(report.instance_variable_get(:@opts)).to eq({invoice_date: Date.civil(2015, 5, 5), - due_date: Date.civil(2015, 6, 5), - account_code: 'food sales', - report_type: 'summary'}) + expect(report.instance_variable_get(:@opts)).to eq( invoice_date: Date.civil(2015, 5, 5), + due_date: Date.civil(2015, 6, 5), + account_code: 'food sales', + report_type: 'summary' ) end end describe "summary rows" do - let(:report) { XeroInvoicesReport.new user, {initial_invoice_number: '', invoice_date: '', due_date: '', account_code: ''} } + let(:report) { XeroInvoicesReport.new user, initial_invoice_number: '', invoice_date: '', due_date: '', account_code: '' } let(:order) { double(:order) } let(:summary_rows) { report.send(:summary_rows_for_order, order, 1, {}) } @@ -31,7 +31,7 @@ module OpenFoodNetwork allow(report).to receive(:shipping_summary_rows) { ['shipping'] } allow(report).to receive(:payment_summary_rows) { ['payment'] } allow(report).to receive(:admin_adjustment_summary_rows) { ['admin'] } - allow(order).to receive(:account_invoice?) { false } + allow(order).to receive(:account_invoice?) { false } end it "displays produce summary rows when summary report" do @@ -77,7 +77,7 @@ module OpenFoodNetwork end describe "finding account invoice adjustments" do - let(:report) { XeroInvoicesReport.new user, {initial_invoice_number: '', invoice_date: '', due_date: '', account_code: ''} } + let(:report) { XeroInvoicesReport.new user, initial_invoice_number: '', invoice_date: '', due_date: '', account_code: '' } let!(:order) { create(:order) } let(:billable_period) { create(:billable_period) } let(:shipping_method) { create(:shipping_method) } @@ -104,7 +104,7 @@ module OpenFoodNetwork end describe "when an initial invoice number is given" do - subject { XeroInvoicesReport.new user, {initial_invoice_number: '123'} } + subject { XeroInvoicesReport.new user, initial_invoice_number: '123' } it "increments the number by the index" do expect(subject.send(:invoice_number_for, order, 456)).to eq(579) From 16211da5f649bab3d84a851f60bc0d0a029eaf25 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Fri, 25 May 2018 15:03:33 +1000 Subject: [PATCH 141/206] Reimplement tabs on shopfront using our custom tab directive --- .../shopping_tabs_controller.js.coffee | 12 -- .../{tabs.css.scss => shop_tabs.css.scss} | 107 +++++++--------- .../stylesheets/darkswarm/tabset.css.scss | 2 +- app/helpers/shop_helper.rb | 9 ++ app/views/shopping_shared/_about.html.haml | 17 +-- app/views/shopping_shared/_contact.html.haml | 121 +++++++++--------- app/views/shopping_shared/_groups.html.haml | 27 ++-- .../shopping_shared/_producers.html.haml | 23 ++-- app/views/shopping_shared/_tabs.html.haml | 22 ++-- 9 files changed, 159 insertions(+), 181 deletions(-) delete mode 100644 app/assets/javascripts/darkswarm/controllers/shopping_tabs_controller.js.coffee rename app/assets/stylesheets/darkswarm/{tabs.css.scss => shop_tabs.css.scss} (60%) diff --git a/app/assets/javascripts/darkswarm/controllers/shopping_tabs_controller.js.coffee b/app/assets/javascripts/darkswarm/controllers/shopping_tabs_controller.js.coffee deleted file mode 100644 index b99d169c5b..0000000000 --- a/app/assets/javascripts/darkswarm/controllers/shopping_tabs_controller.js.coffee +++ /dev/null @@ -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 diff --git a/app/assets/stylesheets/darkswarm/tabs.css.scss b/app/assets/stylesheets/darkswarm/shop_tabs.css.scss similarity index 60% rename from app/assets/stylesheets/darkswarm/tabs.css.scss rename to app/assets/stylesheets/darkswarm/shop_tabs.css.scss index 55b55f1189..c1777ec851 100644 --- a/app/assets/stylesheets/darkswarm/tabs.css.scss +++ b/app/assets/stylesheets/darkswarm/shop_tabs.css.scss @@ -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); } } } diff --git a/app/assets/stylesheets/darkswarm/tabset.css.scss b/app/assets/stylesheets/darkswarm/tabset.css.scss index eaefe188a8..c100bff672 100644 --- a/app/assets/stylesheets/darkswarm/tabset.css.scss +++ b/app/assets/stylesheets/darkswarm/tabset.css.scss @@ -2,7 +2,7 @@ @import "mixins"; @import "branding"; -.tabset-ctrl { +.tabset-ctrl:not(#shop-tabs) { .tab-view { padding-top: 30px; } diff --git a/app/helpers/shop_helper.rb b/app/helpers/shop_helper.rb index 920d41d65e..fa88bf4ac9 100644 --- a/app/helpers/shop_helper.rb +++ b/app/helpers/shop_helper.rb @@ -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) }, + { name: 'producers', title: t(:label_producers) }, + { name: 'contact', title: t(:shopping_tabs_contact) }, + { name: 'groups', title: t(:label_groups) }, + ] + end end diff --git a/app/views/shopping_shared/_about.html.haml b/app/views/shopping_shared/_about.html.haml index eac2fe5658..e4d03a1d2c 100644 --- a/app/views/shopping_shared/_about.html.haml +++ b/app/views/shopping_shared/_about.html.haml @@ -1,8 +1,9 @@ -.content#about{"ng-controller" => "AboutUsCtrl"} - .panel - .row - .small-12.large-8.columns - %img.hero-img-small{"ng-src" => "{{::CurrentHub.hub.promo_image}}", "ng-if" => "::CurrentHub.hub.promo_image"} - %p{"ng-bind-html" => "::CurrentHub.hub.long_description"} - .small-12.large-4.columns -   +%script{ type: "text/ng-template", id: "shop/about.html" } + .content#about{"ng-controller" => "AboutUsCtrl"} + .panel + .row + .small-12.large-8.columns + %img.hero-img-small{"ng-src" => "{{::CurrentHub.hub.promo_image}}", "ng-if" => "::CurrentHub.hub.promo_image"} + %p{"ng-bind-html" => "::CurrentHub.hub.long_description"} + .small-12.large-4.columns +   diff --git a/app/views/shopping_shared/_contact.html.haml b/app/views/shopping_shared/_contact.html.haml index 637ca5f530..94c0bd3b52 100644 --- a/app/views/shopping_shared/_contact.html.haml +++ b/app/views/shopping_shared/_contact.html.haml @@ -1,65 +1,66 @@ -.content#contact - .panel - .row - .small-12.large-4.columns - - if current_distributor.address.address1 || current_distributor.address.address2 || current_distributor.address.city || current_distributor.address.state || current_distributor.address.zipcode - %div.center - .header - = t :shopping_contact_address - %strong=current_distributor.name - %p - = current_distributor.address.address1 - - unless current_distributor.address.address2.blank? - %br - = current_distributor.address.address2 - %br - = current_distributor.address.city - = current_distributor.address.state - = current_distributor.address.zipcode - - .small-12.large-4.columns - - if current_distributor.website || current_distributor.email_address || current_distributor.phone - %div.center - .header - = t :shopping_contact_web - %p - - unless current_distributor.phone.blank? - = current_distributor.phone - %br - - unless current_distributor.website.blank? - %a{href: "http://#{current_distributor.website}", target: "_blank" } - = current_distributor.website +%script{ type: "text/ng-template", id: "shop/contact.html" } + .content#contact + .panel + .row + .small-12.large-4.columns + - if current_distributor.address.address1 || current_distributor.address.address2 || current_distributor.address.city || current_distributor.address.state || current_distributor.address.zipcode + %div.center + .header + = t :shopping_contact_address + %strong=current_distributor.name + %p + = current_distributor.address.address1 + - unless current_distributor.address.address2.blank? %br - - unless current_distributor.email_address.blank? - %a{href: current_distributor.email_address.reverse, mailto: true} - %span.email - = current_distributor.email_address.reverse + = current_distributor.address.address2 + %br + = current_distributor.address.city + = current_distributor.address.state + = current_distributor.address.zipcode - .small-12.large-4.columns - - if current_distributor.twitter.present? || current_distributor.facebook.present? || current_distributor.linkedin.present? || current_distributor.instagram.present? - %div.center - .header - = t :shopping_contact_social - %div.follow-icons - - unless current_distributor.twitter.blank? - %span - %a{href: "http://twitter.com/#{current_distributor.twitter}", target: "_blank" } - %i.ofn-i_041-twitter + .small-12.large-4.columns + - if current_distributor.website || current_distributor.email_address || current_distributor.phone + %div.center + .header + = t :shopping_contact_web + %p + - unless current_distributor.phone.blank? + = current_distributor.phone + %br + - unless current_distributor.website.blank? + %a{href: "http://#{current_distributor.website}", target: "_blank" } + = current_distributor.website + %br + - unless current_distributor.email_address.blank? + %a{href: current_distributor.email_address.reverse, mailto: true} + %span.email + = current_distributor.email_address.reverse - - unless current_distributor.facebook.blank? - %span - %a{href: "http://#{current_distributor.facebook}", target: "_blank" } - %i.ofn-i_044-facebook - / = current_distributor.facebook + .small-12.large-4.columns + - if current_distributor.twitter.present? || current_distributor.facebook.present? || current_distributor.linkedin.present? || current_distributor.instagram.present? + %div.center + .header + = t :shopping_contact_social + %div.follow-icons + - unless current_distributor.twitter.blank? + %span + %a{href: "http://twitter.com/#{current_distributor.twitter}", target: "_blank" } + %i.ofn-i_041-twitter - - unless current_distributor.linkedin.blank? - %span - %a{href: "http://#{current_distributor.linkedin}", target: "_blank" } - %i.ofn-i_042-linkedin - / = current_distributor.linkedin + - unless current_distributor.facebook.blank? + %span + %a{href: "http://#{current_distributor.facebook}", target: "_blank" } + %i.ofn-i_044-facebook + / = current_distributor.facebook - - unless current_distributor.instagram.blank? - %span - %a{href: "http://instagram.com/#{current_distributor.instagram}", target: "_blank" } - %i.ofn-i_043-instagram - / = current_distributor.instagram + - unless current_distributor.linkedin.blank? + %span + %a{href: "http://#{current_distributor.linkedin}", target: "_blank" } + %i.ofn-i_042-linkedin + / = current_distributor.linkedin + + - unless current_distributor.instagram.blank? + %span + %a{href: "http://instagram.com/#{current_distributor.instagram}", target: "_blank" } + %i.ofn-i_043-instagram + / = current_distributor.instagram diff --git a/app/views/shopping_shared/_groups.html.haml b/app/views/shopping_shared/_groups.html.haml index 6278f44c77..5e04f3d43c 100644 --- a/app/views/shopping_shared/_groups.html.haml +++ b/app/views/shopping_shared/_groups.html.haml @@ -1,13 +1,14 @@ -.content - .panel - .row - .small-12.large-4.columns - - if current_distributor.groups.length > 0 - %h5 - =current_distributor.name - = t :shopping_groups_part_of - %ul.bullet-list - - for group in current_distributor.groups - %li - %a{href: main_app.groups_path + "/#{group.permalink}"} - = group.name +%script{ type: "text/ng-template", id: "shop/groups.html" } + .content + .panel + .row + .small-12.large-4.columns + - if current_distributor.groups.length > 0 + %h5 + =current_distributor.name + = t :shopping_groups_part_of + %ul.bullet-list + - for group in current_distributor.groups + %li + %a{href: main_app.groups_path + "/#{group.permalink}"} + = group.name diff --git a/app/views/shopping_shared/_producers.html.haml b/app/views/shopping_shared/_producers.html.haml index 3c9e9982db..754a26b265 100644 --- a/app/views/shopping_shared/_producers.html.haml +++ b/app/views/shopping_shared/_producers.html.haml @@ -1,11 +1,12 @@ -.content#producers{"ng-controller" => "ProducersTabCtrl"} - .panel - .row - .small-12.columns - %h5 - = t :shopping_producers_of_hub, hub: '{{CurrentHub.hub.name}}' - %ul.small-block-grid-2.large-block-grid-4 - %li{"ng-repeat" => "enterprise in CurrentHub.hub.producers"} - %enterprise-modal - %i.ofn-i_036-producers - {{ enterprise.name }} +%script{ type: "text/ng-template", id: "shop/producers.html" } + .content#producers{"ng-controller" => "ProducersTabCtrl"} + .panel + .row + .small-12.columns + %h5 + = t :shopping_producers_of_hub, hub: '{{CurrentHub.hub.name}}' + %ul.small-block-grid-2.large-block-grid-4 + %li{"ng-repeat" => "enterprise in CurrentHub.hub.producers"} + %enterprise-modal + %i.ofn-i_036-producers + {{ enterprise.name }} diff --git a/app/views/shopping_shared/_tabs.html.haml b/app/views/shopping_shared/_tabs.html.haml index 3bcd2aa37e..2f43362436 100644 --- a/app/views/shopping_shared/_tabs.html.haml +++ b/app/views/shopping_shared/_tabs.html.haml @@ -1,15 +1,9 @@ -#tabs{"ng-controller" => "ShoppingTabsCtrl", "ng-cloak" => true} +- shop_tabs.each do |tab| + = render "shopping_shared/#{tab[:name]}" + +.tabset-ctrl#shop-tabs{ navigate: 'true', prefix: 'shop', ng: { cloak: true } } .row - %tabset{ 'open-on-load' => 'false' } - -# Build all tabs. - - for name, heading_cols in { about: [t(:shopping_tabs_about, distributor: current_distributor.name), 6], - producers: [t(:label_producers),2], - contact: [t(:shopping_tabs_contact),2], - groups: [t(:label_groups),2]} - - heading, cols = heading_cols - %tab.columns{heading: heading, - id: "tab_#{name}", - active: "tabs.#{name}.active", - select: "select(\'#{name}\')", - class: "small-12 medium-#{cols}" } - = render "shopping_shared/#{name}" + - shop_tabs.each do |tab| + .small-12.medium-6.columns.tab{ id: "tab_#{tab[:name]}", name: tab[:name] } + %a{ href: 'javascript:void(0)' }=tab[:title] + .small-12.columns.tab-view From 184bf9ce36116d04ac20a384a778b90efda0d496 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Fri, 18 May 2018 13:50:00 +1000 Subject: [PATCH 142/206] Allow super admin users to enable subscriptions for enterprises --- .../form/_shop_preferences.html.haml | 26 +++++++++---------- spec/features/admin/enterprises_spec.rb | 2 ++ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/app/views/admin/enterprises/form/_shop_preferences.html.haml b/app/views/admin/enterprises/form/_shop_preferences.html.haml index 2b7e916eeb..fceac454aa 100644 --- a/app/views/admin/enterprises/form/_shop_preferences.html.haml +++ b/app/views/admin/enterprises/form/_shop_preferences.html.haml @@ -77,16 +77,16 @@ = f.radio_button :allow_order_changes, true, "ng-model" => "Enterprise.allow_order_changes", "ng-value" => "true" = f.label :allow_order_changes, t('.allow_order_changes_true'), value: :true - --# .row --# .alpha.eleven.columns --# .three.columns.alpha --# %label= t '.enable_subscriptions' --# %div{'ofn-with-tip' => t('.enable_subscriptions_tip')} --# %a= t 'admin.whats_this' --# .three.columns --# = f.radio_button :enable_subscriptions, true --# = f.label :enable_subscriptions, t('.enable_subscriptions_true'), value: :true --# .five.columns.omega --# = f.radio_button :enable_subscriptions, false --# = f.label :enable_subscriptions, t('.enable_subscriptions_false'), value: :false +- if spree_current_user.admin? + .row + .alpha.eleven.columns + .three.columns.alpha + %label= t '.enable_subscriptions' + %div{'ofn-with-tip' => t('.enable_subscriptions_tip')} + %a= t 'admin.whats_this' + .three.columns + = f.radio_button :enable_subscriptions, true + = f.label :enable_subscriptions, t('.enable_subscriptions_true'), value: :true + .five.columns.omega + = f.radio_button :enable_subscriptions, false + = f.label :enable_subscriptions, t('.enable_subscriptions_false'), value: :false diff --git a/spec/features/admin/enterprises_spec.rb b/spec/features/admin/enterprises_spec.rb index 448933bac7..6ed07f5bd3 100644 --- a/spec/features/admin/enterprises_spec.rb +++ b/spec/features/admin/enterprises_spec.rb @@ -167,6 +167,7 @@ feature %q{ page.first("input[name='enterprise\[preferred_shopfront_message\]']", visible: false).set('This is my shopfront message.') page.should have_checked_field "enterprise_preferred_shopfront_order_cycle_order_orders_close_at" choose "enterprise_preferred_shopfront_order_cycle_order_orders_open_at" + choose "enterprise_enable_subscriptions_true" click_button 'Update' @@ -195,6 +196,7 @@ feature %q{ page.should have_content 'This is my shopfront message.' page.should have_checked_field "enterprise_preferred_shopfront_order_cycle_order_orders_open_at" expect(page).to have_checked_field "enterprise_require_login_true" + expect(page).to have_checked_field "enterprise_enable_subscriptions_true" end describe "producer properties" do From 8201da9fab5f90e7a01074b9576c22fbc33c720f Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Wed, 30 May 2018 17:05:31 +1000 Subject: [PATCH 143/206] Prodide specific tab widths for shop tabs when screen width > medium --- app/helpers/shop_helper.rb | 8 ++++---- app/views/shopping_shared/_tabs.html.haml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/helpers/shop_helper.rb b/app/helpers/shop_helper.rb index fa88bf4ac9..0ca473e44f 100644 --- a/app/helpers/shop_helper.rb +++ b/app/helpers/shop_helper.rb @@ -22,10 +22,10 @@ module ShopHelper def shop_tabs [ - { name: 'about', title: t(:shopping_tabs_about, distributor: current_distributor.name) }, - { name: 'producers', title: t(:label_producers) }, - { name: 'contact', title: t(:shopping_tabs_contact) }, - { name: 'groups', title: t(:label_groups) }, + { 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 diff --git a/app/views/shopping_shared/_tabs.html.haml b/app/views/shopping_shared/_tabs.html.haml index 2f43362436..3961cf9bfa 100644 --- a/app/views/shopping_shared/_tabs.html.haml +++ b/app/views/shopping_shared/_tabs.html.haml @@ -4,6 +4,6 @@ .tabset-ctrl#shop-tabs{ navigate: 'true', prefix: 'shop', ng: { cloak: true } } .row - shop_tabs.each do |tab| - .small-12.medium-6.columns.tab{ id: "tab_#{tab[:name]}", name: tab[:name] } + .small-12.columns.tab{ id: "tab_#{tab[:name]}", name: tab[:name], class: "medium-#{tab[:cols]}" } %a{ href: 'javascript:void(0)' }=tab[:title] .small-12.columns.tab-view From 65e13c2a2ddd7265a63af40c52e219c4a671b9ac Mon Sep 17 00:00:00 2001 From: robotscissors Date: Wed, 30 May 2018 20:42:31 -0700 Subject: [PATCH 144/206] add image back into server for background --- app/assets/images/home/tagline-bg.jpg | Bin 0 -> 166404 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 app/assets/images/home/tagline-bg.jpg diff --git a/app/assets/images/home/tagline-bg.jpg b/app/assets/images/home/tagline-bg.jpg new file mode 100644 index 0000000000000000000000000000000000000000..68366a95695dcd0716474fbb6832f6439b52b5b5 GIT binary patch literal 166404 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

N9=Ad|of+Wr7G2 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 literal 0 HcmV?d00001 From e3ea60fda55229ff7e90b7db9ef570808d0b4674 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Wed, 14 Mar 2018 16:22:39 +1100 Subject: [PATCH 145/206] Process proxy orders even if underlying subscription has been paused or cancelled --- app/jobs/subscription_confirm_job.rb | 1 - spec/jobs/subscription_confirm_job_spec.rb | 20 ++++++++++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/app/jobs/subscription_confirm_job.rb b/app/jobs/subscription_confirm_job.rb index f2788288ff..d9c284d7f8 100644 --- a/app/jobs/subscription_confirm_job.rb +++ b/app/jobs/subscription_confirm_job.rb @@ -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 diff --git a/spec/jobs/subscription_confirm_job_spec.rb b/spec/jobs/subscription_confirm_job_spec.rb index 8dc425c087..f1b64b1343 100644 --- a/spec/jobs/subscription_confirm_job_spec.rb +++ b/spec/jobs/subscription_confirm_job_spec.rb @@ -16,21 +16,21 @@ describe SubscriptionConfirmJob do expect(proxy_orders).to include proxy_order end + it "returns proxy orders for paused subscriptions" do + subscription.update_attributes!(paused_at: 1.minute.ago) + expect(proxy_orders).to include proxy_order + end + + it "returns proxy orders for cancelled subscriptions" do + subscription.update_attributes!(canceled_at: 1.minute.ago) + expect(proxy_orders).to include proxy_order + end + it "ignores proxy orders where the OC closed more than 1 hour ago" do proxy_order.update_attributes!(order_cycle_id: order_cycle2.id) expect(proxy_orders).to_not include proxy_order end - it "ignores proxy orders for paused subscriptions" do - subscription.update_attributes!(paused_at: 1.minute.ago) - expect(proxy_orders).to_not include proxy_order - end - - it "ignores proxy orders for cancelled subscriptions" do - subscription.update_attributes!(canceled_at: 1.minute.ago) - expect(proxy_orders).to_not include proxy_order - end - it "ignores cancelled proxy orders" do proxy_order.update_attributes!(canceled_at: 5.minutes.ago) expect(proxy_orders).to_not include proxy_order From fed0a81159c3e805afb88ea6bedab8dde596bb74 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Thu, 24 May 2018 12:12:58 +1000 Subject: [PATCH 146/206] Add an optional link to Skylight dashboard to footer --- app/views/shared/_footer.html.haml | 10 ++++++++++ config/application.yml.example | 3 +++ config/locales/en.yml | 2 ++ 3 files changed, 15 insertions(+) diff --git a/app/views/shared/_footer.html.haml b/app/views/shared/_footer.html.haml index de352b444a..6b04cedb5a 100644 --- a/app/views/shared/_footer.html.haml +++ b/app/views/shared/_footer.html.haml @@ -142,3 +142,13 @@ .medium-2.columns.text-center / Placeholder + -if ENV['SKYLIGHT_PUBLIC_DASHBOARD_URL'].present? + .row + .small-12.medium-8.medium-offset-2.columns.text-center + %hr.hr-light + %br + + .row + .small-12.medium-8.medium-offset-2.columns.text-center + .text.small + = t :footer_skylight_dashboard_html, {dashboard: link_to('Skylight', ENV['SKYLIGHT_PUBLIC_DASHBOARD_URL'], target: "_blank")} diff --git a/config/application.yml.example b/config/application.yml.example index a554404a45..03b33f9f11 100644 --- a/config/application.yml.example +++ b/config/application.yml.example @@ -43,6 +43,9 @@ SMTP_PASSWORD: 'f00d' # optional, see: https://www.skylight.io/oss # SKYLIGHT_AUTHENTICATION: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +# Should be set if using Skylight, adds a link to Skylight dashboard to our footer +# SKYLIGHT_PUBLIC_DASHBOARD_URL: "https://oss.skylight.io/app/applications/xxxxxxxxxx" + # Stripe Connect details for instance account # Find these under 'API keys' and 'Connect' in your Stripe account dashboard -> Account Settings # Under 'Connect', the Redirect URI should be set to https://YOUR_SERVER_URL/stripe/callbacks (e.g. https://openfoodnetwork.org.uk/stripe/callbacks) diff --git a/config/locales/en.yml b/config/locales/en.yml index 817f561b64..2394977579 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1187,6 +1187,8 @@ en: footer_legal_visit: "Find us on" footer_legal_text_html: "Open Food Network is a free and open source software platform. Our content is licensed with %{content_license} and our code with %{code_license}." + footer_skylight_dashboard_html: Performance data is available on %{dashboard}. + home_shop: Shop Now brandstory_headline: "Food, unincorporated." From 3619ec0dc8d48cf69c9c720f8ffa7f125cf91b75 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Wed, 18 Apr 2018 13:06:55 +1000 Subject: [PATCH 147/206] Add is_default attribute to Spree::CreditCard model --- db/migrate/20180418025217_add_is_default_to_credit_card.rb | 5 +++++ db/schema.rb | 7 ++++--- 2 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20180418025217_add_is_default_to_credit_card.rb diff --git a/db/migrate/20180418025217_add_is_default_to_credit_card.rb b/db/migrate/20180418025217_add_is_default_to_credit_card.rb new file mode 100644 index 0000000000..c39b3e44dc --- /dev/null +++ b/db/migrate/20180418025217_add_is_default_to_credit_card.rb @@ -0,0 +1,5 @@ +class AddIsDefaultToCreditCard < ActiveRecord::Migration + def change + add_column :spree_credit_cards, :is_default, :boolean, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 5b1433fc00..787c37c227 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended to check this file into your version control system. -ActiveRecord::Schema.define(:version => 20180316034336) do +ActiveRecord::Schema.define(:version => 20180418025217) do create_table "account_invoices", :force => true do |t| t.integer "user_id", :null => false @@ -490,12 +490,13 @@ ActiveRecord::Schema.define(:version => 20180316034336) do t.string "start_year" t.string "issue_number" t.integer "address_id" - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false t.string "gateway_customer_profile_id" t.string "gateway_payment_profile_id" t.integer "user_id" t.integer "payment_method_id" + t.boolean "is_default", :default => false end add_index "spree_credit_cards", ["payment_method_id"], :name => "index_spree_credit_cards_on_payment_method_id" From 5c25e85d92f1313e9b5ca2622b8dd657df4e3d3f Mon Sep 17 00:00:00 2001 From: robotscissors Date: Thu, 7 Jun 2018 21:53:17 -0700 Subject: [PATCH 148/206] Create dark background on load --- app/assets/stylesheets/admin/welcome.css.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/admin/welcome.css.scss b/app/assets/stylesheets/admin/welcome.css.scss index fe31a07396..77cd21e134 100644 --- a/app/assets/stylesheets/admin/welcome.css.scss +++ b/app/assets/stylesheets/admin/welcome.css.scss @@ -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; From d146d3714f070c403dd19ba23fa26b5f38ad9394 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Wed, 18 Apr 2018 17:00:47 +1000 Subject: [PATCH 149/206] Add callbacks to ensure a user always has a default credit card (if any exist) --- app/models/spree/credit_card_decorator.rb | 18 ++++- spec/models/spree/credit_card_spec.rb | 80 +++++++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 spec/models/spree/credit_card_spec.rb diff --git a/app/models/spree/credit_card_decorator.rb b/app/models/spree/credit_card_decorator.rb index fd4dbc9261..a63026b0a0 100644 --- a/app/models/spree/credit_card_decorator.rb +++ b/app/models/spree/credit_card_decorator.rb @@ -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,6 +14,9 @@ Spree::CreditCard.class_eval do belongs_to :user + after_create :ensure_default + after_save :ensure_default, 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 @@ -25,4 +28,17 @@ Spree::CreditCard.class_eval do def save_requested_by_customer? !!@save_requested_by_customer end + + private + + def default_missing? + user.credit_cards.where(is_default: true).none? + end + + def ensure_default + return unless user + return unless is_default? || default_missing? + user.credit_cards.update_all(['is_default=(id=?)', id]) + self.is_default = true + end end diff --git a/spec/models/spree/credit_card_spec.rb b/spec/models/spree/credit_card_spec.rb new file mode 100644 index 0000000000..9d246944f6 --- /dev/null +++ b/spec/models/spree/credit_card_spec.rb @@ -0,0 +1,80 @@ +require 'spec_helper' + +module Spree + describe CreditCard do + describe "setting default credit card for a user" do + let(:user) { create(:user) } + + context "when a card is already set as the default" do + let!(:card1) { create(:credit_card, user: user, is_default: true) } + + context "and I create a new card" do + let(:attrs) { { user: user } } + let!(:card2) { create(:credit_card, attrs) } + + context "without specifying it as the default" do + it "keeps the existing default" do + expect(card1.reload.is_default).to be true + expect(card2.reload.is_default).to be false + end + end + + context "and I specify it as the default" do + let(:attrs) { { user: user, is_default: true } } + + it "switches the default to the new card" do + expect(card1.reload.is_default).to be false + expect(card2.reload.is_default).to be true + end + end + end + + context "and I update another card" do + let(:attrs) { { user: user } } + let!(:card2) { create(:credit_card, user: user) } + + before do + card2.update_attributes!(attrs) + end + + context "without specifying it as the default" do + it "keeps the existing default" do + expect(card1.reload.is_default).to be true + expect(card2.reload.is_default).to be false + end + end + + context "and I specify it as the default" do + let(:attrs) { { user: user, is_default: true } } + + it "switches the default to the updated card" do + expect(card1.reload.is_default).to be false + expect(card2.reload.is_default).to be true + end + end + end + end + + context "when no card is currently set as the default for a user" do + context "and I create a new card" do + let(:attrs) { { user: user } } + let!(:card1) { create(:credit_card, attrs) } + + context "without specifying it as the default" do + it "sets it as the default anyway" do + expect(card1.reload.is_default).to be true + end + end + + context "and I specify it as the default" do + let(:attrs) { { user: user, is_default: true } } + + it "sets it as the default" do + expect(card1.reload.is_default).to be true + end + end + end + end + end + end +end From 1327b9dc2c17a0b4d7329881f3fff9180178bb44 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Wed, 18 Apr 2018 18:18:26 +1000 Subject: [PATCH 150/206] Add update method to CreditCardsController --- .../spree/credit_cards_controller.rb | 16 +++++++ app/models/spree/ability_decorator.rb | 2 +- app/serializers/api/credit_card_serializer.rb | 2 +- config/locales/en.yml | 1 + .../spree/credit_cards_controller_spec.rb | 47 +++++++++++++++++++ 5 files changed, 66 insertions(+), 2 deletions(-) diff --git a/app/controllers/spree/credit_cards_controller.rb b/app/controllers/spree/credit_cards_controller.rb index 4582931dd0..9dd129bacf 100644 --- a/app/controllers/spree/credit_cards_controller.rb +++ b/app/controllers/spree/credit_cards_controller.rb @@ -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 diff --git a/app/models/spree/ability_decorator.rb b/app/models/spree/ability_decorator.rb index d9a99bce7f..5834a51afe 100644 --- a/app/models/spree/ability_decorator.rb +++ b/app/models/spree/ability_decorator.rb @@ -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 diff --git a/app/serializers/api/credit_card_serializer.rb b/app/serializers/api/credit_card_serializer.rb index 1ea2da07d5..bab0f5bc8d 100644 --- a/app/serializers/api/credit_card_serializer.rb +++ b/app/serializers/api/credit_card_serializer.rb @@ -1,6 +1,6 @@ module Api class CreditCardSerializer < ActiveModel::Serializer - attributes :id, :brand, :number, :expiry, :formatted, :delete_link + attributes :id, :brand, :number, :expiry, :formatted, :delete_link, :is_default def brand object.cc_type.capitalize diff --git a/config/locales/en.yml b/config/locales/en.yml index 0e7f441af6..8946c62b82 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1112,6 +1112,7 @@ en: # Front-end controller translations + card_could_not_be_updated: Card could not be updated card_could_not_be_saved: card could not be saved spree_gateway_error_flash_for_checkout: "There was a problem with your payment information: %{error}" diff --git a/spec/controllers/spree/credit_cards_controller_spec.rb b/spec/controllers/spree/credit_cards_controller_spec.rb index 7806f51d5c..1a907a4fda 100644 --- a/spec/controllers/spree/credit_cards_controller_spec.rb +++ b/spec/controllers/spree/credit_cards_controller_spec.rb @@ -69,6 +69,53 @@ describe Spree::CreditCardsController, type: :controller do end end + describe "#update" do + let(:params) { { format: :json, credit_card: { is_default: true } } } + context "when the specified credit card is not found" do + before { params[:id] = 123 } + + it "renders a flash error" do + put :update, params + json_response = JSON.parse(response.body) + expect(json_response['flash']['error']).to eq I18n.t(:card_could_not_be_updated) + end + end + + context "when the specified credit card is found" do + let!(:card) { create(:credit_card, gateway_customer_profile_id: 'cus_AZNMJ') } + before { params[:id] = card.id } + + context "but the card is not owned by the user" do + it "redirects to unauthorized" do + put :update, params + expect(response).to redirect_to spree.unauthorized_path + end + end + + context "and the card is owned by the user" do + before { card.update_attribute(:user_id, user.id) } + + context "when the update completes successfully" do + it "renders a serialized copy of the updated card" do + expect{ put :update, params }.to change { card.reload.is_default }.to(true) + json_response = JSON.parse(response.body) + expect(json_response['id']).to eq card.id + expect(json_response['is_default']).to eq true + end + end + + context "when the update fails" do + before { params[:credit_card][:month] = 'some illegal month' } + it "renders an error" do + put :update, params + json_response = JSON.parse(response.body) + expect(json_response['flash']['error']).to eq I18n.t(:card_could_not_be_updated) + end + end + end + end + end + describe "#destroy" do context "when the specified credit card is not found" do let(:params) { { id: 123 } } From e88e963b4cd1303f00d605bdded38202afbb539c Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Thu, 19 Apr 2018 16:28:53 +1000 Subject: [PATCH 151/206] Alter cards interface to allow updating of default card --- .../credit_cards_controller.js.coffee | 3 +- .../darkswarm/services/credit_cards.js.coffee | 13 ++++- app/views/spree/users/_saved_cards.html.haml | 3 ++ config/locales/en.yml | 2 + spec/features/consumer/account/cards_spec.rb | 33 ++++++++++-- .../services/credit_cards_spec.js.coffee | 50 +++++++++++++++++++ 6 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 spec/javascripts/unit/darkswarm/services/credit_cards_spec.js.coffee diff --git a/app/assets/javascripts/darkswarm/controllers/credit_cards_controller.js.coffee b/app/assets/javascripts/darkswarm/controllers/credit_cards_controller.js.coffee index 7fa3e4f8c4..868057b22c 100644 --- a/app/assets/javascripts/darkswarm/controllers/credit_cards_controller.js.coffee +++ b/app/assets/javascripts/darkswarm/controllers/credit_cards_controller.js.coffee @@ -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 diff --git a/app/assets/javascripts/darkswarm/services/credit_cards.js.coffee b/app/assets/javascripts/darkswarm/services/credit_cards.js.coffee index c588ef681c..d217a7a7bd 100644 --- a/app/assets/javascripts/darkswarm/services/credit_cards.js.coffee +++ b/app/assets/javascripts/darkswarm/services/credit_cards.js.coffee @@ -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}) diff --git a/app/views/spree/users/_saved_cards.html.haml b/app/views/spree/users/_saved_cards.html.haml index d8c710607b..55d4d83442 100644 --- a/app/views/spree/users/_saved_cards.html.haml +++ b/app/views/spree/users/_saved_cards.html.haml @@ -3,11 +3,14 @@ %th= t(:card_type) %th= t(:card_number) %th= t(:card_expiry_date) + %th= t(:default?) %th= t(:delete?) %tr.card{ id: "card{{ card.id }}", ng: { repeat: "card in savedCreditCards" } } %td.brand{ ng: { bind: '::card.brand' } } %td.number{ ng: { bind: '::card.number' } } %td.expiry{ ng: { bind: '::card.expiry' } } + %td.is-default + %input{ type: 'radio', name: 'default_card', ng: { model: 'card.is_default', change: 'setDefault(card)', value: "true"} } %td.actions %a{"rel" => "nofollow", "data-method" => "delete", "ng-href" => "{{card.delete_link}}" } = t(:delete) diff --git a/config/locales/en.yml b/config/locales/en.yml index 8946c62b82..00631faf55 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1232,6 +1232,7 @@ en: saving_credit_card: Saving credit card... card_has_been_removed: "Your card has been removed (number: %{number})" card_could_not_be_removed: Sorry, the card could not be removed + default?: Default? ie_warning_headline: "Your browser is out of date :-(" ie_warning_text: "For the best Open Food Network experience, we strongly recommend upgrading your browser:" @@ -2344,6 +2345,7 @@ See the %{link} to find out more about %{sitename}'s features and to start using choose: Choose resolve_errors: Please resolve the following errors more_items: "+ %{count} More" + default_card_updated: Default Card Updated admin: enterprise_limit_reached: "You have reached the standard limit of enterprises per account. Write to %{contact_email} if you need to increase it." modals: diff --git a/spec/features/consumer/account/cards_spec.rb b/spec/features/consumer/account/cards_spec.rb index 3a9e32ddd6..6412322eaa 100644 --- a/spec/features/consumer/account/cards_spec.rb +++ b/spec/features/consumer/account/cards_spec.rb @@ -4,7 +4,8 @@ feature "Credit Cards", js: true do include AuthenticationWorkflow describe "as a logged in user" do let(:user) { create(:user) } - let!(:card) { create(:credit_card, user_id: user.id, gateway_customer_profile_id: 'cus_AZNMJ') } + let!(:card) { create(:credit_card, user_id: user.id, gateway_customer_profile_id: 'cus_AZNMJ', is_default: true) } + let!(:card2) { create(:credit_card, user_id: user.id, gateway_customer_profile_id: 'cus_FDTG') } before do quick_login_as user @@ -20,28 +21,52 @@ feature "Credit Cards", js: true do to_return(:status => 200, :body => JSON.generate(deleted: true, id: "cus_AZNMJ")) end - it "lists saved cards, shows interface for adding new cards" do + it "passes the smoke test" do visit "/account" click_link I18n.t('spree.users.show.tabs.cards') expect(page).to have_content I18n.t(:saved_cards) + # Lists saved cards within(".card#card#{card.id}") do expect(page).to have_content card.cc_type.capitalize expect(page).to have_content card.last_digits + expect(find_field('default_card')).to be_checked end + within(".card#card#{card2.id}") do + expect(page).to have_content card2.cc_type.capitalize + expect(page).to have_content card2.last_digits + expect(find_field('default_card')).to_not be_checked + end + + # Allows switching of default card + within(".card#card#{card2.id}") do + find_field('default_card').click + expect(find_field('default_card')).to be_checked + end + + expect(page).to have_content I18n.t('js.default_card_updated') + + within(".card#card#{card.id}") do + expect(find_field('default_card')).to_not be_checked + end + expect(card.reload.is_default).to be false + expect(card2.reload.is_default).to be true + # Shows the interface for adding a card click_button I18n.t(:add_a_card) expect(page).to have_field 'first_name' expect(page).to have_selector '#card-element.StripeElement' # Allows deletion of cards - click_link I18n.t(:delete) + within(".card#card#{card.id}") do + click_link I18n.t(:delete) + end expect(page).to have_content I18n.t(:card_has_been_removed, number: "x-#{card.last_digits}") - expect(page).to have_content I18n.t(:you_have_no_saved_cards) + expect(page).to_not have_selector ".card#card#{card.id}" end end end diff --git a/spec/javascripts/unit/darkswarm/services/credit_cards_spec.js.coffee b/spec/javascripts/unit/darkswarm/services/credit_cards_spec.js.coffee new file mode 100644 index 0000000000..be3106f89c --- /dev/null +++ b/spec/javascripts/unit/darkswarm/services/credit_cards_spec.js.coffee @@ -0,0 +1,50 @@ +describe 'CreditCards service', -> + CreditCards = $httpBackend = RailsFlashLoader = null + + beforeEach -> + module 'Darkswarm' + module ($provide)-> + $provide.value "savedCreditCards", [] + $provide.value "railsFlash", null + null + + inject (_CreditCards_, _$httpBackend_, _RailsFlashLoader_)-> + CreditCards = _CreditCards_ + $httpBackend = _$httpBackend_ + RailsFlashLoader = _RailsFlashLoader_ + + describe "setDefault", -> + card1 = { last4: "1234", is_default: true } + card2 = { last4: "4321", is_default: false } + card3 = { last4: "5555", is_default: false } + ajax = null + + beforeEach -> + CreditCards.saved = [card1, card2, card3] + ajax = $httpBackend.expectPUT("/credit_cards/#{card2.id}") + + it "resets the default value on other cards to false", -> + CreditCards.setDefault(card2) + expect(card1.is_default).toBe false + expect(card2.is_default).toBe true + expect(card3.is_default).toBe false + + describe "when the update request succeeds", -> + beforeEach -> + spyOn(RailsFlashLoader,"loadFlash") + ajax.respond(200) + + it "loads a success flash", -> + CreditCards.setDefault(card2) + $httpBackend.flush() + expect(RailsFlashLoader.loadFlash).toHaveBeenCalledWith({success: t('js.default_card_updated')}) + + describe "when the update request fails", -> + beforeEach -> + spyOn(RailsFlashLoader,"loadFlash") + ajax.respond(400, flash: { error: 'Some error message'}) + + it "loads a error flash", -> + CreditCards.setDefault(card2) + $httpBackend.flush() + expect(RailsFlashLoader.loadFlash).toHaveBeenCalledWith({error: 'Some error message'}) From 254f0db97cd95df9866808900dc49b72cba038e7 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Fri, 4 May 2018 09:19:57 +1000 Subject: [PATCH 152/206] Automatically select the customer's default card in the checkout --- .../checkout/payment_controller.js.coffee | 4 ++++ .../features/consumer/shopping/checkout_spec.rb | 5 ++--- .../checkout/payment_controller_spec.js.coffee | 17 +++++++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 spec/javascripts/unit/darkswarm/controllers/checkout/payment_controller_spec.js.coffee diff --git a/app/assets/javascripts/darkswarm/controllers/checkout/payment_controller.js.coffee b/app/assets/javascripts/darkswarm/controllers/checkout/payment_controller.js.coffee index 04ff6ab45e..15bfd199e8 100644 --- a/app/assets/javascripts/darkswarm/controllers/checkout/payment_controller.js.coffee +++ b/app/assets/javascripts/darkswarm/controllers/checkout/payment_controller.js.coffee @@ -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] diff --git a/spec/features/consumer/shopping/checkout_spec.rb b/spec/features/consumer/shopping/checkout_spec.rb index ac2a50e411..b69a6aae58 100644 --- a/spec/features/consumer/shopping/checkout_spec.rb +++ b/spec/features/consumer/shopping/checkout_spec.rb @@ -199,10 +199,9 @@ feature "As a consumer I want to check out my cart", js: true, retry: 3 do # shows the saved credit card dropdown expect(page).to have_content I18n.t("spree.checkout.payment.stripe.used_saved_card") - # removes the input fields when a saved card is selected" - expect(page).to have_selector "#card-element.StripeElement" - select "Visa x-1111 Exp:01/2025", from: "selected_card" + # default card is selected, form element is not shown expect(page).to_not have_selector "#card-element.StripeElement" + expect(page).to have_select 'selected_card', selected: "Visa x-1111 Exp:01/2025" # allows checkout place_order diff --git a/spec/javascripts/unit/darkswarm/controllers/checkout/payment_controller_spec.js.coffee b/spec/javascripts/unit/darkswarm/controllers/checkout/payment_controller_spec.js.coffee new file mode 100644 index 0000000000..46b152b00a --- /dev/null +++ b/spec/javascripts/unit/darkswarm/controllers/checkout/payment_controller_spec.js.coffee @@ -0,0 +1,17 @@ +describe "PaymentCtrl", -> + ctrl = null + scope = null + card1 = { id: 1, is_default: false } + card2 = { id: 3, is_default: true } + cards = [card1, card2] + + beforeEach -> + module("Darkswarm") + angular.module('Darkswarm').value('savedCreditCards', cards) + inject ($controller, $rootScope) -> + scope = $rootScope.$new() + scope.secrets = {} + ctrl = $controller 'PaymentCtrl', {$scope: scope} + + it "sets the default card id as the selected_card", -> + expect(scope.secrets.selected_card).toEqual card2.id From 05e4d9007e918bea30cd3d2aede0cd172266ebec Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Fri, 11 May 2018 10:52:40 +1000 Subject: [PATCH 153/206] Update card spec to make the initial roles of cards clearer --- spec/features/consumer/account/cards_spec.rb | 30 ++++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/spec/features/consumer/account/cards_spec.rb b/spec/features/consumer/account/cards_spec.rb index 6412322eaa..2f78511135 100644 --- a/spec/features/consumer/account/cards_spec.rb +++ b/spec/features/consumer/account/cards_spec.rb @@ -4,8 +4,8 @@ feature "Credit Cards", js: true do include AuthenticationWorkflow describe "as a logged in user" do let(:user) { create(:user) } - let!(:card) { create(:credit_card, user_id: user.id, gateway_customer_profile_id: 'cus_AZNMJ', is_default: true) } - let!(:card2) { create(:credit_card, user_id: user.id, gateway_customer_profile_id: 'cus_FDTG') } + let!(:default_card) { create(:credit_card, user_id: user.id, gateway_customer_profile_id: 'cus_AZNMJ', is_default: true) } + let!(:non_default_card) { create(:credit_card, user_id: user.id, gateway_customer_profile_id: 'cus_FDTG') } before do quick_login_as user @@ -29,31 +29,31 @@ feature "Credit Cards", js: true do expect(page).to have_content I18n.t(:saved_cards) # Lists saved cards - within(".card#card#{card.id}") do - expect(page).to have_content card.cc_type.capitalize - expect(page).to have_content card.last_digits + within(".card#card#{default_card.id}") do + expect(page).to have_content default_card.cc_type.capitalize + expect(page).to have_content default_card.last_digits expect(find_field('default_card')).to be_checked end - within(".card#card#{card2.id}") do - expect(page).to have_content card2.cc_type.capitalize - expect(page).to have_content card2.last_digits + within(".card#card#{non_default_card.id}") do + expect(page).to have_content non_default_card.cc_type.capitalize + expect(page).to have_content non_default_card.last_digits expect(find_field('default_card')).to_not be_checked end # Allows switching of default card - within(".card#card#{card2.id}") do + within(".card#card#{non_default_card.id}") do find_field('default_card').click expect(find_field('default_card')).to be_checked end expect(page).to have_content I18n.t('js.default_card_updated') - within(".card#card#{card.id}") do + within(".card#card#{default_card.id}") do expect(find_field('default_card')).to_not be_checked end - expect(card.reload.is_default).to be false - expect(card2.reload.is_default).to be true + expect(default_card.reload.is_default).to be false + expect(non_default_card.reload.is_default).to be true # Shows the interface for adding a card click_button I18n.t(:add_a_card) @@ -61,12 +61,12 @@ feature "Credit Cards", js: true do expect(page).to have_selector '#card-element.StripeElement' # Allows deletion of cards - within(".card#card#{card.id}") do + within(".card#card#{default_card.id}") do click_link I18n.t(:delete) end - expect(page).to have_content I18n.t(:card_has_been_removed, number: "x-#{card.last_digits}") - expect(page).to_not have_selector ".card#card#{card.id}" + expect(page).to have_content I18n.t(:card_has_been_removed, number: "x-#{default_card.last_digits}") + expect(page).to_not have_selector ".card#card#{default_card.id}" end end end From 5dbf98f39b40c7e9f45b154be81ab83c31e8489c Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Fri, 11 May 2018 10:56:39 +1000 Subject: [PATCH 154/206] Use exists? method to avoid unnecesary loading of credit card array --- app/models/spree/credit_card_decorator.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/spree/credit_card_decorator.rb b/app/models/spree/credit_card_decorator.rb index a63026b0a0..eab507b994 100644 --- a/app/models/spree/credit_card_decorator.rb +++ b/app/models/spree/credit_card_decorator.rb @@ -32,7 +32,7 @@ Spree::CreditCard.class_eval do private def default_missing? - user.credit_cards.where(is_default: true).none? + !user.credit_cards.exists?(is_default: true) end def ensure_default From 6a202d94463b4a225719a5179fbce2f6309d4c51 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Fri, 11 May 2018 11:00:02 +1000 Subject: [PATCH 155/206] Use more descriptive name for after_save callback method: :ensure_single_default_card --- app/models/spree/credit_card_decorator.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/models/spree/credit_card_decorator.rb b/app/models/spree/credit_card_decorator.rb index eab507b994..e82dea3443 100644 --- a/app/models/spree/credit_card_decorator.rb +++ b/app/models/spree/credit_card_decorator.rb @@ -14,8 +14,8 @@ Spree::CreditCard.class_eval do belongs_to :user - after_create :ensure_default - after_save :ensure_default, if: :is_default_changed? + 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 @@ -35,7 +35,7 @@ Spree::CreditCard.class_eval do !user.credit_cards.exists?(is_default: true) end - def ensure_default + def ensure_single_default_card return unless user return unless is_default? || default_missing? user.credit_cards.update_all(['is_default=(id=?)', id]) From 18c211e97c642210c18156ad1e17effe59955021 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Thu, 17 May 2018 17:59:43 +1000 Subject: [PATCH 156/206] Ensure that savedCreditCards always exists --- .../controllers/checkout/payment_controller.js.coffee | 2 +- app/helpers/injection_helper.rb | 8 +++++--- app/views/spree/checkout/payment/_stripe.html.haml | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/darkswarm/controllers/checkout/payment_controller.js.coffee b/app/assets/javascripts/darkswarm/controllers/checkout/payment_controller.js.coffee index 15bfd199e8..261bf9759a 100644 --- a/app/assets/javascripts/darkswarm/controllers/checkout/payment_controller.js.coffee +++ b/app/assets/javascripts/darkswarm/controllers/checkout/payment_controller.js.coffee @@ -9,7 +9,7 @@ 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 + for card in savedCreditCards when card.is_default $scope.secrets.selected_card = card.id break diff --git a/app/helpers/injection_helper.rb b/app/helpers/injection_helper.rb index 5b6186ff3e..ea5589abe6 100644 --- a/app/helpers/injection_helper.rb +++ b/app/helpers/injection_helper.rb @@ -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 diff --git a/app/views/spree/checkout/payment/_stripe.html.haml b/app/views/spree/checkout/payment/_stripe.html.haml index 3c1bc0c35e..eace9f00a7 100644 --- a/app/views/spree/checkout/payment/_stripe.html.haml +++ b/app/views/spree/checkout/payment/_stripe.html.haml @@ -1,4 +1,4 @@ -.row{ "ng-show" => "savedCreditCards != null && savedCreditCards.length > 0" } +.row{ "ng-show" => "savedCreditCards.length > 0" } .small-12.columns %h6= t('.used_saved_card') %select{ name: "selected_card", required: false, ng: { model: "secrets.selected_card", options: "card.id as card.formatted for card in savedCreditCards" } } From 5cbc4cbf30f0e320e4715256a2edb60886e6900e Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Fri, 8 Jun 2018 10:53:30 +1000 Subject: [PATCH 157/206] Use scoped keys for table headers in saved card partial --- app/views/spree/users/_saved_cards.html.haml | 4 ++-- config/locales/en.yml | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/views/spree/users/_saved_cards.html.haml b/app/views/spree/users/_saved_cards.html.haml index 55d4d83442..d37d6c6a86 100644 --- a/app/views/spree/users/_saved_cards.html.haml +++ b/app/views/spree/users/_saved_cards.html.haml @@ -3,8 +3,8 @@ %th= t(:card_type) %th= t(:card_number) %th= t(:card_expiry_date) - %th= t(:default?) - %th= t(:delete?) + %th= t('.default?') + %th= t('.delete?') %tr.card{ id: "card{{ card.id }}", ng: { repeat: "card in savedCreditCards" } } %td.brand{ ng: { bind: '::card.brand' } } %td.number{ ng: { bind: '::card.number' } } diff --git a/config/locales/en.yml b/config/locales/en.yml index 00631faf55..2a38a62e36 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1232,7 +1232,6 @@ en: saving_credit_card: Saving credit card... card_has_been_removed: "Your card has been removed (number: %{number})" card_could_not_be_removed: Sorry, the card could not be removed - default?: Default? ie_warning_headline: "Your browser is out of date :-(" ie_warning_text: "For the best Open Food Network experience, we strongly recommend upgrading your browser:" @@ -2732,5 +2731,8 @@ See the %{link} to find out more about %{sitename}'s features and to start using total: Total paid?: Paid? view: View + saved_cards: + default?: Default? + delete?: Delete? localized_number: invalid_format: has an invalid format. Please enter a number. From fed60cf9619897175bf595893ec29acb42cb4a3a Mon Sep 17 00:00:00 2001 From: Pau Perez Date: Fri, 8 Jun 2018 10:01:04 +0200 Subject: [PATCH 158/206] Ask devs to tell whether we should test on mobile --- .github/PULL_REQUEST_TEMPLATE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7c620167cc..7468fd5248 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -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 From 9e0732ceaeeee97b19b88ce4032db908e134df39 Mon Sep 17 00:00:00 2001 From: luisramos0 Date: Wed, 30 May 2018 00:37:33 +0100 Subject: [PATCH 159/206] removed uppercase from state on shops and producers lists in the frontoffice --- app/views/producers/_skinny.html.haml | 2 +- app/views/shops/_skinny.html.haml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/views/producers/_skinny.html.haml b/app/views/producers/_skinny.html.haml index 28fbd5439f..8c0a71e7e3 100644 --- a/app/views/producers/_skinny.html.haml +++ b/app/views/producers/_skinny.html.haml @@ -18,7 +18,7 @@ .columns.small-6.medium-2.large-2 %span.margin-top{"ng-bind" => "::producer.address.city"} .columns.small-4.medium-1.large-1 - %span.margin-top{"ng-bind" => "::producer.address.state_name | uppercase"} + %span.margin-top{"ng-bind" => "::producer.address.state_name"} .columns.small-2.medium-1.large-1.text-right %span.margin-top %i{"ng-class" => "{'ofn-i_005-caret-down' : !open(), 'ofn-i_006-caret-up' : open()}"} diff --git a/app/views/shops/_skinny.html.haml b/app/views/shops/_skinny.html.haml index a6603236e1..1a5627bd26 100644 --- a/app/views/shops/_skinny.html.haml +++ b/app/views/shops/_skinny.html.haml @@ -7,7 +7,7 @@ .columns.small-4.medium-2.large-2 %span.margin-top{"ng-bind" => "::hub.address.city"} .columns.small-2.medium-1.large-1 - %span.margin-top{"ng-bind" => "::hub.address.state_name | uppercase"} + %span.margin-top{"ng-bind" => "::hub.address.state_name"} %span.margin-top{"ng-if" => "hub.distance != null && hub.distance > 0"} ({{ hub.distance / 1000 | number:0 }} km) .columns.small-4.medium-3.large-3.text-right{"ng-if" => "::hub.active"} @@ -39,7 +39,7 @@ .columns.small-4.medium-2.large-2 %span.margin-top{"ng-bind" => "::hub.address.city"} .columns.small-2.medium-1.large-1 - %span.margin-top{"ng-bind" => "::hub.address.state_name | uppercase"} + %span.margin-top{"ng-bind" => "::hub.address.state_name"} .columns.small-6.medium-3.large-4.text-right %span.margin-top{ ng: { if: "::!current()" } } From 7bef474efda2d0c5f865f4a22785a026b11060d3 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Sat, 9 Jun 2018 02:24:32 +0100 Subject: [PATCH 160/206] Admin Order guest checkout status --- .../orders/customer_details_controller_decorator.rb | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/controllers/spree/admin/orders/customer_details_controller_decorator.rb b/app/controllers/spree/admin/orders/customer_details_controller_decorator.rb index 19dd3aae65..a8ce4dad59 100644 --- a/app/controllers/spree/admin/orders/customer_details_controller_decorator.rb +++ b/app/controllers/spree/admin/orders/customer_details_controller_decorator.rb @@ -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 From d90e362e6fde187d27090cf514003a3070573826 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Sat, 9 Jun 2018 02:41:45 +0100 Subject: [PATCH 161/206] Remove guest checkout radio buttons from admin order view --- .../admin/orders/directives/customer_search_override.js.coffee | 3 --- .../_form/make_email_fullwidth.html.haml.deface | 2 ++ .../customer_details/_form/remove_guest_order_buttons.deface | 1 + 3 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 app/overrides/spree/admin/orders/customer_details/_form/make_email_fullwidth.html.haml.deface create mode 100644 app/overrides/spree/admin/orders/customer_details/_form/remove_guest_order_buttons.deface diff --git a/app/assets/javascripts/admin/orders/directives/customer_search_override.js.coffee b/app/assets/javascripts/admin/orders/directives/customer_search_override.js.coffee index 08d4ceeb5d..76200d92ff 100644 --- a/app/assets/javascripts/admin/orders/directives/customer_search_override.js.coffee +++ b/app/assets/javascripts/admin/orders/directives/customer_search_override.js.coffee @@ -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 diff --git a/app/overrides/spree/admin/orders/customer_details/_form/make_email_fullwidth.html.haml.deface b/app/overrides/spree/admin/orders/customer_details/_form/make_email_fullwidth.html.haml.deface new file mode 100644 index 0000000000..409c3c90ab --- /dev/null +++ b/app/overrides/spree/admin/orders/customer_details/_form/make_email_fullwidth.html.haml.deface @@ -0,0 +1,2 @@ +/ set_attributes "div[data-hook='customer_fields'] div.alpha" +/ attributes({class: "fullwidth"}) diff --git a/app/overrides/spree/admin/orders/customer_details/_form/remove_guest_order_buttons.deface b/app/overrides/spree/admin/orders/customer_details/_form/remove_guest_order_buttons.deface new file mode 100644 index 0000000000..f94267be6f --- /dev/null +++ b/app/overrides/spree/admin/orders/customer_details/_form/remove_guest_order_buttons.deface @@ -0,0 +1 @@ +remove "div[data-hook='customer_fields'] div.omega" From c7f7541e2dbd0866aaa47ae9111d17a5c7978239 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Sun, 10 Jun 2018 02:32:59 +0100 Subject: [PATCH 162/206] Admin order customer details spec --- .../customer_details_controller_spec.rb | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 spec/controllers/spree/admin/orders/customer_details_controller_spec.rb diff --git a/spec/controllers/spree/admin/orders/customer_details_controller_spec.rb b/spec/controllers/spree/admin/orders/customer_details_controller_spec.rb new file mode 100644 index 0000000000..cd1a04a4dd --- /dev/null +++ b/spec/controllers/spree/admin/orders/customer_details_controller_spec.rb @@ -0,0 +1,65 @@ +require 'spec_helper' + +describe Spree::Admin::Orders::CustomerDetailsController, type: :controller do + include AuthenticationWorkflow + + describe "#update" do + context "adding customer details via newly created admin order" do + let!(:user) { create(:user) } + let(:address) { create(:address) } + let!(:distributor) { create(:distributor_enterprise) } + let!(:shipping_method) { create(:shipping_method) } + let!(:order) { + create( + :order_with_totals_and_distribution, + state: 'cart', + shipping_method: shipping_method, + distributor: distributor, + user: nil, + email: nil, + bill_address: nil, + ship_address: nil, + ) + } + let(:address_params) { + { + firstname: address.firstname, + lastname: address.lastname, + address1: address.address1, + address2: address.address2, + city: address.city, + zipcode: address.zipcode, + country_id: address.country_id, + state_id: address.state_id, + phone: address.phone + } + } + + before do + login_as_enterprise_user [order.distributor] + end + + it "accepts registered users" do + spree_post :update, order: { email: user.email, bill_address_attributes: address_params, ship_address_attributes: address_params }, order_id: order.number + + order.reload + + expect(response).to redirect_to spree.edit_admin_order_shipment_path(order, order.shipment) + expect(order.email).to eq user.email + expect(order.user_id).to eq user.id + expect(order.ship_address).to_not be_nil + end + + it "accepts unregistered users" do + spree_post :update, order: { email: 'unregistered@email.com', bill_address_attributes: address_params, ship_address_attributes: address_params }, order_id: order.number + + order.reload + + expect(response).to redirect_to spree.edit_admin_order_shipment_path(order, order.shipment) + expect(order.email).to eq 'unregistered@email.com' + expect(order.user_id).to be_nil + expect(order.ship_address).to_not be_nil + end + end + end +end From 56040685a08fa5456b6b9f445764ab7ebe495f84 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Fri, 8 Jun 2018 17:11:09 +1000 Subject: [PATCH 163/206] Add Matt, Pau and Enrico to the contributors list on the README --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 47cd122f84..018e605ad7 100644 --- a/README.md +++ b/README.md @@ -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 From 78e59d059a5b5418f26f26ca0c2ef0eb2cc25885 Mon Sep 17 00:00:00 2001 From: Pau Perez Date: Mon, 11 Jun 2018 19:33:08 +0200 Subject: [PATCH 164/206] Show product import's UI only to superadmins We still need to iterate on its implementation and it's too early to make it publicly available. --- .../admin/products_controller_decorator.rb | 2 ++ app/models/feature_flags.rb | 28 +++++++++++++++++++ .../products/bulk_edit/_filters.html.haml | 13 +++++---- spec/models/feature_flags_spec.rb | 28 +++++++++++++++++++ 4 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 app/models/feature_flags.rb create mode 100644 spec/models/feature_flags_spec.rb diff --git a/app/controllers/spree/admin/products_controller_decorator.rb b/app/controllers/spree/admin/products_controller_decorator.rb index 7d58e94dfe..d2f715712e 100644 --- a/app/controllers/spree/admin/products_controller_decorator.rb +++ b/app/controllers/spree/admin/products_controller_decorator.rb @@ -5,6 +5,7 @@ Spree::Admin::ProductsController.class_eval do include OpenFoodNetwork::SpreeApiKeyLoader include OrderCyclesHelper include EnterprisesHelper + before_filter :load_form_data, :only => [:bulk_edit, :new, :create, :edit, :update] before_filter :load_spree_api_key, :only => [:bulk_edit, :variant_overrides] before_filter :strip_new_properties, only: [:create, :update] @@ -25,6 +26,7 @@ Spree::Admin::ProductsController.class_eval do end def bulk_edit + @current_user = spree_current_user @show_latest_import = params[:latest_import] || false end diff --git a/app/models/feature_flags.rb b/app/models/feature_flags.rb new file mode 100644 index 0000000000..aaba0f4472 --- /dev/null +++ b/app/models/feature_flags.rb @@ -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 diff --git a/app/views/spree/admin/products/bulk_edit/_filters.html.haml b/app/views/spree/admin/products/bulk_edit/_filters.html.haml index 72458b3391..9c08c9adda 100644 --- a/app/views/spree/admin/products/bulk_edit/_filters.html.haml +++ b/app/views/spree/admin/products/bulk_edit/_filters.html.haml @@ -12,12 +12,13 @@ %label{ for: 'category_filter' }= t 'category' %br %select.fullwidth{ id: 'category_filter', 'ofn-select2-min-search' => 5, ng: {model: 'categoryFilter', options: 'taxon.id as taxon.name for taxon in filterTaxons'} } - .filter_select.three.columns - %label{ for: 'import_filter' } Import Date - %br - %select.fullwidth{ id: 'import_date_filter', 'ofn-select2-min-search' => 5, ng: {model: 'importDateFilter', init: "importDates = #{@import_dates}; showLatestImport = #{@show_latest_import}"}} - %option{value: "{{date.id}}", ng: {repeat: "date in importDates track by date.id" }} - {{date.name}} + - if FeatureFlags.new(@current_user).product_import_enabled? + .filter_select.three.columns + %label{ for: 'import_filter' } Import Date + %br + %select.fullwidth{ id: 'import_date_filter', 'ofn-select2-min-search' => 5, ng: {model: 'importDateFilter', init: "importDates = #{@import_dates}; showLatestImport = #{@show_latest_import}"}} + %option{value: "{{date.id}}", ng: {repeat: "date in importDates track by date.id" }} + {{date.name}} .filter_clear.three.columns.omega %label{ for: 'clear_all_filters' } diff --git a/spec/models/feature_flags_spec.rb b/spec/models/feature_flags_spec.rb new file mode 100644 index 0000000000..3a2d06da30 --- /dev/null +++ b/spec/models/feature_flags_spec.rb @@ -0,0 +1,28 @@ +require 'spec_helper' + +describe FeatureFlags do + describe '.product_import_enabled?' do + let(:user) { build_stubbed(:user) } + let(:feature_flags) { described_class.new(user) } + + context 'when the user is superadmin' do + before do + allow(user).to receive(:has_spree_role?).with('admin') { true } + end + + it 'returns true' do + expect(feature_flags.product_import_enabled?).to eq(true) + end + end + + context 'when the user is not superadmin' do + before do + allow(user).to receive(:has_spree_role?).with('admin') { false } + end + + it 'returns false' do + expect(feature_flags.product_import_enabled?).to eq(false) + end + end + end +end From 6908635822a7593523f0d034498b2ed8f1a382ca Mon Sep 17 00:00:00 2001 From: Transifex-Openfoodnetwork Date: Tue, 12 Jun 2018 19:36:28 +1000 Subject: [PATCH 165/206] Updating translations for config/locales/pt.yml --- config/locales/pt.yml | 84 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 4 deletions(-) diff --git a/config/locales/pt.yml b/config/locales/pt.yml index e25c72939b..f071628b51 100644 --- a/config/locales/pt.yml +++ b/config/locales/pt.yml @@ -237,6 +237,9 @@ pt: form_invalid: "O formulário contém campos incompletos ou inválidos" clear_filters: Limpar filtros clear: Limpar + save: Guardar + cancel: Cancelar + back: Voltar show_more: Mostrar mais show_n_more: Mostrar mais %{num} choose: "Escolher..." @@ -283,7 +286,7 @@ pt: edit: business_model_configuration: "Modelo de Negócio" business_model_configuration_tip: "Configure a taxa a que as lojas serão cobradas mensalmente por usar o Open Food Network" - bill_calculation_settings: "Configurações de cálculo de conta" + bill_calculation_settings: "Configurações de Cálculo de Conta" bill_calculation_settings_tip: "Configure a quantia que será cobrada mensalmente às organizações pela utilização da OFN" shop_trial_length: "Duração da Loja Experimental (Dias)" shop_trial_length_tip: "Duração (em número de dias) do período experimental para as organizações definidas como lojas." @@ -376,6 +379,7 @@ pt: inherits_properties?: Herda Propriedades? available_on: Disponível em av_on: "Disp. em" + import_date: Importado upload_an_image: Carregar uma imagem product_search_keywords: Palavras-chave para Pesquisa de Produto product_search_tip: Insira palavras que ajudem a encontrar os seus produtos nas lojas. Use um espaço para separar cada palavra-chave @@ -390,6 +394,75 @@ pt: product_distributions: "Distribuições de Produto" group_buy_options: "Opções de Compra Colectiva" back_to_products_list: "Voltar à lista de produtos" + product_import: + title: Importação de Produtos + file_not_found: O ficheiro não foi encontrado ou não pôde ser aberto + no_data: Não foram encontrados dados na tabela + confirm_reset: "Isto colocará o nível de stock a zero em todos os produtos desta \n organização que não estão presentes no ficheiro carregado" + model: + no_file: "erro: nenhum ficheiro foi carregado" + could_not_process: "não foi possível processar o ficheiro: tipo de ficheiro inválido" + incorrect_value: valor incorrecto + conditional_blank: não pode ser vazio se tipo de unidade é vazio + no_product: não foram encontrados produtos + not_found: não encontrado + blank: não pode ser vazio + products_no_permission: não tem permissões para gerir produtos desta organização + inventory_no_permission: não tem permissões para criar inventário para este produtor + none_saved: não gravou nenhum produto com sucesso + line: Linha + index: + select_file: Selecione uma folha de cálculo para carregar + spreadsheet: Folha de cálculo + import_into: "Importar para:" + product_list: Lista de produtos + inventories: Inventários + import: Importar + upload: Carregar + import: + review: Rever + proceed: Continuar + save: Guardar + results: Resultados + save_imported: Guardar produtos importados + no_valid_entries: Não foram encontradas entradas válidas + none_to_save: Não foram encontradas entradas que possam ser guardadas + some_invalid_entries: Ficheiro importado contém algumas entradas inválidas + save_valid?: Guardar entradas válidas e descartar as outras? + no_errors: Nenhum erro detectado. + save_all_imported?: Guardar todos os produtos importados? + options_and_defaults: Importar opções e valores por defeito + no_permission: não tem permissões para gerir esta organização + not_found: organização não encontrada + no_name: Sem nome + blank_supplier: alguns produtos têm o nome do fornecedor vazio + reset_absent?: Restabelecer produtos ausentes? + overwrite_all: Substituir todos + overwrite_empty: Substituir se vazio + default_stock: Definir nível de stock + default_tax_cat: Definir categoria de imposto + default_shipping_cat: Definir categoria de envio + default_available_date: Definir data de disponibilidade + validation_overview: Visão geral da validação da importação + entries_found: Entradas encontradas no ficheiro importado + entries_with_errors: Entradas contêm erros e não serão importados + products_to_create: Produtos serão criados + products_to_update: Produtos serão actualizados + inventory_to_create: Itens de inventário serão criados + inventory_to_update: Itens de inventário serão actualizados + products_to_reset: Produtos existentes terão o nível de stock restabelecido a zero + inventory_to_reset: Itens de inventário existentes terão o nível de stock restabelecido a zero + line: Linha + item_line: Linha de item + save: + final_results: Importar resultados finais + products_created: Produtos criados + products_updated: Produtos actualizados + inventory_created: Itens de inventário criados + inventory_updated: Itens de inventário actualizados + products_reset: Os produtos tiveram o nível de stock restabelecido a zero + inventory_reset: Itens de inventário tiveram o nível de stock restabelecido a zero + all_saved: "Todos os itens guardados com sucesso" variant_overrides: loading_flash: loading_inventory: A CARREGAR INVENTÁRIO... @@ -400,6 +473,7 @@ pt: inherit?: Herdar? add: Adicionar hide: Ocultar + import_date: Importado select_a_shop: Seleccionar Uma Loja review_now: Rever Agora new_products_alert_message: Há %{new_product_count} novos produtos disponíveis para adicionar ao seu inventário. @@ -439,7 +513,7 @@ pt: current_fulfilled_units: "Unidades Completas no Momento" max_fulfilled_units: "Máximo de Unidades Completas " order_error: "Alguns erros devem ser corrigidos antes de actualizar encomendas.\nOs campos com bordas vermelhas contêm erros." - variants_without_unit_value: "AVISO: Algumas variantes não possuem unidade de valor" + variants_without_unit_value: "AVISO: Algumas variantes não possuem valor unitário" select_variant: "Selecionar uma variante" enterprise: select_outgoing_oc_products_from: Selecione produtos de saída do ciclo de encomendas @@ -722,7 +796,7 @@ pt: name: Nome orders_open: Encomendas abrem às coordinator: Coordenador - order_closes: Encomendas fecham + orders_close: Encomendas fecham row: suppliers: fornecedores distributors: distribuidores @@ -1760,7 +1834,7 @@ pt: spree_admin_single_enterprise_alert_mail_sent: "Enviamos um email para" spree_admin_overview_action_required: "Ação Requerida" spree_admin_overview_check_your_inbox: "Por favor, verifique a sua caixa de correio para mais instruções. Obrigada!" - spree_admin_unit_value: Valor unitário + spree_admin_unit_value: Valor Unitário spree_admin_unit_description: Descrição Unitária spree_admin_variant_unit: Unidade variante spree_admin_variant_unit_scale: Escala de unidade variante @@ -2446,5 +2520,7 @@ pt: total: Total paid?: Pago? view: Ver + saved_cards: + delete?: Apagar? localized_number: invalid_format: tem um formato inválido. Por favor introduza um número. From 03fb5e7ccf5c893a75f4811e79e4dab01d8aa0d3 Mon Sep 17 00:00:00 2001 From: Transifex-Openfoodnetwork Date: Tue, 12 Jun 2018 20:03:03 +1000 Subject: [PATCH 166/206] Updating translations for config/locales/fr.yml --- config/locales/fr.yml | 79 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 2006bc146b..50cdf5e37f 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -237,6 +237,9 @@ fr: form_invalid: "Le formulaire contient des champs manquants ou invalides" clear_filters: Annuler les filtres clear: Annuler + save: Sauvergarder + cancel: Annuler + back: Retour show_more: Afficher plus show_n_more: Montrer + %{num} choose: "Choisir..." @@ -376,6 +379,7 @@ fr: inherits_properties?: Hériter des propriétés? available_on: Disponible via av_on: "Disp. via" + import_date: Importé upload_an_image: Importer une image product_search_keywords: Mots-clés de recherche produits product_search_tip: Saisissez des mots qui peuvent simplifier la recherche de vo produits dans les boutiques. Laissez un espace entre chaque mot-clé. @@ -390,6 +394,76 @@ fr: product_distributions: "Lieux de distribution" group_buy_options: "Options d'achat par lot" back_to_products_list: "Retour à la liste produits" + product_import: + title: Import liste produits + file_not_found: Fichier non trouvé ou impossible à ouvrir + no_data: Aucune donnée trouvée dans le tableau + confirm_reset: "Cette action remettra tous les niveaux de stock à zero pour cette\nentreprise pour les produits non présents dans ce fichier." + model: + no_file: "erreur : aucun document importé" + could_not_process: "impossible de traiter le fichier : type de fichier invalide" + incorrect_value: Valeur non conforme + conditional_blank: ne peut pas être vide si unit_type est vide + no_product: n'a pu être associé à aucun produit existant dans la base de données + not_found: non trouvé dans le base de données + blank: Champ obligatoire + products_no_permission: vous n'avez pas l'autorisation de gérer les produits de cette entreprise + inventory_no_permission: vous n'avez pas l'autorisation d'ajouter les produits de ce producteur à votre catalogue boutique + none_saved: n'a pu sauvegarder aucun produit :-( + line: Ligne + index: + select_file: Sélectionner le fichier (tableur sous format csv) à importer + spreadsheet: Tableur csv + import_into: "Importer dans:" + product_list: Catalogues produits des producteurs + inventories: Catalogues boutiques des hubs distributeurs + import: Importer + upload: Télécharger + import: + review: Vérifier + proceed: Continuer + save: Sauvergarder + results: Résultats + save_imported: Sauvegarder les produits importés + no_valid_entries: Aucune entrée valide trouvée + none_to_save: Il n'y a pas aucune information pouvant être sauvegardée + some_invalid_entries: 'Le fichier importé contient des données non-conformes ' + save_valid?: Sauvegarder les informations valides et détruire les autres? + no_errors: Aucune erreur détectée! + save_all_imported?: Sauvegarder tous les produits importés? + options_and_defaults: Options d'import + no_permission: vous n'avez pas l'autorisation de gérer cette entreprise + not_found: l'entreprise n'a pas été trouvée dans la base de données + no_name: Pas de nom + blank_supplier: certains produits ne sont associés à aucun fournisseur + reset_absent?: Mettre à zéro le produits absents du fichier? + overwrite_all: Modifier pour tous + overwrite_empty: Modifier si vide + default_stock: Indiquer niveau de stock + default_tax_cat: Indiquer taux de taxe + default_shipping_cat: Indiquer condition de transport + default_available_date: Indiquer date de disponibilité + validation_overview: Aperçu des entrées produits créées/modifiées + entries_found: Informations trouvées dans le fichier importé + entries_with_errors: Certaines lignes contiennent des erreurs et les produits correspondant ne seront pas importés + products_to_create: Ces produits vont être crées + products_to_update: Ces produits vont être mis à jour + inventory_to_create: Ces produits vont être ajoutés au catalogue boutique + inventory_to_update: Les informations de ces produits dans le catalogue boutique vont être mises à jour + products_to_reset: Le stock des produits existants va être remis à zero + inventory_to_reset: Dans le catalogue boutique, le stock des produits existants va être remis à zéro + line: Ligne + item_line: Ligne produit concernée + save: + final_results: Importer les informations produits confirmées + products_created: produits crées + products_updated: produits mis à jour + inventory_created: produits ajoutés dans le catalogue boutique + inventory_updated: produits mis à jour dans le catalogue boutique + products_reset: produits ont vu leur niveau de stock remis à zéro + inventory_reset: produits ont vu leur niveau de stock remis à zéro dans le catalogue boutique + all_saved: "Tous les produits ont été sauvegardés avec succès" + some_saved: "produits sauvegardés avec succès" variant_overrides: loading_flash: loading_inventory: Catalogue boutique en cours de chargement... @@ -400,6 +474,7 @@ fr: inherit?: Hériter? add: Ajouter hide: Masquer + import_date: Importé select_a_shop: Choisir une boutique review_now: Vérifier maintenant new_products_alert_message: Il y a %{new_product_count} nouveaux produits disponibles pouvant être ajoutés à votre catalogue. @@ -722,7 +797,7 @@ fr: name: Nom orders_open: Commandes à partir de coordinator: Coordinateur - order_closes: Commandes jusqu'au + orders_close: Commandes jusqu'au row: suppliers: fournisseurs distributors: hubs-distributeurs @@ -2247,7 +2322,7 @@ fr: product_import: confirmation: | Cette action remettra tous les niveaux de stock à zero pour cette - entreprises pour les produits non présents dans ce fichier. + entreprise pour les produits non présents dans ce fichier. order_cycles: update_success: 'Votre cycle de vente a été mis à jour.' no_distributors: Il n'y a pas de distributeur pour ce cycle de vente. Il ne sera pas visible aux acheteurs tant qu'il n'y aura pas de distributeur. Voulez-vous tout de même sauvegarder ce cycle de vente ? From 644c2e87883dbd99e662e0a2bcf98a500d39ce7a Mon Sep 17 00:00:00 2001 From: Transifex-Openfoodnetwork Date: Tue, 12 Jun 2018 20:33:43 +1000 Subject: [PATCH 167/206] Updating translations for config/locales/nb.yml --- config/locales/nb.yml | 154 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 146 insertions(+), 8 deletions(-) diff --git a/config/locales/nb.yml b/config/locales/nb.yml index cf94cab202..27ec19ef78 100644 --- a/config/locales/nb.yml +++ b/config/locales/nb.yml @@ -8,6 +8,10 @@ nb: completed_at: Fullført på number: Nummer email: Epost Kunde + spree/payment: + amount: Beløp + order_cycle: + orders_close_at: Lukkedato errors: models: spree/user: @@ -16,6 +20,10 @@ nb: taken: "Det finnes allerede en konto for denne eposten. Vennligst logg inn eller tilbakestill passordet ditt." spree/order: no_card: Det er ingen gyldige kredittkort tilgjengelig + order_cycle: + attributes: + orders_close_at: + after_orders_open_at: må være etter åpningsdato activemodel: errors: models: @@ -53,11 +61,17 @@ nb: Ugyldig epost eller passord. Var du gjest forrige gang? Kanskje du må opprette en konto eller nullstille passordet. unconfirmed: "Du må bekrefte kontoen din før du fortsetter." + already_registered: "Denne epostadressen er allerede registrert. Vennligst logg inn for å fortsette, eller gå tilbake og bruk en annen epostadresse." + user_passwords: + spree_user: + updated_not_active: "Ditt passord har blitt tilbakestilt, men epostadressen din er ikke bekreftet enda." enterprise_mailer: confirmation_instructions: subject: "Vennligst bekreft epostadressen til %{enterprise}" welcome: subject: "%{enterprise} er nå på %{sitename}" + invite_manager: + subject: "%{enterprise} har invitert deg til å være en administrator" producer_mailer: order_cycle: subject: "Bestillingsrunderapport for %{producer}" @@ -200,6 +214,7 @@ nb: phone: Telefon price: Pris producer: Produsent + image: Bilde product: Produkt quantity: Mengde schedule: Tidsplan @@ -222,6 +237,9 @@ nb: form_invalid: "Skjemaet inneholder manglende eller ugyldige felt" clear_filters: Fjern filtre clear: Fjern + save: Lagre + cancel: Avbryt + back: Tilbake show_more: Vis mer show_n_more: Vis %{num} flere choose: "Velg..." @@ -353,6 +371,20 @@ nb: manages: administrerer products: unit_name_placeholder: 'f.eks. bunter' + bulk_edit: + unit: Enhet + display_as: Vis som + category: Kategori + tax_category: Avgiftskategori + inherits_properties?: Arver egenskaper? + available_on: Tilgjengelig på + av_on: "Tilgj. På" + import_date: Importert + upload_an_image: Last opp et bilde + product_search_keywords: Nøkkelord for produktsøk + product_search_tip: Skriv ord for å søke etter dine produkter i butikkene. Bruk mellomrom for å skille mellom hvert nøkkelord. + SEO_keywords: SEO Nøkkelord + seo_tip: Skriv ord for å søke etter produktene dine på nettet. Bruk mellomrom for å skille mellom hvert søkeord. Search: Søk properties: property_name: Navn på egenskap @@ -362,6 +394,79 @@ nb: product_distributions: "Produktdistribusjoner" group_buy_options: "Gruppekjøpsalternativer" back_to_products_list: "Tilbake til produktlisten" + product_import: + title: Produktimport + file_not_found: Filen ble ikke funnet eller kunne ikke åpnes + no_data: Ingen data funnet i regnearket + confirm_reset: "Dette vil stille lagernivå til null på alle produkter for denne\n bedriften som ikke er til stede i den opplastede filen" + model: + no_file: "feil: ingen fil lastet opp" + could_not_process: "kunne ikke behandle filen: ugyldig filtype" + incorrect_value: feil verdi + conditional_blank: kan ikke være tom hvis unit_type er tom + no_product: samsvarte ikke med noen produkter i databasen + not_found: ikke funnet i databasen + blank: kan ikke være tomt + products_no_permission: du har ikke tillatelse til å administrere produkter for denne bedriften + inventory_no_permission: du har ikke tillatelse til å opprette lager for denne produsenten + none_saved: kunne ikke lagre noen produkter + line: Linje + index: + select_file: Velg et regneark for å laste opp + spreadsheet: Regneark + import_into: "Importer inn i:" + product_list: Produktliste + inventories: Varelagre + import: Import + upload: Last opp + import: + review: Anmeldelse + proceed: Fortsett + save: Lagre + results: Resultater + save_imported: Lagre importerte produkter + no_valid_entries: Ingen gyldige oppføringer funnet + none_to_save: Det er ingen oppføringer som kan lagres + some_invalid_entries: Importert fil inneholder noen ugyldige oppføringer + save_valid?: Lagre gyldige oppføringer for nå og forkast de andre? + no_errors: Ingen feil oppdaget! + save_all_imported?: Lagre alle importerte produkter? + options_and_defaults: Importalternativer og standardinnstillinger + no_permission: du har ikke tillatelse til å administrere denne bedriften + not_found: bedriften kunne ikke bli funnet i databasen + no_name: Ingen navn + blank_supplier: noen produkter har tomt leverandørnavn + reset_absent?: Tilbakestill fraværende produkter? + overwrite_all: Overskrive alt + overwrite_empty: Overskriv hvis tomt + default_stock: Sett lagernivå + default_tax_cat: Angi avgiftskategori + default_shipping_cat: Angi fraktkategori + default_available_date: Angi tilgjengelig dato + validation_overview: Importvalidering oversikt + entries_found: Oppføringer funnet i importert fil + entries_with_errors: Elementene inneholder feil og vil ikke bli importert + products_to_create: Produkter vil bli opprettet + products_to_update: Produktene vil bli oppdatert + inventory_to_create: Lagerelementer vil bli opprettet + inventory_to_update: Lagerelementer blir oppdatert + products_to_reset: Eksisterende produkter vil få lager satt til null + inventory_to_reset: Eksisterende vareobjekter vil få lager satt til null + line: Linje + item_line: Artikkellinje + save: + final_results: Importer endelige resultater + products_created: Produkter opprettet + products_updated: Produkter oppdatert + inventory_created: Lagerelementer opprettet + inventory_updated: Lagerelementer oppdatert + products_reset: Produktene hadde lagernivå satt til null + inventory_reset: Lagerelementer hadde lagernivå satt til null + all_saved: "Alle elementer lagret vellykket" + some_saved: "elementer lagret vellykket" + save_errors: Lagre feil + view_products: Se produkter + view_inventory: Se beholdning variant_overrides: loading_flash: loading_inventory: LASTER VARELAGER @@ -372,6 +477,7 @@ nb: inherit?: Arve? add: Legg til hide: Skjul + import_date: Importert select_a_shop: Velg en butikk review_now: Sjekk nå new_products_alert_message: Det er %{new_product_count} nye produkter tilgjengelig for å legge til ditt varelager. @@ -412,6 +518,7 @@ nb: max_fulfilled_units: "Max Oppfylte Enheter" order_error: "Noen feil må løses før du kan oppdatere bestillinger.\nAlle felt med røde kanter inneholder feil." variants_without_unit_value: "ADVARSEL: Noen varianter mangler enhetsverdi" + select_variant: "Velg en variant" enterprise: select_outgoing_oc_products_from: Velg utgående bestillingsrundeprodukter fra enterprises: @@ -571,6 +678,9 @@ nb: notifications_note: 'Merk: En ny epostadresse må kanskje bekreftes før bruk' managers: Administratorer managers_tip: Andre brukere med tilgang til å administrere denne bedriften. + invite_manager: "Inviter Administrator" + invite_manager_tip: "Inviter en uregistrert bruker til å registrere seg og bli administrator av denne bedriften." + add_unregistered_user: "Legg til en uregistrert bruker" email_confirmed: "Epost bekreftet" email_not_confirmed: "Epost ikke bekreftet" actions: @@ -629,7 +739,10 @@ nb: welcome_title: Velkommen til Open Food Network! welcome_text: Du har opprettet en next_step: Neste steg - choose_starting_point: 'Velg ditt startpunkt:' + choose_starting_point: 'Velg din pakke:' + invite_manager: + user_already_exists: "Brukeren eksisterer allerede" + error: "Noe gikk galt" order_cycles: edit: advanced_settings: Avanserte Innstillinger @@ -686,7 +799,7 @@ nb: name: Navn orders_open: Bestillinger åpner coordinator: Koordinator - order_closes: Bestillinger stenger + orders_close: Bestillinger stenger row: suppliers: 'leverandører ' distributors: distributører @@ -701,6 +814,8 @@ nb: destroy_errors: orders_present: Denne bestillingsrunden er valgt av en kunde og kan ikke slettes. For å hindre at kundene får tilgang til det, må du lukke den i stedet. schedule_present: Denne bestillingsrunden er knyttet til en tidsplan og kan ikke slettes. Vennligst fjern koblingen eller slett tidsplanen først. + bulk_update: + no_data: Hm, noe gikk galt. Ingen data for bestillingsrunde funnet. producer_properties: index: title: Produsentegenskaper @@ -842,6 +957,9 @@ nb: no_subscriptions: Ingen abonnement ennå... why_dont_you_add_one: Hvorfor legger du ikke til en? :) no_matching_subscriptions: Ingen tilsvarende abonnement funnet + schedules: + destroy: + associated_subscriptions_error: Denne tidsplanen kan ikke slettes fordi den har tilknyttede abonnementer stripe_connect_settings: edit: title: "Stripe Connect" @@ -890,6 +1008,7 @@ nb: require_customer_login: "Denne butikken er kun for kunder." require_login_html: "Vennligst %{login} hvis du allerede har en konto. Hvis ikke, %{register} for å bli kunde." require_customer_html: "Vennligst %{contact} %{enterprise} for å bli kunde." + card_could_not_be_updated: Kortet kunne ikke oppdateres card_could_not_be_saved: kort kunne ikke lagres spree_gateway_error_flash_for_checkout: "Det oppstod et problem med betalingsinformasjonen din: %{error}" invoice_billing_address: "Fakturaadresse:" @@ -1026,6 +1145,7 @@ nb: footer_legal_tos: "Vilkår og betingelser" footer_legal_visit: "Finn oss på" footer_legal_text_html: "Open Food Network er en plattform med fri og åpen kildekode. Vårt innhold er lisensiert med %{content_license} og vår kode med %{code_license}." + footer_skylight_dashboard_html: Ytelsesdata er tilgjengelig på %{dashboard}. home_shop: Handle nå brandstory_headline: "Food, unincorporated." brandstory_intro: "Noen ganger er det best å fikse systemet ved å starte et nytt..." @@ -1167,6 +1287,12 @@ nb: email_signup_shop_html: "Du kan nå logge inn på %{link}." email_signup_text: "Takk for at du ble med i nettverket. Hvis du er kunde ser vi frem til å vise deg mange fantastiske bønder, flotte mathubs og deilig mat! Hvis du er produsent eller selskap er vi glade for å ha deg som en del av nettverket." email_signup_help_html: "Vi tar imot alle dine spørsmål og tilbakemeldinger; du kan bruke Send tilbakemelding på nettside eller email oss på %{email}" + invite_email: + greeting: "Hallo!" + invited_to_manage: "Du har blitt invitert til å administrere %{enterprise} på %{instance}." + confirm_your_email: "Du burde ha mottatt eller vil snart motta en epost med en bekreftelseslenke. Du vil ikke kunne få tilgang til %{enterprise}s profil før du har bekreftet din epost." + set_a_password: "Du blir da bedt om å angi et passord før du kan administrere bedriften." + mistakenly_sent: "Ikke sikker på hvorfor du har mottatt denne e-posten? Kontakt %{owner_email} for mer informasjon." producer_mail_greeting: "Kjære" producer_mail_text_before: "Alle dine kunderbestillinger er klar." producer_mail_order_text: "Her er en oppsummering av bestillingene:" @@ -1415,6 +1541,7 @@ nb: november: "november" december: "desember" email_not_found: "epostadresse ikke funnet" + email_unconfirmed: "Du må bekrefte epostadressen din før du kan tilbakestille passordet ditt." email_required: "Du må oppgi en epostadresse" logging_in: "Et øyeblikk, vi logger deg inn" signup_email: "Din epost" @@ -1429,6 +1556,7 @@ nb: password_reset_sent: "En epost med instruksjoner om å nullstille passordet har blitt sendt!" reset_password: "Tilbakestill passord" who_is_managing_enterprise: "Hvem er ansvarlig for å administrere %{enterprise}?" + update_and_recalculate_fees: "Oppdater og regn avgifter på nytt" enterprise: registration: modal: @@ -1665,6 +1793,10 @@ nb: calculator: "Kalkulator" calculator_values: "Kalkulatorverdier" flat_percent_per_item: "Flat prosent (per stk)" + flat_rate_per_item: "Flat rate (per vare)" + flat_rate_per_order: "Flat Rate (per bestilling)" + flexible_rate: "Fleksibel Rate" + price_sack: "Prissekk" new_order_cycles: "Nye Bestillingsrunder" new_order_cycle: "Ny bestillingsrunde" select_a_coordinator_for_your_order_cycle: "Velg en koordinator for bestillingsrunde" @@ -1699,12 +1831,7 @@ nb: spree_admin_enterprises_fees: "Bedriftsavgift" spree_admin_enterprises_none_create_a_new_enterprise: "OPPRETT NY BEDRIFT" spree_admin_enterprises_none_text: "Du har ingen bedrifter ennå" - spree_admin_enterprises_producers_name: "Navn" - spree_admin_enterprises_producers_total_products: "Alle Produkter" - spree_admin_enterprises_producers_active_products: "Aktive Produkter" - spree_admin_enterprises_producers_order_cycles: "Produkter i Bestillingsrunder" spree_admin_enterprises_tabs_hubs: "HUBS" - spree_admin_enterprises_tabs_producers: "PRODUSENTER" spree_admin_enterprises_producers_manage_products: "ADMINISTRER PRODUKTER" spree_admin_enterprises_any_active_products_text: "Du har ingen aktive produkter." spree_admin_enterprises_create_new_product: "OPPRETT NYTT PRODUKT" @@ -1940,6 +2067,8 @@ nb: products_unsaved: "Endringer i %{n} produkter er fortsatt ulagret." is_already_manager: "er allerede administrator!" no_change_to_save: "Ingen endring å lagre" + user_invited: "%{email} har blitt invitert til å administrere denne bedriften" + add_manager: "Legg til en eksisterende bruker" users: "Brukere" about: "Om" images: "Bilder" @@ -1950,6 +2079,7 @@ nb: social: "Sosial" business_details: "Forretningsdetaljer" properties: "Egenskaper" + shipping: "Levering" shipping_methods: "Leveringsmetoder" payment_methods: "Betalingsmetoder" payment_method_fee: "Transaksjongebyr" @@ -1966,7 +2096,7 @@ nb: content_configuration_pricing_table: "(TODO: Pristabell)" content_configuration_case_studies: "(TODO: Casestudier)" content_configuration_detail: "(TODO: Detalj)" - enterprise_name_error: "har allerede blitt tatt. Hvis dette er din bedrift og du ønsker å kreve eierskap, vennligst kontakt gjeldende leder av denne profilen på %{email}." + enterprise_name_error: "har allerede blitt tatt. Hvis dette er din bedrift og du ønsker å kreve eierskap, eller hvis du ønsker å handle med denne bedriften, vennligst kontakt gjeldende administrator av denne profilen på %{email}." enterprise_owner_error: "^ %{email} kan ikke eie flere bedrifter (grense er %{enterprise_limit})." enterprise_role_uniqueness_error: "^Den rollen finnes allerede." inventory_item_visibility_error: må være true eller false @@ -2000,6 +2130,14 @@ nb: order_cycles_email_to_producers_notice: 'E-poster som skal sendes til produsentene har blitt satt i kø for sending.' order_cycles_no_permission_to_coordinate_error: "Ingen av bedriftene dine har tillatelse til å koordinere en bestillingsrunde" order_cycles_no_permission_to_create_error: "Du har ikke rettigheter til å opprette en bestillingsrunde som er koordinert av den bedriften" + back_to_orders_list: "Tilbake til bestillingslisten" + no_orders_found: "Ingen bestillinger funnet" + order_information: "Bestillingsinformasjon" + date_completed: "Dato Fullført" + amount: "Beløp" + state_names: + ready: Klar + pending: I påvente av js: saving: 'Lagrer...' changes_saved: 'Endringene er lagret.' From ea0d24a5bfdf1c06c8bfd28f48e0438b7cad9c19 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Tue, 12 Jun 2018 13:20:00 +0100 Subject: [PATCH 168/206] Re-word test names for clarity --- .../customer_details_controller_spec.rb | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/spec/controllers/spree/admin/orders/customer_details_controller_spec.rb b/spec/controllers/spree/admin/orders/customer_details_controller_spec.rb index cd1a04a4dd..bd78bc2fd9 100644 --- a/spec/controllers/spree/admin/orders/customer_details_controller_spec.rb +++ b/spec/controllers/spree/admin/orders/customer_details_controller_spec.rb @@ -39,26 +39,24 @@ describe Spree::Admin::Orders::CustomerDetailsController, type: :controller do login_as_enterprise_user [order.distributor] end - it "accepts registered users" do - spree_post :update, order: { email: user.email, bill_address_attributes: address_params, ship_address_attributes: address_params }, order_id: order.number + context "when adding details of a registered user" do + it "redirects to shipments on success" do + spree_post :update, order: { email: user.email, bill_address_attributes: address_params, ship_address_attributes: address_params }, order_id: order.number - order.reload + order.reload - expect(response).to redirect_to spree.edit_admin_order_shipment_path(order, order.shipment) - expect(order.email).to eq user.email - expect(order.user_id).to eq user.id - expect(order.ship_address).to_not be_nil + expect(response).to redirect_to spree.edit_admin_order_shipment_path(order, order.shipment) + end end - it "accepts unregistered users" do - spree_post :update, order: { email: 'unregistered@email.com', bill_address_attributes: address_params, ship_address_attributes: address_params }, order_id: order.number + context "when adding details of an unregistered user" do + it "redirects to shipments on success" do + spree_post :update, order: { email: 'unregistered@email.com', bill_address_attributes: address_params, ship_address_attributes: address_params }, order_id: order.number - order.reload + order.reload - expect(response).to redirect_to spree.edit_admin_order_shipment_path(order, order.shipment) - expect(order.email).to eq 'unregistered@email.com' - expect(order.user_id).to be_nil - expect(order.ship_address).to_not be_nil + expect(response).to redirect_to spree.edit_admin_order_shipment_path(order, order.shipment) + end end end end From ac309ffaf50b32cc77729354d56adab3d19109c8 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Wed, 14 Mar 2018 16:27:21 +1100 Subject: [PATCH 169/206] Rename obsolete_proxy_orders to orphaned_proxy_orders --- lib/open_food_network/proxy_order_syncer.rb | 10 +++++----- spec/lib/open_food_network/proxy_order_syncer_spec.rb | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/open_food_network/proxy_order_syncer.rb b/lib/open_food_network/proxy_order_syncer.rb index 0b52ec1971..d1331518f3 100644 --- a/lib/open_food_network/proxy_order_syncer.rb +++ b/lib/open_food_network/proxy_order_syncer.rb @@ -19,7 +19,7 @@ module OpenFoodNetwork return sync_all! if @subscriptions return initialise_proxy_orders! unless @subscription.id create_proxy_orders! - remove_obsolete_proxy_orders! + remove_orphaned_proxy_orders! end private @@ -28,7 +28,7 @@ module OpenFoodNetwork @subscriptions.each do |subscription| @subscription = subscription create_proxy_orders! - remove_obsolete_proxy_orders! + remove_orphaned_proxy_orders! end end @@ -51,11 +51,11 @@ module OpenFoodNetwork not_closed_in_range_order_cycles.pluck(:id) - proxy_orders.map(&:order_cycle_id) end - def remove_obsolete_proxy_orders! - obsolete_proxy_orders.scoped.delete_all + def remove_orphaned_proxy_orders! + orphaned_proxy_orders.scoped.delete_all end - def obsolete_proxy_orders + def orphaned_proxy_orders in_range_order_cycle_ids = in_range_order_cycles.pluck(:id) return proxy_orders unless in_range_order_cycle_ids.any? proxy_orders.where('order_cycle_id NOT IN (?)', in_range_order_cycle_ids) diff --git a/spec/lib/open_food_network/proxy_order_syncer_spec.rb b/spec/lib/open_food_network/proxy_order_syncer_spec.rb index eb7684ad28..83bd6d720d 100644 --- a/spec/lib/open_food_network/proxy_order_syncer_spec.rb +++ b/spec/lib/open_food_network/proxy_order_syncer_spec.rb @@ -33,7 +33,7 @@ module OpenFoodNetwork it "performs both create and remove actions to rectify proxy orders" do expect(syncer).to receive(:create_proxy_orders!).and_call_original - expect(syncer).to receive(:remove_obsolete_proxy_orders!).and_call_original + expect(syncer).to receive(:remove_orphaned_proxy_orders!).and_call_original syncer.sync! subscription.reload expect(subscription.proxy_orders).to include po2 @@ -61,7 +61,7 @@ module OpenFoodNetwork end end - describe "#remove_obsolete_proxy_orders!" do + describe "#remove_orphaned_proxy_orders!" do let!(:po1) { create(:proxy_order, subscription: subscription, order_cycle: oc1) } let!(:po2) { create(:proxy_order, subscription: subscription, order_cycle: oc2) } let!(:po3) { create(:proxy_order, subscription: subscription, order_cycle: oc3) } @@ -70,7 +70,7 @@ module OpenFoodNetwork it "destroys proxy orders that are closed or out of range" do allow(syncer).to receive(:subscription) { subscription } - expect{ syncer.send(:remove_obsolete_proxy_orders!) }.to change(ProxyOrder, :count).from(5).to(2) + expect{ syncer.send(:remove_orphaned_proxy_orders!) }.to change(ProxyOrder, :count).from(5).to(2) expect(subscription.proxy_orders.map(&:order_cycle)).to include oc3, oc4 end end From d9ddad554eb78b071678588ce72ab6a82894d797 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Wed, 14 Mar 2018 17:11:11 +1100 Subject: [PATCH 170/206] Prevent proxy_orders for active or complete order cycles from being removed automatically by ProxyOrderSyncer --- lib/open_food_network/proxy_order_syncer.rb | 10 +- .../proxy_order_syncer_spec.rb | 225 ++++++++++++++---- 2 files changed, 183 insertions(+), 52 deletions(-) diff --git a/lib/open_food_network/proxy_order_syncer.rb b/lib/open_food_network/proxy_order_syncer.rb index d1331518f3..40b9b10214 100644 --- a/lib/open_food_network/proxy_order_syncer.rb +++ b/lib/open_food_network/proxy_order_syncer.rb @@ -56,9 +56,9 @@ module OpenFoodNetwork end def orphaned_proxy_orders - in_range_order_cycle_ids = in_range_order_cycles.pluck(:id) - return proxy_orders unless in_range_order_cycle_ids.any? - proxy_orders.where('order_cycle_id NOT IN (?)', in_range_order_cycle_ids) + order_cycle_ids = active_or_complete_or_in_range_order_cycle_ids + return proxy_orders unless order_cycle_ids.any? + proxy_orders.where('order_cycle_id NOT IN (?)', order_cycle_ids) end def insert_values @@ -72,6 +72,10 @@ module OpenFoodNetwork in_range_order_cycles.merge(OrderCycle.not_closed) end + def active_or_complete_or_in_range_order_cycle_ids + in_range_order_cycles.pluck(:id) | order_cycles.active_or_complete.pluck(:id) + end + def in_range_order_cycles order_cycles.where('orders_close_at >= ? AND orders_close_at <= ?', begins_at, ends_at || 100.years.from_now) end diff --git a/spec/lib/open_food_network/proxy_order_syncer_spec.rb b/spec/lib/open_food_network/proxy_order_syncer_spec.rb index 83bd6d720d..0bf6c59e67 100644 --- a/spec/lib/open_food_network/proxy_order_syncer_spec.rb +++ b/spec/lib/open_food_network/proxy_order_syncer_spec.rb @@ -12,66 +12,193 @@ module OpenFoodNetwork end end - describe "updating proxy_orders on a subscriptions" do + describe "#sync!" do let(:now) { Time.zone.now } - let!(:schedule) { create(:schedule) } - let!(:subscription) { create(:subscription, schedule: schedule, begins_at: now + 1.minute, ends_at: now + 2.minutes) } - let!(:oc1) { create(:simple_order_cycle, schedules: [schedule], orders_open_at: now - 1.minute, orders_close_at: now) } # Closed - let!(:oc2) { create(:simple_order_cycle, schedules: [schedule], orders_open_at: now - 1.minute, orders_close_at: now + 59.seconds) } # Open, but closes before begins at - let!(:oc3) { create(:simple_order_cycle, schedules: [schedule], orders_open_at: now - 1.minute, orders_close_at: now + 1.minute) } # Open + closes on begins at - let!(:oc4) { create(:simple_order_cycle, schedules: [schedule], orders_open_at: now - 1.minute, orders_close_at: now + 2.minutes) } # Open + closes on ends at - let!(:oc5) { create(:simple_order_cycle, schedules: [schedule], orders_open_at: now - 1.minute, orders_close_at: now + 121.seconds) } # Open + closes after ends at + let(:schedule) { create(:schedule) } + let(:closed_oc) { create(:simple_order_cycle, schedules: [schedule], orders_open_at: now - 1.minute, orders_close_at: now) } # Closed + let(:open_oc) { create(:simple_order_cycle, schedules: [schedule], orders_open_at: now - 1.minute, orders_close_at: now + 90.seconds) } # Open & closes between begins at and ends at + let(:upcoming_closes_before_begins_at_oc) { create(:simple_order_cycle, schedules: [schedule], orders_open_at: now + 30.seconds, orders_close_at: now + 59.seconds) } # Upcoming, but closes before begins at + let(:upcoming_closes_on_begins_at_oc) { create(:simple_order_cycle, schedules: [schedule], orders_open_at: now + 30.seconds, orders_close_at: now + 1.minute) } # Upcoming & closes on begins at + let(:upcoming_closes_on_ends_at_oc) { create(:simple_order_cycle, schedules: [schedule], orders_open_at: now + 30.seconds, orders_close_at: now + 2.minutes) } # Upcoming & closes on ends at + let(:upcoming_closes_after_ends_at_oc) { create(:simple_order_cycle, schedules: [schedule], orders_open_at: now + 30.seconds, orders_close_at: now + 121.seconds) } # Upcoming & closes after ends at + let(:subscription) { build(:subscription, schedule: schedule, begins_at: now + 1.minute, ends_at: now + 2.minutes) } + let(:proxy_orders) { subscription.reload.proxy_orders } + let(:order_cycles) { proxy_orders.map(&:order_cycle) } let(:syncer) { ProxyOrderSyncer.new(subscription) } - describe "#sync!" do - let!(:oc6) { create(:simple_order_cycle, schedules: [schedule], orders_open_at: now - 1.minute, orders_close_at: now + 59.seconds) } # Open, but closes before begins at - let!(:oc7) { create(:simple_order_cycle, schedules: [schedule], orders_open_at: now - 1.minute, orders_close_at: now + 61.seconds) } # Open + closes on begins at - let!(:oc8) { create(:simple_order_cycle, schedules: [schedule], orders_open_at: now - 1.minute, orders_close_at: now + 121.seconds) } # Open + closes after ends at - let!(:po1) { create(:proxy_order, subscription: subscription, order_cycle: oc6) } - let!(:po2) { create(:proxy_order, subscription: subscription, order_cycle: oc7) } - let!(:po3) { create(:proxy_order, subscription: subscription, order_cycle: oc8) } + context "when the subscription is not persisted" do + before do + oc # Ensure oc is created before we attempt to sync + expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(0) + end - it "performs both create and remove actions to rectify proxy orders" do - expect(syncer).to receive(:create_proxy_orders!).and_call_original - expect(syncer).to receive(:remove_orphaned_proxy_orders!).and_call_original - syncer.sync! - subscription.reload - expect(subscription.proxy_orders).to include po2 - expect(subscription.proxy_orders).to_not include po1, po3 - expect(subscription.proxy_orders.map(&:order_cycle)).to include oc3, oc4, oc7 - expect(subscription.proxy_orders.map(&:order_cycle)).to_not include oc1, oc2, oc5, oc6, oc8 + context "and the schedule includes a closed oc" do + let(:oc) { closed_oc } + it "does not create a new proxy order for that oc" do + expect{ subscription.save! }.to_not change(ProxyOrder, :count).from(0) + expect(order_cycles).to_not include oc + end + end + + context "and the schedule includes an open oc that closes between begins_at and ends_at" do + let(:oc) { open_oc } + it "creates a new proxy order for that oc" do + expect{ subscription.save! }.to change(ProxyOrder, :count).from(0).to(1) + expect(order_cycles).to include oc + end + end + + context "and the schedule includes upcoming oc that closes before begins_at" do + let(:oc) { upcoming_closes_before_begins_at_oc } + it "does not create a new proxy order for that oc" do + expect{ subscription.save! }.to_not change(ProxyOrder, :count).from(0) + expect(order_cycles).to_not include oc + end + end + + context "and the schedule includes upcoming oc that closes on begins_at" do + let(:oc) { upcoming_closes_on_begins_at_oc } + it "creates a new proxy order for that oc" do + expect{ subscription.save! }.to change(ProxyOrder, :count).from(0).to(1) + expect(order_cycles).to include oc + end + end + + context "and the schedule includes upcoming oc that closes after ends_at" do + let(:oc) { upcoming_closes_on_ends_at_oc } + it "creates a new proxy order for that oc" do + expect{ subscription.save! }.to change(ProxyOrder, :count).from(0).to(1) + expect(order_cycles).to include oc + end + end + + context "and the schedule includes upcoming oc that closes after ends_at" do + let(:oc) { upcoming_closes_after_ends_at_oc } + it "does not create a new proxy order for that oc" do + expect{ subscription.save! }.to_not change(ProxyOrder, :count).from(0) + expect(order_cycles).to_not include oc + end end end - describe "#initialise_proxy_orders!" do - let(:new_subscription) { build(:subscription, schedule: schedule, begins_at: now + 1.minute, ends_at: now + 2.minutes) } - it "builds proxy orders for in-range order cycles that are not already closed" do - allow(syncer).to receive(:subscription) { new_subscription } - expect{ syncer.send(:initialise_proxy_orders!) }.to_not change(ProxyOrder, :count).from(0) - expect{ new_subscription.save! }.to change(ProxyOrder, :count).from(0).to(2) - expect(new_subscription.proxy_orders.map(&:order_cycle_id)).to include oc3.id, oc4.id + context "when the subscription is persisted" do + before { expect(subscription.save!).to be true } + + context "when a proxy order exists" do + let!(:proxy_order) { create(:proxy_order, subscription: subscription, order_cycle: oc) } + + context "for an oc included in the relevant schedule" do + context "the oc is closed" do + let(:oc) { closed_oc } + it "keeps the proxy order" do + expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(1) + expect(proxy_orders).to include proxy_order + end + end + + context "and the oc is open and closes between begins_at and ends_at" do + let(:oc) { open_oc } + it "keeps the proxy order" do + expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(1) + expect(proxy_orders).to include proxy_order + end + end + + context "and the oc is upcoming and closes before begins_at" do + let(:oc) { upcoming_closes_before_begins_at_oc } + it "removes the proxy order" do + expect{ syncer.sync! }.to change(ProxyOrder, :count).from(1).to(0) + expect(proxy_orders).to_not include proxy_order + end + end + + context "and the oc is upcoming and closes on begins_at" do + let(:oc) { upcoming_closes_on_begins_at_oc } + it "keeps the proxy order" do + expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(1) + expect(proxy_orders).to include proxy_order + end + end + + context "and the oc is upcoming and closes after ends_at" do + let(:oc) { upcoming_closes_on_ends_at_oc } + it "keeps the proxy order" do + expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(1) + expect(proxy_orders).to include proxy_order + end + end + + context "and the oc is upcoming and closes after ends_at" do + let(:oc) { upcoming_closes_after_ends_at_oc } + it "removes the proxy order" do + expect{ syncer.sync! }.to change(ProxyOrder, :count).from(1).to(0) + expect(proxy_orders).to_not include proxy_order + end + end + end + + context "for an oc not included in the relevant schedule" do + let!(:proxy_order) { create(:proxy_order, subscription: subscription, order_cycle: open_oc) } + before do + open_oc.schedule_ids = [] + expect(open_oc.save!).to be true + end + + it "removes the proxy order" do + expect{ syncer.sync! }.to change(ProxyOrder, :count).by(-1) + expect(proxy_orders).to_not include proxy_order + end + end end - end - describe "#create_proxy_orders!" do - it "creates proxy orders for in-range order cycles that are not already closed" do - allow(syncer).to receive(:subscription) { subscription } - expect{ syncer.send(:create_proxy_orders!) }.to change(ProxyOrder, :count).from(0).to(2) - expect(subscription.proxy_orders.map(&:order_cycle)).to include oc3, oc4 - end - end + context "when a proxy order does not exist" do + context "and the schedule includes a closed oc" do + let!(:oc) { closed_oc } + it "does not create a new proxy order for that oc" do + expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(0) + expect(order_cycles).to_not include oc + end + end - describe "#remove_orphaned_proxy_orders!" do - let!(:po1) { create(:proxy_order, subscription: subscription, order_cycle: oc1) } - let!(:po2) { create(:proxy_order, subscription: subscription, order_cycle: oc2) } - let!(:po3) { create(:proxy_order, subscription: subscription, order_cycle: oc3) } - let!(:po4) { create(:proxy_order, subscription: subscription, order_cycle: oc4) } - let!(:po5) { create(:proxy_order, subscription: subscription, order_cycle: oc5) } + context "and the schedule includes an open oc that closes between begins_at and ends_at" do + let!(:oc) { open_oc } + it "creates a new proxy order for that oc" do + expect{ syncer.sync! }.to change(ProxyOrder, :count).from(0).to(1) + expect(order_cycles).to include oc + end + end - it "destroys proxy orders that are closed or out of range" do - allow(syncer).to receive(:subscription) { subscription } - expect{ syncer.send(:remove_orphaned_proxy_orders!) }.to change(ProxyOrder, :count).from(5).to(2) - expect(subscription.proxy_orders.map(&:order_cycle)).to include oc3, oc4 + context "and the schedule includes upcoming oc that closes before begins_at" do + let!(:oc) { upcoming_closes_before_begins_at_oc } + it "does not create a new proxy order for that oc" do + expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(0) + expect(order_cycles).to_not include oc + end + end + + context "and the schedule includes upcoming oc that closes on begins_at" do + let!(:oc) { upcoming_closes_on_begins_at_oc } + it "creates a new proxy order for that oc" do + expect{ syncer.sync! }.to change(ProxyOrder, :count).from(0).to(1) + expect(order_cycles).to include oc + end + end + + context "and the schedule includes upcoming oc that closes after ends_at" do + let!(:oc) { upcoming_closes_on_ends_at_oc } + it "creates a new proxy order for that oc" do + expect{ syncer.sync! }.to change(ProxyOrder, :count).from(0).to(1) + expect(order_cycles).to include oc + end + end + + context "and the schedule includes upcoming oc that closes after ends_at" do + let!(:oc) { upcoming_closes_after_ends_at_oc } + it "does not create a new proxy order for that oc" do + expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(0) + expect(order_cycles).to_not include oc + end + end end end end From c72a2fda91002d3691f7b4666ca23b2feb0d32e7 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Fri, 8 Jun 2018 13:44:37 +1000 Subject: [PATCH 171/206] Use placed_at state to determine whether to remove orphaned proxy orders --- lib/open_food_network/proxy_order_syncer.rb | 11 +- .../proxy_order_syncer_spec.rb | 274 +++++++++++++++--- 2 files changed, 238 insertions(+), 47 deletions(-) diff --git a/lib/open_food_network/proxy_order_syncer.rb b/lib/open_food_network/proxy_order_syncer.rb index 40b9b10214..10cbaa12b0 100644 --- a/lib/open_food_network/proxy_order_syncer.rb +++ b/lib/open_food_network/proxy_order_syncer.rb @@ -56,9 +56,10 @@ module OpenFoodNetwork end def orphaned_proxy_orders - order_cycle_ids = active_or_complete_or_in_range_order_cycle_ids - return proxy_orders unless order_cycle_ids.any? - proxy_orders.where('order_cycle_id NOT IN (?)', order_cycle_ids) + orphaned = proxy_orders.where(placed_at: nil) + order_cycle_ids = in_range_order_cycles.pluck(:id) + return orphaned unless order_cycle_ids.any? + orphaned.where('order_cycle_id NOT IN (?)', order_cycle_ids) end def insert_values @@ -72,10 +73,6 @@ module OpenFoodNetwork in_range_order_cycles.merge(OrderCycle.not_closed) end - def active_or_complete_or_in_range_order_cycle_ids - in_range_order_cycles.pluck(:id) | order_cycles.active_or_complete.pluck(:id) - end - def in_range_order_cycles order_cycles.where('orders_close_at >= ? AND orders_close_at <= ?', begins_at, ends_at || 100.years.from_now) end diff --git a/spec/lib/open_food_network/proxy_order_syncer_spec.rb b/spec/lib/open_food_network/proxy_order_syncer_spec.rb index 0bf6c59e67..9b059d178f 100644 --- a/spec/lib/open_food_network/proxy_order_syncer_spec.rb +++ b/spec/lib/open_food_network/proxy_order_syncer_spec.rb @@ -16,6 +16,7 @@ module OpenFoodNetwork let(:now) { Time.zone.now } let(:schedule) { create(:schedule) } let(:closed_oc) { create(:simple_order_cycle, schedules: [schedule], orders_open_at: now - 1.minute, orders_close_at: now) } # Closed + let(:open_oc_closes_before_begins_at_oc) { create(:simple_order_cycle, schedules: [schedule], orders_open_at: now - 1.minute, orders_close_at: now + 59.seconds) } # Open, but closes before begins at let(:open_oc) { create(:simple_order_cycle, schedules: [schedule], orders_open_at: now - 1.minute, orders_close_at: now + 90.seconds) } # Open & closes between begins at and ends at let(:upcoming_closes_before_begins_at_oc) { create(:simple_order_cycle, schedules: [schedule], orders_open_at: now + 30.seconds, orders_close_at: now + 59.seconds) } # Upcoming, but closes before begins at let(:upcoming_closes_on_begins_at_oc) { create(:simple_order_cycle, schedules: [schedule], orders_open_at: now + 30.seconds, orders_close_at: now + 1.minute) } # Upcoming & closes on begins at @@ -32,7 +33,7 @@ module OpenFoodNetwork expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(0) end - context "and the schedule includes a closed oc" do + context "and the schedule includes a closed oc (ie. closed before opens_at)" do let(:oc) { closed_oc } it "does not create a new proxy order for that oc" do expect{ subscription.save! }.to_not change(ProxyOrder, :count).from(0) @@ -40,6 +41,14 @@ module OpenFoodNetwork end end + context "and the schedule includes an open oc that closes before begins_at" do + let(:oc) { open_oc_closes_before_begins_at_oc } + it "does not create a new proxy order for that oc" do + expect{ subscription.save! }.to_not change(ProxyOrder, :count).from(0) + expect(order_cycles).to_not include oc + end + end + context "and the schedule includes an open oc that closes between begins_at and ends_at" do let(:oc) { open_oc } it "creates a new proxy order for that oc" do @@ -88,51 +97,121 @@ module OpenFoodNetwork let!(:proxy_order) { create(:proxy_order, subscription: subscription, order_cycle: oc) } context "for an oc included in the relevant schedule" do - context "the oc is closed" do - let(:oc) { closed_oc } - it "keeps the proxy order" do - expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(1) - expect(proxy_orders).to include proxy_order + context "and the proxy order has already been placed" do + before { proxy_order.update_attributes(placed_at: 5.minutes.ago) } + + context "the oc is closed (ie. closed before opens_at)" do + let(:oc) { closed_oc } + it "keeps the proxy order" do + expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(1) + expect(proxy_orders).to include proxy_order + end + end + + context "and the schedule includes an open oc that closes before begins_at" do + let(:oc) { open_oc_closes_before_begins_at_oc } + it "keeps the proxy order" do + expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(1) + expect(proxy_orders).to include proxy_order + end + end + + context "and the oc is open and closes between begins_at and ends_at" do + let(:oc) { open_oc } + it "keeps the proxy order" do + expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(1) + expect(proxy_orders).to include proxy_order + end + end + + context "and the oc is upcoming and closes before begins_at" do + let(:oc) { upcoming_closes_before_begins_at_oc } + it "keeps the proxy order" do + expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(1) + expect(proxy_orders).to include proxy_order + end + end + + context "and the oc is upcoming and closes on begins_at" do + let(:oc) { upcoming_closes_on_begins_at_oc } + it "keeps the proxy order" do + expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(1) + expect(proxy_orders).to include proxy_order + end + end + + context "and the oc is upcoming and closes on ends_at" do + let(:oc) { upcoming_closes_on_ends_at_oc } + it "keeps the proxy order" do + expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(1) + expect(proxy_orders).to include proxy_order + end + end + + context "and the oc is upcoming and closes after ends_at" do + let(:oc) { upcoming_closes_after_ends_at_oc } + it "keeps the proxy order" do + expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(1) + expect(proxy_orders).to include proxy_order + end end end - context "and the oc is open and closes between begins_at and ends_at" do - let(:oc) { open_oc } - it "keeps the proxy order" do - expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(1) - expect(proxy_orders).to include proxy_order + context "and the proxy order has not already been placed" do + context "the oc is closed (ie. closed before opens_at)" do + let(:oc) { closed_oc } + it "removes the proxy order" do + expect{ syncer.sync! }.to change(ProxyOrder, :count).from(1).to(0) + expect(proxy_orders).to_not include proxy_order + end end - end - context "and the oc is upcoming and closes before begins_at" do - let(:oc) { upcoming_closes_before_begins_at_oc } - it "removes the proxy order" do - expect{ syncer.sync! }.to change(ProxyOrder, :count).from(1).to(0) - expect(proxy_orders).to_not include proxy_order + context "and the schedule includes an open oc that closes before begins_at" do + let(:oc) { open_oc_closes_before_begins_at_oc } + it "removes the proxy order" do + expect{ syncer.sync! }.to change(ProxyOrder, :count).from(1).to(0) + expect(proxy_orders).to_not include proxy_order + end end - end - context "and the oc is upcoming and closes on begins_at" do - let(:oc) { upcoming_closes_on_begins_at_oc } - it "keeps the proxy order" do - expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(1) - expect(proxy_orders).to include proxy_order + context "and the oc is open and closes between begins_at and ends_at" do + let(:oc) { open_oc } + it "keeps the proxy order" do + expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(1) + expect(proxy_orders).to include proxy_order + end end - end - context "and the oc is upcoming and closes after ends_at" do - let(:oc) { upcoming_closes_on_ends_at_oc } - it "keeps the proxy order" do - expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(1) - expect(proxy_orders).to include proxy_order + context "and the oc is upcoming and closes before begins_at" do + let(:oc) { upcoming_closes_before_begins_at_oc } + it "removes the proxy order" do + expect{ syncer.sync! }.to change(ProxyOrder, :count).from(1).to(0) + expect(proxy_orders).to_not include proxy_order + end end - end - context "and the oc is upcoming and closes after ends_at" do - let(:oc) { upcoming_closes_after_ends_at_oc } - it "removes the proxy order" do - expect{ syncer.sync! }.to change(ProxyOrder, :count).from(1).to(0) - expect(proxy_orders).to_not include proxy_order + context "and the oc is upcoming and closes on begins_at" do + let(:oc) { upcoming_closes_on_begins_at_oc } + it "keeps the proxy order" do + expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(1) + expect(proxy_orders).to include proxy_order + end + end + + context "and the oc is upcoming and closes on ends_at" do + let(:oc) { upcoming_closes_on_ends_at_oc } + it "keeps the proxy order" do + expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(1) + expect(proxy_orders).to include proxy_order + end + end + + context "and the oc is upcoming and closes after ends_at" do + let(:oc) { upcoming_closes_after_ends_at_oc } + it "removes the proxy order" do + expect{ syncer.sync! }.to change(ProxyOrder, :count).from(1).to(0) + expect(proxy_orders).to_not include proxy_order + end end end end @@ -144,15 +223,122 @@ module OpenFoodNetwork expect(open_oc.save!).to be true end - it "removes the proxy order" do - expect{ syncer.sync! }.to change(ProxyOrder, :count).by(-1) - expect(proxy_orders).to_not include proxy_order + context "and the proxy order has already been placed" do + before { proxy_order.update_attributes(placed_at: 5.minutes.ago) } + + context "the oc is closed (ie. closed before opens_at)" do + let(:oc) { closed_oc } + it "keeps the proxy order" do + expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(1) + expect(proxy_orders).to include proxy_order + end + end + + context "and the schedule includes an open oc that closes before begins_at" do + let(:oc) { open_oc_closes_before_begins_at_oc } + it "keeps the proxy order" do + expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(1) + expect(proxy_orders).to include proxy_order + end + end + + context "and the oc is open and closes between begins_at and ends_at" do + let(:oc) { open_oc } + it "keeps the proxy order" do + expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(1) + expect(proxy_orders).to include proxy_order + end + end + + context "and the oc is upcoming and closes before begins_at" do + let(:oc) { upcoming_closes_before_begins_at_oc } + it "keeps the proxy order" do + expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(1) + expect(proxy_orders).to include proxy_order + end + end + + context "and the oc is upcoming and closes on begins_at" do + let(:oc) { upcoming_closes_on_begins_at_oc } + it "keeps the proxy order" do + expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(1) + expect(proxy_orders).to include proxy_order + end + end + + context "and the oc is upcoming and closes on ends_at" do + let(:oc) { upcoming_closes_on_ends_at_oc } + it "keeps the proxy order" do + expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(1) + expect(proxy_orders).to include proxy_order + end + end + + context "and the oc is upcoming and closes after ends_at" do + let(:oc) { upcoming_closes_after_ends_at_oc } + it "keeps the proxy order" do + expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(1) + expect(proxy_orders).to include proxy_order + end + end + end + + context "and the proxy order has not already been placed" do + # This shouldn't really happen, but it is possible + context "the oc is closed (ie. closed before opens_at)" do + let(:oc) { closed_oc } + it "removes the proxy order" do + expect{ syncer.sync! }.to change(ProxyOrder, :count).from(1).to(0) + expect(proxy_orders).to_not include proxy_order + end + end + + # This shouldn't really happen, but it is possible + context "and the oc is open and closes between begins_at and ends_at" do + let(:oc) { open_oc } + it "removes the proxy order" do + expect{ syncer.sync! }.to change(ProxyOrder, :count).from(1).to(0) + expect(proxy_orders).to_not include proxy_order + end + end + + context "and the oc is upcoming and closes before begins_at" do + let(:oc) { upcoming_closes_before_begins_at_oc } + it "removes the proxy order" do + expect{ syncer.sync! }.to change(ProxyOrder, :count).from(1).to(0) + expect(proxy_orders).to_not include proxy_order + end + end + + context "and the oc is upcoming and closes on begins_at" do + let(:oc) { upcoming_closes_on_begins_at_oc } + it "removes the proxy order" do + expect{ syncer.sync! }.to change(ProxyOrder, :count).from(1).to(0) + expect(proxy_orders).to_not include proxy_order + end + end + + context "and the oc is upcoming and closes on ends_at" do + let(:oc) { upcoming_closes_on_ends_at_oc } + it "removes the proxy order" do + expect{ syncer.sync! }.to change(ProxyOrder, :count).from(1).to(0) + expect(proxy_orders).to_not include proxy_order + end + end + + context "and the oc is upcoming and closes after ends_at" do + let(:oc) { upcoming_closes_after_ends_at_oc } + it "removes the proxy order" do + expect{ syncer.sync! }.to change(ProxyOrder, :count).from(1).to(0) + expect(proxy_orders).to_not include proxy_order + end + end end end end context "when a proxy order does not exist" do - context "and the schedule includes a closed oc" do + context "and the schedule includes a closed oc (ie. closed before opens_at)" do let!(:oc) { closed_oc } it "does not create a new proxy order for that oc" do expect{ syncer.sync! }.to_not change(ProxyOrder, :count).from(0) @@ -160,6 +346,14 @@ module OpenFoodNetwork end end + context "and the schedule includes an open oc that closes before begins_at" do + let(:oc) { open_oc_closes_before_begins_at_oc } + it "does not create a new proxy order for that oc" do + expect{ subscription.save! }.to_not change(ProxyOrder, :count).from(0) + expect(order_cycles).to_not include oc + end + end + context "and the schedule includes an open oc that closes between begins_at and ends_at" do let!(:oc) { open_oc } it "creates a new proxy order for that oc" do @@ -184,7 +378,7 @@ module OpenFoodNetwork end end - context "and the schedule includes upcoming oc that closes after ends_at" do + context "and the schedule includes upcoming oc that closes on ends_at" do let!(:oc) { upcoming_closes_on_ends_at_oc } it "creates a new proxy order for that oc" do expect{ syncer.sync! }.to change(ProxyOrder, :count).from(0).to(1) From 561a73e91136b688edcab1d4d984f94ca0eacd07 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 14 Jun 2018 16:49:52 +1000 Subject: [PATCH 172/206] Pass on redirect path instead of URL Fixes https://github.com/openfoodfoundation/openfoodnetwork/issues/2376 The checkout doesn't deal with absolute URLs since fc2cc09ea5e6c123243dac27b0f5dff601ac518b. --- app/controllers/checkout_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/checkout_controller.rb b/app/controllers/checkout_controller.rb index 16b5134b11..8cba0d8a0e 100644 --- a/app/controllers/checkout_controller.rb +++ b/app/controllers/checkout_controller.rb @@ -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 From d0f8e9fba61a15fd0f509adfe1acca6c4e5c504d Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Wed, 13 Jun 2018 10:48:16 +1000 Subject: [PATCH 173/206] Reimplement import date filter as an actual Filter --- app/assets/javascripts/admin/bulk_product_update.js.coffee | 2 +- .../javascripts/admin/filters/import_date_filter.js.coffee | 4 ++++ app/views/spree/admin/products/bulk_edit/_products.html.haml | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 app/assets/javascripts/admin/filters/import_date_filter.js.coffee diff --git a/app/assets/javascripts/admin/bulk_product_update.js.coffee b/app/assets/javascripts/admin/bulk_product_update.js.coffee index 500b4547d4..a9f2f20d14 100644 --- a/app/assets/javascripts/admin/bulk_product_update.js.coffee +++ b/app/assets/javascripts/admin/bulk_product_update.js.coffee @@ -28,7 +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 = "" + $scope.importDateFilter = "0" $scope.products = BulkProducts.products $scope.filteredProducts = [] $scope.currentFilters = [] diff --git a/app/assets/javascripts/admin/filters/import_date_filter.js.coffee b/app/assets/javascripts/admin/filters/import_date_filter.js.coffee new file mode 100644 index 0000000000..768d305afd --- /dev/null +++ b/app/assets/javascripts/admin/filters/import_date_filter.js.coffee @@ -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 } ) diff --git a/app/views/spree/admin/products/bulk_edit/_products.html.haml b/app/views/spree/admin/products/bulk_edit/_products.html.haml index 4072fd0220..f4193ca883 100644 --- a/app/views/spree/admin/products/bulk_edit/_products.html.haml +++ b/app/views/spree/admin/products/bulk_edit/_products.html.haml @@ -8,7 +8,7 @@ = render 'spree/admin/products/bulk_edit/products_head' - %tbody{ 'ng-repeat' => 'product in filteredProducts = ( products | filter:query | producer: producerFilter | category: categoryFilter | filter: (importDateFilter != 0) && {import_date: importDateFilter} | limitTo:limit )', 'ng-class-even' => "'even'", 'ng-class-odd' => "'odd'" } + %tbody{ 'ng-repeat' => 'product in filteredProducts = ( products | filter:query | producer: producerFilter | category: categoryFilter | importDate: importDateFilter | limitTo:limit )', 'ng-class-even' => "'even'", 'ng-class-odd' => "'odd'" } = render 'spree/admin/products/bulk_edit/products_product' = render 'spree/admin/products/bulk_edit/products_variant' From 0d7352813c54f4607760ceba2de058210bf4af82 Mon Sep 17 00:00:00 2001 From: Frank West Date: Fri, 15 Jun 2018 11:41:22 -0700 Subject: [PATCH 174/206] Set the SSL protocol for secure connections There are ssl errors when using stripe through phantom js. This allows other SSL protocols now. --- spec/spec_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 08ad5094bc..40dc6cd256 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -36,7 +36,7 @@ require 'capybara/poltergeist' Capybara.javascript_driver = :poltergeist Capybara.register_driver :poltergeist do |app| - options = {phantomjs_options: ['--load-images=no'], window_size: [1280, 3600], timeout: 2.minutes} + options = {phantomjs_options: ['--load-images=no', '--ssl-protocol=any'], window_size: [1280, 3600], timeout: 2.minutes} # Extend poltergeist's timeout to allow ample time to use pry in browser thread #options.merge! {timeout: 5.minutes} # Enable the remote inspector: Use page.driver.debug to open a remote debugger in chrome From 66567fd9e6c07e02586ac65bca271968290a9b6d Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Wed, 6 Jun 2018 15:23:20 +1000 Subject: [PATCH 175/206] Unify report table rendering There is a lot of code duplication in the report views and we would like to change that code. So we move it into one file first. --- .../spree/admin/reports_controller_decorator.rb | 2 ++ app/views/spree/admin/reports/_table.html.haml | 15 +++++++++++++++ .../spree/admin/reports/bulk_coop.html.haml | 17 ++--------------- .../spree/admin/reports/customers.html.haml | 16 +--------------- .../reports/order_cycle_management.html.haml | 16 +--------------- .../reports/orders_and_distributors.html.haml | 16 +--------------- .../reports/orders_and_fulfillment.html.haml | 16 +--------------- app/views/spree/admin/reports/packing.html.haml | 16 +--------------- .../spree/admin/reports/payments.html.haml | 17 ++--------------- .../reports/products_and_inventory.html.haml | 16 +--------------- .../reports/users_and_enterprises.html.haml | 17 ++--------------- .../spree/admin/reports/xero_invoices.html.haml | 14 +------------- spec/features/admin/reports_spec.rb | 2 +- 13 files changed, 31 insertions(+), 149 deletions(-) create mode 100644 app/views/spree/admin/reports/_table.html.haml diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index 52abed6fde..7dea34335d 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -240,6 +240,8 @@ Spree::Admin::ReportsController.class_eval do 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 diff --git a/app/views/spree/admin/reports/_table.html.haml b/app/views/spree/admin/reports/_table.html.haml new file mode 100644 index 0000000000..11fed58584 --- /dev/null +++ b/app/views/spree/admin/reports/_table.html.haml @@ -0,0 +1,15 @@ +%br +%br +%table{id: id} + %thead + %tr + - @header.each do |heading| + %th=heading + %tbody + - @table.each do |row| + %tr + - row.each do |column| + %td= column + - if @table.empty? + %tr + %td{:colspan => "2"}= t(:none) diff --git a/app/views/spree/admin/reports/bulk_coop.html.haml b/app/views/spree/admin/reports/bulk_coop.html.haml index 71bd570658..a3a3b8e367 100644 --- a/app/views/spree/admin/reports/bulk_coop.html.haml +++ b/app/views/spree/admin/reports/bulk_coop.html.haml @@ -15,18 +15,5 @@ %br %br = button t(:search) -%br -%br -%table#listing_orders.index - %thead - %tr{'data-hook' => "orders_header"} - - @report.header.each do |heading| - %th=heading - %tbody - - @table.each do |row| - %tr - - row.each do |column| - %td= column - - if @table.empty? - %tr - %td{:colspan => "2"}= t(:none) + += render "table", id: "listing_orders" diff --git a/app/views/spree/admin/reports/customers.html.haml b/app/views/spree/admin/reports/customers.html.haml index 3ab25e5c6c..63d084f016 100644 --- a/app/views/spree/admin/reports/customers.html.haml +++ b/app/views/spree/admin/reports/customers.html.haml @@ -29,18 +29,4 @@ %br = button t(:search) -%br -%br -%table#listing_customers.index - %thead - %tr{'data-hook' => "orders_header"} - - @report.header.each do |heading| - %th=heading - %tbody - - @report.table.each do |row| - %tr - - row.each do |column| - %td= column - - if @report.table.empty? - %tr - %td{:colspan => "2"}= t(:none) += render "table", id: "listing_customers" diff --git a/app/views/spree/admin/reports/order_cycle_management.html.haml b/app/views/spree/admin/reports/order_cycle_management.html.haml index bf4a1ed2e8..54d9eca68a 100644 --- a/app/views/spree/admin/reports/order_cycle_management.html.haml +++ b/app/views/spree/admin/reports/order_cycle_management.html.haml @@ -29,18 +29,4 @@ .row = button t(:search) -%br -%br -%table#listing_ocm_orders.index - %thead - %tr{'data-hook' => "orders_header"} - - @report.header.each do |heading| - %th=heading - %tbody - - @table.each do |row| - %tr - - row.each do |column| - %td= column - - if @table.empty? - %tr - %td{:colspan => "2"}= t(:none) += render "table", id: "listing_ocm_orders" diff --git a/app/views/spree/admin/reports/orders_and_distributors.html.haml b/app/views/spree/admin/reports/orders_and_distributors.html.haml index aa710b1db6..2279438c25 100644 --- a/app/views/spree/admin/reports/orders_and_distributors.html.haml +++ b/app/views/spree/admin/reports/orders_and_distributors.html.haml @@ -6,18 +6,4 @@ %br = button t(:search) -%br -%br -%table#listing_orders.index - %thead - %tr{'data-hook' => 'orders_header'} - - @report.header.each do |heading| - %th=heading - %tbody - - @report.table.each do |row| - %tr - - row.each do |column| - %td= column - - if @report.table.empty? - %tr - %td{:colspan => "2"}= t(:none) += render "table", id: "listing_orders" diff --git a/app/views/spree/admin/reports/orders_and_fulfillment.html.haml b/app/views/spree/admin/reports/orders_and_fulfillment.html.haml index 5bda882108..9bf5b6bd24 100644 --- a/app/views/spree/admin/reports/orders_and_fulfillment.html.haml +++ b/app/views/spree/admin/reports/orders_and_fulfillment.html.haml @@ -25,18 +25,4 @@ .row = button t(:search) -%br -%br -%table#listing_orders.index - %thead - %tr{'data-hook' => "orders_header"} - - @report.header.each do |heading| - %th=heading - %tbody - - @table.each do |row| - %tr - - row.each do |column| - %td= column - - if @table.empty? - %tr - %td{:colspan => "2"}= t(:none) += render "table", id: "listing_orders" diff --git a/app/views/spree/admin/reports/packing.html.haml b/app/views/spree/admin/reports/packing.html.haml index fa9d22eb70..742520d715 100644 --- a/app/views/spree/admin/reports/packing.html.haml +++ b/app/views/spree/admin/reports/packing.html.haml @@ -25,18 +25,4 @@ .row = button t(:search) -%br -%br -%table#listing_orders.index - %thead - %tr{'data-hook' => "orders_header"} - - @report.header.each do |heading| - %th=heading - %tbody - - @table.each do |row| - %tr - - row.each do |column| - %td= column - - if @table.empty? - %tr - %td{:colspan => "2"}= t(:none) += render "table", id: "listing_orders" diff --git a/app/views/spree/admin/reports/payments.html.haml b/app/views/spree/admin/reports/payments.html.haml index b69708fbd1..23b046e7c0 100644 --- a/app/views/spree/admin/reports/payments.html.haml +++ b/app/views/spree/admin/reports/payments.html.haml @@ -15,18 +15,5 @@ %br %br = button t(:search) -%br -%br -%table#listing_orders.index - %thead - %tr{'data-hook' => "orders_header"} - - @report.header.each do |heading| - %th=heading - %tbody - - @table.each do |row| - %tr - - row.each do |column| - %td= column - - if @table.empty? - %tr - %td{:colspan => "2"}= t(:none) + += render "table", id: "listing_orders" diff --git a/app/views/spree/admin/reports/products_and_inventory.html.haml b/app/views/spree/admin/reports/products_and_inventory.html.haml index 40c4fbfccf..3d552b81be 100644 --- a/app/views/spree/admin/reports/products_and_inventory.html.haml +++ b/app/views/spree/admin/reports/products_and_inventory.html.haml @@ -31,18 +31,4 @@ = label_tag :csv, t(:report_customers_csv) %br = button t(:search) -%br -%br -%table#listing_products.index - %thead - %tr{'data-hook' => "products_header"} - - @report.header.each do |heading| - %th=heading - %tbody - - @report.table.each do |row| - %tr - - row.each do |column| - %td= column - - if @report.table.empty? - %tr - %td{:colspan => "2"}= t(:none) += render "table", id: "listing_products" diff --git a/app/views/spree/admin/reports/users_and_enterprises.html.haml b/app/views/spree/admin/reports/users_and_enterprises.html.haml index 1b57373c38..d7a521911f 100644 --- a/app/views/spree/admin/reports/users_and_enterprises.html.haml +++ b/app/views/spree/admin/reports/users_and_enterprises.html.haml @@ -17,18 +17,5 @@ = label_tag :csv, t(:report_customers_csv) .row = button t(:search) -%br -%br -%table#users_and_enterprises - %thead - %tr - - @report.header.each do |heading| - %th=heading - %tbody - - @report.table.each do |row| - %tr - - row.each do |column| - %td= column - - if @report.table.empty? - %tr - %td{:colspan => "2"}= t(:none) \ No newline at end of file + += render "table", id: "users_and_enterprises" diff --git a/app/views/spree/admin/reports/xero_invoices.html.haml b/app/views/spree/admin/reports/xero_invoices.html.haml index 8669089f81..0139b42a68 100644 --- a/app/views/spree/admin/reports/xero_invoices.html.haml +++ b/app/views/spree/admin/reports/xero_invoices.html.haml @@ -32,16 +32,4 @@ .four.columns.alpha= button t(:search) -%table#listing_invoices.index - %thead - %tr - - @report.header.each do |header| - %th= header - %tbody - - @report.table.each do |row| - %tr - - row.each do |column| - %td= column - - if @report.table.empty? - %tr - %td{:colspan => "2"}= t(:none) += render "table", id: "listing_invoices" diff --git a/spec/features/admin/reports_spec.rb b/spec/features/admin/reports_spec.rb index 74260047f6..31be0bd499 100644 --- a/spec/features/admin/reports_spec.rb +++ b/spec/features/admin/reports_spec.rb @@ -119,7 +119,7 @@ feature %q{ #select 'Pack By Customer', from: 'report_type' click_button 'Search' - rows = find("table#listing_orders.index").all("thead tr") + rows = find("table#listing_orders").all("thead tr") table = rows.map { |r| r.all("th").map { |c| c.text.strip } } expect(table.sort).to eq([ ["Hub", "Code", "First Name", "Last Name", "Supplier", "Product", "Variant", "Quantity", "TempControlled?"] From 6243640bcab64d9d1ab49fd278b294eaceae0017 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Wed, 6 Jun 2018 16:33:53 +1000 Subject: [PATCH 176/206] Unify sales tax report rendering with others --- .../spree/admin/reports/_link_order.html.haml | 1 + .../spree/admin/reports/_table.html.haml | 14 +++++++++---- .../spree/admin/reports/sales_tax.html.haml | 20 +------------------ 3 files changed, 12 insertions(+), 23 deletions(-) create mode 100644 app/views/spree/admin/reports/_link_order.html.haml diff --git a/app/views/spree/admin/reports/_link_order.html.haml b/app/views/spree/admin/reports/_link_order.html.haml new file mode 100644 index 0000000000..9f0b72b4f7 --- /dev/null +++ b/app/views/spree/admin/reports/_link_order.html.haml @@ -0,0 +1 @@ +%a.edit-order{href: "/admin/orders/#{value}"}= value diff --git a/app/views/spree/admin/reports/_table.html.haml b/app/views/spree/admin/reports/_table.html.haml index 11fed58584..d0c4af8a5d 100644 --- a/app/views/spree/admin/reports/_table.html.haml +++ b/app/views/spree/admin/reports/_table.html.haml @@ -1,15 +1,21 @@ +- column_partials ||= {} %br %br %table{id: id} %thead %tr - @header.each do |heading| - %th=heading + %th= heading %tbody - @table.each do |row| %tr - - row.each do |column| - %td= column + - row.each_with_index do |cell_value, column_index| + %td + - partial = column_partials[column_index] + - if partial + = render partial, value: cell_value + - else + = cell_value - if @table.empty? %tr - %td{:colspan => "2"}= t(:none) + %td{colspan: @header.count}= t(:none) diff --git a/app/views/spree/admin/reports/sales_tax.html.haml b/app/views/spree/admin/reports/sales_tax.html.haml index 9971b2c5cb..d64f73ec4d 100644 --- a/app/views/spree/admin/reports/sales_tax.html.haml +++ b/app/views/spree/admin/reports/sales_tax.html.haml @@ -16,22 +16,4 @@ %br = button t(:search) -%br -%br -%table#listing_orders.index - %thead - %tr{'data-hook' => "orders_header"} - - @report.header.each do |heading| - %th= heading - %tbody - - @report.table.each do |row| - %tr - - row.each_with_index do |column, i| - - if i == 0 - %td - %a.edit-order{'href' => "/admin/orders/#{column}"}= column - - else - %td= column - - if @report.table.empty? - %tr - %td{:colspan => @report.header.count}= t(:none) += render "table", id: "listing_orders", column_partials: {0 => "link_order"} From 7a2218fe26e4413e3956e5171175a5665a7d062b Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Wed, 6 Jun 2018 17:36:19 +1000 Subject: [PATCH 177/206] Style the reports controller --- .rubocop_todo.yml | 9 --------- .../spree/admin/reports_controller_decorator.rb | 11 +++++------ 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index bc47b9054c..445f0dbc35 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -271,7 +271,6 @@ Layout/EmptyLinesAroundArguments: Layout/EmptyLinesAroundBlockBody: Exclude: - 'app/controllers/spree/admin/orders_controller_decorator.rb' - - 'app/controllers/spree/admin/reports_controller_decorator.rb' - 'app/controllers/spree/api/orders_controller_decorator.rb' - 'app/controllers/spree/api/products_controller_decorator.rb' - 'app/controllers/spree/checkout_controller_decorator.rb' @@ -442,7 +441,6 @@ Layout/IndentHash: # SupportedStyles: normal, rails Layout/IndentationConsistency: Exclude: - - 'app/controllers/spree/admin/reports_controller_decorator.rb' - 'lib/open_food_network/permissions.rb' - 'spec/controllers/admin/tag_rules_controller_spec.rb' - 'spec/features/consumer/shopping/checkout_spec.rb' @@ -824,7 +822,6 @@ Layout/SpaceInsideHashLiteralBraces: - 'app/controllers/checkout_controller.rb' - 'app/controllers/spree/admin/line_items_controller_decorator.rb' - 'app/controllers/spree/admin/products_controller_decorator.rb' - - 'app/controllers/spree/admin/reports_controller_decorator.rb' - 'app/controllers/spree/admin/search_controller_decorator.rb' - 'app/controllers/spree/orders_controller_decorator.rb' - 'app/helpers/admin/business_model_configuration_helper.rb' @@ -1274,7 +1271,6 @@ Naming/UncommunicativeMethodParamName: # SupportedStyles: snake_case, camelCase Naming/VariableName: Exclude: - - 'app/controllers/spree/admin/reports_controller_decorator.rb' - 'app/helpers/admin/injection_helper.rb' # Offense count: 16 @@ -1554,7 +1550,6 @@ Rails/Presence: Rails/Present: Exclude: - 'app/controllers/spree/admin/orders_controller_decorator.rb' - - 'app/controllers/spree/admin/reports_controller_decorator.rb' - 'app/models/producer_property.rb' - 'lib/open_food_network/products_and_inventory_report.rb' @@ -1856,7 +1851,6 @@ Style/ConditionalAssignment: - 'app/controllers/checkout_controller.rb' - 'app/controllers/spree/admin/base_controller_decorator.rb' - 'app/controllers/spree/admin/payment_methods_controller_decorator.rb' - - 'app/controllers/spree/admin/reports_controller_decorator.rb' - 'app/controllers/spree/admin/search_controller_decorator.rb' - 'app/helpers/spree/admin/orders_helper_decorator.rb' - 'app/models/spree/calculator/per_item_decorator.rb' @@ -1992,7 +1986,6 @@ Style/HashSyntax: - 'app/controllers/spree/admin/line_items_controller_decorator.rb' - 'app/controllers/spree/admin/orders_controller_decorator.rb' - 'app/controllers/spree/admin/products_controller_decorator.rb' - - 'app/controllers/spree/admin/reports_controller_decorator.rb' - 'app/controllers/spree/admin/search_controller_decorator.rb' - 'app/controllers/spree/admin/shipping_methods_controller_decorator.rb' - 'app/controllers/spree/api/products_controller_decorator.rb' @@ -2437,7 +2430,6 @@ Style/RescueModifier: Exclude: - 'app/controllers/application_controller.rb' - 'app/controllers/spree/admin/orders_controller_decorator.rb' - - 'app/controllers/spree/admin/reports_controller_decorator.rb' - 'lib/tasks/data.rake' # Offense count: 5 @@ -2528,7 +2520,6 @@ Style/TrivialAccessors: # Cop supports --auto-correct. Style/UnlessElse: Exclude: - - 'app/controllers/spree/admin/reports_controller_decorator.rb' - 'app/models/enterprise.rb' - 'lib/open_food_network/order_grouper.rb' diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index 7dea34335d..2af21ddac3 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -15,7 +15,6 @@ require 'open_food_network/payments_report' require 'open_food_network/orders_and_fulfillments_report' Spree::Admin::ReportsController.class_eval do - include Spree::ReportsHelper before_filter :cache_search_state @@ -183,11 +182,11 @@ 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, render_content? - else - @report = OpenFoodNetwork::LettuceShareReport.new spree_current_user, params, render_content? - 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 From 67c2574b0b488abe96bb820ae6b0354c12babdb3 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Wed, 6 Jun 2018 17:54:26 +1000 Subject: [PATCH 178/206] Show report search instructions Solves: https://github.com/openfoodfoundation/openfoodnetwork/issues/2356 --- app/assets/stylesheets/admin/reports.css.scss | 7 ++++ .../admin/reports_controller_decorator.rb | 2 + .../spree/admin/reports/_table.html.haml | 42 ++++++++++--------- config/locales/en.yml | 2 + spec/features/admin/reports_spec.rb | 8 ++++ 5 files changed, 42 insertions(+), 19 deletions(-) create mode 100644 app/assets/stylesheets/admin/reports.css.scss diff --git a/app/assets/stylesheets/admin/reports.css.scss b/app/assets/stylesheets/admin/reports.css.scss new file mode 100644 index 0000000000..aab6be0c0f --- /dev/null +++ b/app/assets/stylesheets/admin/reports.css.scss @@ -0,0 +1,7 @@ +.report__message { + margin-top: 2em; + border: 1px solid #cee1f4; + border-radius: .5em; + padding: .5em; + text-align: center; +} diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index 2af21ddac3..657c6b6bb8 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -17,6 +17,8 @@ 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] diff --git a/app/views/spree/admin/reports/_table.html.haml b/app/views/spree/admin/reports/_table.html.haml index d0c4af8a5d..7d0d9aaef7 100644 --- a/app/views/spree/admin/reports/_table.html.haml +++ b/app/views/spree/admin/reports/_table.html.haml @@ -1,21 +1,25 @@ - column_partials ||= {} -%br -%br -%table{id: id} - %thead - %tr - - @header.each do |heading| - %th= heading - %tbody - - @table.each do |row| +- if render_content? + %br + %br + %table{id: id} + %thead %tr - - row.each_with_index do |cell_value, column_index| - %td - - partial = column_partials[column_index] - - if partial - = render partial, value: cell_value - - else - = cell_value - - if @table.empty? - %tr - %td{colspan: @header.count}= t(:none) + - @header.each do |heading| + %th= heading + %tbody + - @table.each do |row| + %tr + - row.each_with_index do |cell_value, column_index| + %td + - partial = column_partials[column_index] + - if partial + = render partial, value: cell_value + - else + = cell_value + - if @table.empty? + %tr + %td{colspan: @header.count}= t(:none) +- else + %p.report__message + = t(".select_and_search") diff --git a/config/locales/en.yml b/config/locales/en.yml index 2a38a62e36..707579765a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2598,6 +2598,8 @@ See the %{link} to find out more about %{sitename}'s features and to start using display_as: display_as: Display As reports: + table: + select_and_search: "Select filters and click on SEARCH to access your data." bulk_coop: bulk_coop_supplier_report: 'Bulk Co-op - Totals by Supplier' bulk_coop_allocation: 'Bulk Co-op - Allocation' diff --git a/spec/features/admin/reports_spec.rb b/spec/features/admin/reports_spec.rb index 31be0bd499..3435d487e6 100644 --- a/spec/features/admin/reports_spec.rb +++ b/spec/features/admin/reports_spec.rb @@ -39,6 +39,8 @@ feature %q{ scenario "customers report" do click_link "Mailing List" expect(page).to have_select('report_type', selected: 'Mailing List') + expect(page).to have_content "click on SEARCH" + click_button "Search" rows = find("table#listing_customers").all("thead tr") table = rows.map { |r| r.all("th").map { |c| c.text.strip } } @@ -51,6 +53,7 @@ feature %q{ click_link "Addresses" expect(page).to have_select('report_type', selected: 'Addresses') + click_button "Search" rows = find("table#listing_customers").all("thead tr") table = rows.map { |r| r.all("th").map { |c| c.text.strip } } expect(table.sort).to eq([ @@ -67,6 +70,7 @@ feature %q{ scenario "payment method report" do click_link "Payment Methods Report" + click_button "Search" rows = find("table#listing_ocm_orders").all("thead tr") table = rows.map { |r| r.all("th").map { |c| c.text.strip } } expect(table.sort).to eq([ @@ -76,6 +80,7 @@ feature %q{ scenario "delivery report" do click_link "Delivery Report" + click_button "Search" rows = find("table#listing_ocm_orders").all("thead tr") table = rows.map { |r| r.all("th").map { |c| c.text.strip } } expect(table.sort).to eq([ @@ -148,6 +153,7 @@ feature %q{ login_to_admin_section click_link 'Reports' click_link 'Orders And Distributors' + click_button 'Search' expect(page).to have_content 'Order date' end @@ -156,6 +162,7 @@ feature %q{ login_to_admin_section click_link 'Reports' click_link 'Bulk Co-Op' + click_button 'Search' expect(page).to have_content 'Supplier' end @@ -164,6 +171,7 @@ feature %q{ login_to_admin_section click_link 'Reports' click_link 'Payment Reports' + click_button 'Search' expect(page).to have_content 'Payment State' end From b7770510a7ab38fb3d91b069f1a332dc305bcffa Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Wed, 6 Jun 2018 17:57:37 +1000 Subject: [PATCH 179/206] Use CSS for layout --- app/assets/stylesheets/admin/reports.css.scss | 3 +++ app/views/spree/admin/reports/_table.html.haml | 4 +--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/assets/stylesheets/admin/reports.css.scss b/app/assets/stylesheets/admin/reports.css.scss index aab6be0c0f..13df70de8b 100644 --- a/app/assets/stylesheets/admin/reports.css.scss +++ b/app/assets/stylesheets/admin/reports.css.scss @@ -1,3 +1,6 @@ +.report__table { + margin-top: 2em; +} .report__message { margin-top: 2em; border: 1px solid #cee1f4; diff --git a/app/views/spree/admin/reports/_table.html.haml b/app/views/spree/admin/reports/_table.html.haml index 7d0d9aaef7..7cc612a82d 100644 --- a/app/views/spree/admin/reports/_table.html.haml +++ b/app/views/spree/admin/reports/_table.html.haml @@ -1,8 +1,6 @@ - column_partials ||= {} - if render_content? - %br - %br - %table{id: id} + %table.report__table{id: id} %thead %tr - @header.each do |heading| From 836a5836d9ae9ab50b4c5874a778b04130568061 Mon Sep 17 00:00:00 2001 From: luisramos0 Date: Mon, 28 May 2018 23:31:57 +0100 Subject: [PATCH 180/206] fixed country_state selectors on checkout --- .../checkout/country_controller.js.coffee | 8 +++ app/helpers/checkout_helper.rb | 4 -- app/views/checkout/_billing.html.haml | 38 ++++++++------ .../checkout/_shipping_ship_address.html.haml | 52 ++++++++++--------- app/views/checkout/edit.html.haml | 1 + 5 files changed, 58 insertions(+), 45 deletions(-) create mode 100644 app/assets/javascripts/darkswarm/controllers/checkout/country_controller.js.coffee diff --git a/app/assets/javascripts/darkswarm/controllers/checkout/country_controller.js.coffee b/app/assets/javascripts/darkswarm/controllers/checkout/country_controller.js.coffee new file mode 100644 index 0000000000..347fa367ad --- /dev/null +++ b/app/assets/javascripts/darkswarm/controllers/checkout/country_controller.js.coffee @@ -0,0 +1,8 @@ +Darkswarm.controller "CountryCtrl", ($scope, availableCountries) -> + + $scope.countries = availableCountries + + $scope.countriesById = $scope.countries.reduce (obj, country) -> + obj[country.id] = country + obj + , {} diff --git a/app/helpers/checkout_helper.rb b/app/helpers/checkout_helper.rb index a874d996d9..b0bca2c05e 100644 --- a/app/helpers/checkout_helper.rb +++ b/app/helpers/checkout_helper.rb @@ -76,10 +76,6 @@ module CheckoutHelper [[]] + 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, diff --git a/app/views/checkout/_billing.html.haml b/app/views/checkout/_billing.html.haml index bd590d63a5..ef3d09e052 100644 --- a/app/views/checkout/_billing.html.haml +++ b/app/views/checkout/_billing.html.haml @@ -19,25 +19,29 @@ %input{type: :checkbox, "ng-model" => "Checkout.default_bill_address"} = t :checkout_default_bill_address - = f.fields_for :bill_address, @order.bill_address do |ba| - .row - .small-12.columns - = validated_input t(:address), "order.bill_address.address1", "ofn-focus" => "accordion['billing']" - .row - .small-12.columns - = validated_input t(:address2), "order.bill_address.address2", required: false - .row - .small-6.columns - = validated_input t(:city), "order.bill_address.city" + %div{ "ng-controller" => "CountryCtrl" } + = f.fields_for :bill_address, @order.bill_address do |ba| + .row + .small-12.columns + = validated_input t(:address), "order.bill_address.address1", "ofn-focus" => "accordion['billing']" + .row + .small-12.columns + = validated_input t(:address2), "order.bill_address.address2", required: false + .row + .small-6.columns + = validated_input t(:city), "order.bill_address.city" - .small-6.columns - = validated_select t(:state), "order.bill_address.state_id", checkout_state_options(:billing) - .row - .small-6.columns - = validated_input t(:postcode), "order.bill_address.zipcode" + .small-6.columns + %label{ for: 'order.bill_address.state_id' } {{ "state" | t }} + %select.chunky{ id: 'order.bill_address.state_id', ng: { model: 'order.bill_address.state_id', options: 's.id as s.abbr for s in countriesById[order.bill_address.country_id].states' } } - .small-6.columns.right - = validated_select t(:country), "order.bill_address.country_id", checkout_country_options + .row + .small-6.columns + = validated_input t(:postcode), "order.bill_address.zipcode" + + .small-6.columns.right + %label{ for: 'order.bill_address.country_id' } {{ "country" | t }} + %select.chunky{ id: 'order.bill_address.country_id', required: false, ng: { init: "order.bill_address.country_id = #{Spree::Config[:default_country_id]}", model: 'order.bill_address.country_id', options: 'c.id as c.name for c in countries' } } .row .small-12.columns.text-right diff --git a/app/views/checkout/_shipping_ship_address.html.haml b/app/views/checkout/_shipping_ship_address.html.haml index 7b4db0d54c..99605480b2 100644 --- a/app/views/checkout/_shipping_ship_address.html.haml +++ b/app/views/checkout/_shipping_ship_address.html.haml @@ -1,28 +1,32 @@ .small-12.columns #ship_address{"ng-if" => "Checkout.requireShipAddress()"} %div.visible{"ng-if" => "!Checkout.ship_address_same_as_billing"} - .row - .small-6.columns - = validated_input t(:first_name), "order.ship_address.firstname", "ofn-focus" => "accordion['shipping']" - .small-6.columns - = validated_input t(:last_name), "order.ship_address.lastname" - .row - .small-12.columns - = validated_input t(:address), "order.ship_address.address1" - .row - .small-12.columns - = validated_input t(:address2), "order.ship_address.address2", required: false - .row - .small-6.columns - = validated_input t(:city), "order.ship_address.city" - .small-6.columns - = validated_select t(:state), "order.ship_address.state_id", checkout_state_options(:shipping) - .row - .small-6.columns - = validated_input t(:postcode), "order.ship_address.zipcode" - .small-6.columns.right - = validated_select t(:country), "order.ship_address.country_id", checkout_country_options + %div{ "ng-controller" => "CountryCtrl" } + .row + .small-6.columns + = validated_input t(:first_name), "order.ship_address.firstname", "ofn-focus" => "accordion['shipping']" + .small-6.columns + = validated_input t(:last_name), "order.ship_address.lastname" + .row + .small-12.columns + = validated_input t(:address), "order.ship_address.address1" + .row + .small-12.columns + = validated_input t(:address2), "order.ship_address.address2", required: false + .row + .small-6.columns + = validated_input t(:city), "order.ship_address.city" + .small-6.columns + %label{ for: 'order.ship_address.state_id' } {{ "state" | t }} + %select.chunky{ id: 'order.ship_address.state_id', ng: { model: 'order.ship_address.state_id', options: 's.id as s.abbr for s in countriesById[order.ship_address.country_id].states' } } - .row - .small-6.columns - = validated_input t(:phone), "order.ship_address.phone" + .row + .small-6.columns + = validated_input t(:postcode), "order.ship_address.zipcode" + .small-6.columns.right + %label{ for: 'order.ship_address.country_id' } {{ "country" | t }} + %select.chunky{ id: 'order.ship_address.country_id', required: false, ng: { init: "order.ship_address.country_id = #{Spree::Config[:default_country_id]}", model: 'order.ship_address.country_id', options: 'c.id as c.name for c in countries' } } + + .row + .small-6.columns + = validated_input t(:phone), "order.ship_address.phone" diff --git a/app/views/checkout/edit.html.haml b/app/views/checkout/edit.html.haml index b3d30ed442..75c9eff08f 100644 --- a/app/views/checkout/edit.html.haml +++ b/app/views/checkout/edit.html.haml @@ -2,6 +2,7 @@ = t :checkout_title = inject_enterprise_and_relatives += inject_available_countries .darkswarm.footer-pad - content_for :order_cycle_form do From 3cb0b76d21be4b7c1d6d7bc9d2fc067dd63754a2 Mon Sep 17 00:00:00 2001 From: luisramos0 Date: Thu, 31 May 2018 00:39:36 +0100 Subject: [PATCH 181/206] fixed checkout tests by replacing state abbr with name in address selector boxes --- app/views/checkout/_billing.html.haml | 2 +- app/views/checkout/_shipping_ship_address.html.haml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/checkout/_billing.html.haml b/app/views/checkout/_billing.html.haml index ef3d09e052..4e451b51cd 100644 --- a/app/views/checkout/_billing.html.haml +++ b/app/views/checkout/_billing.html.haml @@ -33,7 +33,7 @@ .small-6.columns %label{ for: 'order.bill_address.state_id' } {{ "state" | t }} - %select.chunky{ id: 'order.bill_address.state_id', ng: { model: 'order.bill_address.state_id', options: 's.id as s.abbr for s in countriesById[order.bill_address.country_id].states' } } + %select.chunky{ id: 'order.bill_address.state_id', ng: { model: 'order.bill_address.state_id', options: 's.id as s.name for s in countriesById[order.bill_address.country_id].states' } } .row .small-6.columns diff --git a/app/views/checkout/_shipping_ship_address.html.haml b/app/views/checkout/_shipping_ship_address.html.haml index 99605480b2..aa64e6c521 100644 --- a/app/views/checkout/_shipping_ship_address.html.haml +++ b/app/views/checkout/_shipping_ship_address.html.haml @@ -18,7 +18,7 @@ = validated_input t(:city), "order.ship_address.city" .small-6.columns %label{ for: 'order.ship_address.state_id' } {{ "state" | t }} - %select.chunky{ id: 'order.ship_address.state_id', ng: { model: 'order.ship_address.state_id', options: 's.id as s.abbr for s in countriesById[order.ship_address.country_id].states' } } + %select.chunky{ id: 'order.ship_address.state_id', ng: { model: 'order.ship_address.state_id', options: 's.id as s.name for s in countriesById[order.ship_address.country_id].states' } } .row .small-6.columns From ea9ea83fee377e7534b5c6b6584b983905fcd64a Mon Sep 17 00:00:00 2001 From: luisramos0 Date: Thu, 31 May 2018 23:00:14 +0100 Subject: [PATCH 182/206] improved checkout country and state selectors code --- app/helpers/checkout_helper.rb | 10 ---------- app/views/checkout/_billing.html.haml | 7 ++----- app/views/checkout/_shipping_ship_address.html.haml | 8 ++------ 3 files changed, 4 insertions(+), 21 deletions(-) diff --git a/app/helpers/checkout_helper.rb b/app/helpers/checkout_helper.rb index b0bca2c05e..bc885bd5dc 100644 --- a/app/helpers/checkout_helper.rb +++ b/app/helpers/checkout_helper.rb @@ -66,16 +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 validated_input(name, path, args = {}) attributes = { required: true, diff --git a/app/views/checkout/_billing.html.haml b/app/views/checkout/_billing.html.haml index 4e451b51cd..07b0d0f251 100644 --- a/app/views/checkout/_billing.html.haml +++ b/app/views/checkout/_billing.html.haml @@ -32,16 +32,13 @@ = validated_input t(:city), "order.bill_address.city" .small-6.columns - %label{ for: 'order.bill_address.state_id' } {{ "state" | t }} - %select.chunky{ id: 'order.bill_address.state_id', ng: { model: 'order.bill_address.state_id', options: 's.id as s.name for s in countriesById[order.bill_address.country_id].states' } } - + = validated_select t(:state), "order.bill_address.state_id", {}, {"ng-options" => "s.id as s.name for s in countriesById[order.bill_address.country_id].states"} .row .small-6.columns = validated_input t(:postcode), "order.bill_address.zipcode" .small-6.columns.right - %label{ for: 'order.bill_address.country_id' } {{ "country" | t }} - %select.chunky{ id: 'order.bill_address.country_id', required: false, ng: { init: "order.bill_address.country_id = #{Spree::Config[:default_country_id]}", model: 'order.bill_address.country_id', options: 'c.id as c.name for c in countries' } } + = validated_select t(:country), "order.bill_address.country_id", {}, {"ng-init" => "order.bill_address.country_id = order.bill_address.country_id || #{Spree::Config[:default_country_id]}", "ng-options" => "c.id as c.name for c in countries"} .row .small-12.columns.text-right diff --git a/app/views/checkout/_shipping_ship_address.html.haml b/app/views/checkout/_shipping_ship_address.html.haml index aa64e6c521..1119886a47 100644 --- a/app/views/checkout/_shipping_ship_address.html.haml +++ b/app/views/checkout/_shipping_ship_address.html.haml @@ -17,16 +17,12 @@ .small-6.columns = validated_input t(:city), "order.ship_address.city" .small-6.columns - %label{ for: 'order.ship_address.state_id' } {{ "state" | t }} - %select.chunky{ id: 'order.ship_address.state_id', ng: { model: 'order.ship_address.state_id', options: 's.id as s.name for s in countriesById[order.ship_address.country_id].states' } } - + = validated_select t(:state), "order.ship_address.state_id", {}, {"ng-options" => "s.id as s.name for s in countriesById[order.ship_address.country_id].states"} .row .small-6.columns = validated_input t(:postcode), "order.ship_address.zipcode" .small-6.columns.right - %label{ for: 'order.ship_address.country_id' } {{ "country" | t }} - %select.chunky{ id: 'order.ship_address.country_id', required: false, ng: { init: "order.ship_address.country_id = #{Spree::Config[:default_country_id]}", model: 'order.ship_address.country_id', options: 'c.id as c.name for c in countries' } } - + = validated_select t(:country), "order.ship_address.country_id", {}, {"ng-init" => "order.ship_address.country_id = order.ship_address.country_id || #{Spree::Config[:default_country_id]}", "ng-options" => "c.id as c.name for c in countries"} .row .small-6.columns = validated_input t(:phone), "order.ship_address.phone" From fe39d96e7f84d4fe0ec23ca6e72b04fd19dc8847 Mon Sep 17 00:00:00 2001 From: Transifex-Openfoodnetwork Date: Tue, 19 Jun 2018 05:48:59 +1000 Subject: [PATCH 183/206] Updating translations for config/locales/fr_CA.yml --- config/locales/fr_CA.yml | 2540 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 2540 insertions(+) create mode 100644 config/locales/fr_CA.yml diff --git a/config/locales/fr_CA.yml b/config/locales/fr_CA.yml new file mode 100644 index 0000000000..d1e68d1a75 --- /dev/null +++ b/config/locales/fr_CA.yml @@ -0,0 +1,2540 @@ +fr_CA: + language_name: "Français" + activerecord: + attributes: + spree/order: + payment_state: Statut du paiement + shipment_state: Statut de la livraison + completed_at: 'Passée à ' + number: N° commande + email: Email acheteur + spree/payment: + amount: Montant + order_cycle: + orders_close_at: Date de fermeture + errors: + models: + spree/user: + attributes: + email: + taken: "Un compte existe déjà pour cet e-mail. Connectez-vous ou demandez un nouveau mot de passe." + spree/order: + no_card: Aucune carte de crédit valide trouvée + order_cycle: + attributes: + orders_close_at: + after_orders_open_at: doit être après la date d'ouverture + activemodel: + errors: + models: + subscription_validator: + attributes: + subscription_line_items: + at_least_one_product: "Veuillez ajouter au moins un produit" + not_available: "^%{name} n'est pas disponible pour le rythme d'abonnement sélectionné" + ends_at: + after_begins_at: "doit être après le démarrage" + customer: + does_not_belong_to_shop: "n'appartient pas à %{shop}" + schedule: + not_coordinated_by_shop: "n'est pas coordonné par %{shop}" + payment_method: + not_available_to_shop: "n'est pas disponible pour %{shop}" + invalid_type: "doit être une méthode de paiement de type \"cash\" ou \"Stripe\"" + shipping_method: + not_available_to_shop: "n'est pas disponible pour %{shop}" + credit_card: + not_available: "n'est pas disponible" + blank: "est requis" + devise: + confirmations: + send_instructions: "Un email a été envoyé avec des instructions pour confirmer votre adresse email. Vérifiez votre boite mail!" + failed_to_send: "Une erreur est survenue lors de l'envoi de l'email de confirmation." + resend_confirmation_email: "Renvoyer l'email de confirmation." + confirmed: "Merci d'avoir confirmé votre adresse email. Vous pouvez maintenant vous connecter." + not_confirmed: "Votre adresse email n'a pas pu être confirmée. Peut-être avez-vous déjà confirmé cette adresse email?" + user_registrations: + spree_user: + signed_up_but_unconfirmed: "Un message avec un lien de confirmation a été envoyé à l'adresse email indiquée. Veuillez cliquer sur ce lien pour activer votre compte." + failure: + invalid: | + Email / mot de passe incorrect. + Étiez-vous invité la dernière fois? + Créez votre compte ou réinitialisez votre mot de passe. + unconfirmed: "Veuillez valider le lien envoyé par email pour pouvoir continuer." + already_registered: "Cet email existe déjà. Veuillez vous connecter ou utiliser une autre adresse email." + user_passwords: + spree_user: + updated_not_active: "Votre mot de passe a été mis à jour, mais votre email n'a pas encore été confirmé." + enterprise_mailer: + confirmation_instructions: + subject: "Confirmez l'adresse email pour %{enterprise}" + welcome: + subject: "%{enterprise} est maintenant sur %{sitename}" + invite_manager: + subject: "%{enterprise} vous a invité comme manager" + producer_mailer: + order_cycle: + subject: "Rapport de cycle de vente pour %{producer}" + subscription_mailer: + placement_summary_email: + subject: Un résumé des dernières commandes récemment passées + greeting: "Bonjour %{name}," + intro: "Voici le résumé des commandes qui viennent d'être passées pour la boutique %{shop}." + confirmation_summary_email: + subject: Un résumé des dernières commandes confirmées + greeting: "Bonjour %{name}," + intro: "Voici le résumé des commandes qui viennent d'être finalisées pour %{shop}." + summary_overview: + total: Un total de %{count} commandes ont été paramétrées pour traitement automatique. + success_zero: Sur celles-ci, aucune n'a été traitée avec succès. + success_some: Sur celles-ci, %{count} ont été traitées avec succès. + success_all: Toutes ont été traitées avec succès. + issues: Les détails sur les problèmes rencontrés sont affichés ci-dessous. + summary_detail: + no_message_provided: Aucun message d'erreur à afficher + changes: + title: Stock insuffisant (%{count} commandes) + explainer: Ces commandes ont été traitées mais pour certains produits, le stock était insuffisant + empty: + title: Pas de stock (%{count} commandes) + explainer: Ces commandes n'ont pas pu être traitées car les produits souhaités étaient en rupture de stok + complete: + title: Déjà traité (%{count} commandes) + explainer: Ces commandes étaient déjà marquées comme passées, et n'ont donc pas été modifiées + processing: + title: Erreur rencontrée (%{count} commandes) + explainer: Le traitement automatique de ces commandes a échoué. L'erreur a été affichée à l'endroit pertinent. + failed_payment: + title: Le paiement a échoué (%{count} commandes) + explainer: Le traitement automatique des paiements pour ces commandes a échoué. L'erreur a été affichée à l'endroit pertinent. + other: + title: Autre échec (%{count} commandes) + explainer: Le traitement automatique de ces commandes a échoué pour une raison inconnue. Cela n'aurait pas dû arriver, veuillez nous contacter si vous lisez ce message. + home: "OFN" + title: 'Open Food Network ' + welcome_to: 'Bienvenue sur ' + site_meta_description: "Tout commence dans le sol. Avec ces paysans, agriculteurs, producteurs, engagés pour une agriculture durable et régénératrice, et désireux de partager leur histoire et leur passion avec fierté. Avec ces distributeurs souhaitant reconnecter les individus à leurs aliments et aux gens qui les produisent, soutenir les prises de conscience, dans une démarche de transparence, d'honnêteté, en assurant une juste rémunération des producteurs. Avec ces acheteurs qui croient que de meilleures décisions d'achats peuvent ..." + search_by_name: Recherche par nom ou département... + producers_join: Les producteurs et autres hubs basés au Québec sont invités à rejoindre Open Food Network Canada. + charges_sales_tax: Soumis à la TVA? + print_invoice: "Imprimer la facture" + print_ticket: "Imprimer ticket de caisse" + select_ticket_printer: "Choisir l'imprimante tickets" + send_invoice: "Envoyer la facture" + resend_confirmation: "Renvoyer la confirmation" + view_order: "Voir la commande" + edit_order: "Editer la commande" + ship_order: "Envoyer la commande" + cancel_order: "Annuler la commande" + confirm_send_invoice: "La facture de cette commande va être transmise à l'acheteur. Etes-vous sûr de vouloir continuer ?" + confirm_resend_order_confirmation: "Etes-vous sûr de vouloir renvoyer le mail de confirmation de commande ?" + must_have_valid_business_number: "%{enterprise_name} doit avoir un SIRET valide avant que les factures puissent être envoyées." + invoice: "Facture" + percentage_of_sales: "%{percentage} des ventes" + capped_at_cap: "plafonné à %{cap}" + per_month: "par mois" + free: "gratuit" + free_trial: "essai gratuit" + plus_tax: "plus TVA" + min_bill_turnover_desc: "Quand le chiffre d'affaire dépasse %{mbt_amount}" + say_no: "Non" + say_yes: "Oui" + then: puis + ongoing: En cours + bill_address: Adresse de facturation + ship_address: Adresse de livraison + sort_order_cycles_on_shopfront_by: "Trier les cycles de vente par" + required_fields: Les champs obligatoires sont mentionnés par un asterisk + select_continue: Choisir et continuer + remove: Supprimer + or: ou + collapse_all: Tout masquer + expand_all: Tout afficher + loading: Chargement en cours... + show_more: Voir plus + show_all: Tout voir + show_all_with_more: "Voir tous (%{num} en plus)" + cancel: Annuler + edit: Modifier + clone: Dupliquer + distributors: Distributeurs + distribution: Distribution + bulk_order_management: Gestion des commandes par lot + enterprise_groups: Groupes + reports: Rapports + variant_overrides: Catalogue de produits + spree_products: Produits + all: Tous + current: Actuel + available: Disponible + dashboard: Tableau de bord + undefined: indéfini + unused: inutilisé + admin_and_handling: Admin et gestion + profile: Profil + supplier_only: Uniquement Fournisseur + weight: Poids + volume: Volume + items: Pièces + summary: Résumé + detailed: Détaillé + updated: Mis à jour + 'yes': "Oui" + 'no': "Non" + y: 'O' + n: 'N' + powered_by: Exécuté par + blocked_cookies_alert: "Votre navigateur semble bloquer des cookies nécessaires à l'utilisation de ce site. Cliquez ci-dessous pour autoriser les cookies et rechargez la page." + allow_cookies: "Autoriser les cookies" + notes: Commentaires + error: Erreur + processing_payment: Paiement en cours... + show_only_unfulfilled_orders: Ne montrer que les commandes non finalisées + filter_results: Filtrer les résultats + quantity: Quantité + pick_up: Retrait + copy: Copier + actions: + create_and_add_another: "Créer et ajouter nouveau" + admin: + begins_at: Commence à + begins_on: Commence le + customer: Acheteur + date: Date + email: Email + ends_at: Termine à + ends_on: Termine le + name: Nom + on_hand: En stock + on_demand: A volonté + on_demand?: A volonté? + order_cycle: Cycle de vente + payment: Paiement + payment_method: Méthode de paiement + phone: N° tel + price: Prix + producer: Producteur + image: Image + product: Produit + quantity: Quantité + schedule: Rythme d'abonnement + shipping: Expédition + shipping_method: Option d'expédition + shop: Boutique + sku: Référence produit + status_state: Département + tags: Tags + variant: Variante + weight: Poids + volume: Volume + items: Pièce + select_all: Tout sélectionner + obsolete_master: Master obsolète + quick_search: Recherche rapide + clear_all: Vider + start_date: "Date de début" + end_date: "Date de fin" + form_invalid: "Le formulaire contient des champs manquants ou invalides" + clear_filters: Annuler les filtres + clear: Effacer + save: Enregistrer + cancel: Annuler + back: Retour + show_more: Afficher plus + show_n_more: Montrer %{num} supplémentaires + choose: "Choisir..." + please_select: Veuillez choisir... + columns: Colonnes + actions: Actions + viewing: "Vous regardez: %{current_view_name}" + description: Description + whats_this: Qu'est-ce que c'est? + tag_has_rules: "Règles existantes pour ce tag: %{num}" + has_one_rule: "a une règle" + has_n_rules: "a %{num} règles" + unsaved_confirm_leave: "Des modifications n'ont pas été sauvegardées et seront perdues si vous quittez la page. Souhaitez-vous quitter la page?" + unsaved_changes: "Des modifications n'ont pas été sauvegardées." + accounts_and_billing_settings: + method_settings: + default_accounts_payment_method: "Méthode de paiement par défaut" + default_accounts_shipping_method: "Méthode d'envoi par défaut" + edit: + accounts_and_billing: "Comptes & Factures" + accounts_administration_distributor: "Entreprise d'administration des comptes (facturation des hubs)" + admin_settings: "Paramètres" + update_invoice: "Mettre à jour les factures" + auto_update_invoices: "Mettre à jour automatiquement les factures chaque nuit à 01:00" + finalise_invoice: "Finaliser les factures" + auto_finalise_invoices: "Finaliser automatiquement les factures le 2 de chaque mois à 01:30" + manually_run_task: "Tâche exécutée manuellement" + update_user_invoice_explained: "Cliquez ici pour mettre à jour immédiatement les factures pour le mois en cours pour toutes les entreprises utilisant le système. Cette tache peut être définie pour s'effectuer automatiquement chaque nuit." + finalise_user_invoices: "Finaliser les factures utilisateurs" + finalise_user_invoice_explained: "Cliquez ici pour finaliser toutes les factures pour le mois calendaire précédent. Cette tâche peut-être définie pour être opérée automatiquement une fois par mois." + update_user_invoices: "Mettre à jour les factures utilisateurs" + errors: + accounts_distributor: 'doit être défini si vous souhaitez générer des factures pour les utilisateurs entreprises. ' + default_payment_method: 'doit être défini si vous souhaitez générer des factures pour les utilisateurs entreprises. ' + default_shipping_method: doit être défini si vous souhaitez générer des factures pour les utilisateurs entreprises. + shopfront_settings: + embedded_shopfront_settings: "Paramètres Boutiques Intégrées" + enable_embedded_shopfronts: "Autoriser l'intégration des boutiques" + embedded_shopfronts_whitelist: "Liste blanche des Domaines Externes" + number_localization: + number_localization_settings: "Gestion localisation des nombres" + enable_localized_number: "Utiliser le traitement international des séparateurs de milliers/centimes" + business_model_configuration: + edit: + business_model_configuration: "Modèle économique" + business_model_configuration_tip: "Configurer la fréquence à laquelle les boutiques seront facturées chaque mois pour l'utilisation d'Open Food Network" + bill_calculation_settings: "Paramètres du calcul des frais" + bill_calculation_settings_tip: "Définir le montant qui sera facturé aux hubs tous les mois pour leur utilisation d'Open Food Network." + shop_trial_length: "Durée de la période de test (jours)" + shop_trial_length_tip: "La durée (en jours) de la période d'essai." + fixed_monthly_charge: "Charge mensuelle fixe" + fixed_monthly_charge_tip: "Le montant fixe mensuel facturé pour tous les hubs qui dépassent le seuil de chiffre d'affaire facturable (si défini)." + percentage_of_turnover: "Pourcentage du chiffre d'affaire" + percentage_of_turnover_tip: "Quand supérieur à zéro, ce taux (0.0 - 1.0) sera appliqué au chiffre d'affaire du hub pour déterminer la commission à facturer, qui sera ajoutée aux autres charges (à gauche) pour calculer le montant à facturer pour le mois." + monthly_cap_excl_tax: "plafond mensuel (sans TVA)" + monthly_cap_excl_tax_tip: "Quand supérieure à zéro, cette valeur sert de limite supérieure facturable pour un mois." + tax_rate: "TVA applicable" + tax_rate_tip: "TVA applicable sur le service facturé par Open Food Network." + minimum_monthly_billable_turnover: "Chiffre d'affaire minimum facturable (mensuel)" + minimum_monthly_billable_turnover_tip: "Chiffre d'affaire mensuel au delà duquel le hub devra payer le service Open Food Network. Les hubs n'atteignant pas ce chiffre d'affaire mensuel ne seront pas facturés, ni sur le montant fixe ni sur la commission variable." + example_bill_calculator: "Exemple de calcul de facture" + example_bill_calculator_legend: "Changer le chiffre d'affaire pour voir l'impact des paramètres définis à gauche." + example_monthly_turnover: "Exemple de CA mensuel" + example_monthly_turnover_tip: "Exemple de chiffre d'affaire mensuel qui sert de base de calcul pour voir quel est le montant qui sera facturé au hub concerné." + cap_reached?: "Seuil atteint?" + cap_reached?_tip: "On voit ici si le seuil (défini à gauche) a été atteint, en fonction du chiffre d'affaire et du paramétrage du seuil." + included_tax: "Inclut TVA" + included_tax_tip: "TVA inclue dans l'exemple en cours, dépend du chiffre d'affaire et des paramétrages à gauche." + total_monthly_bill_incl_tax: "Facture mensuelle totale (taxes incluses)" + total_monthly_bill_incl_tax_tip: "Exemple du total TTC facturé pour le mois, selon paramétrages et chiffre d'affaire du mois." + customers: + index: + add_customer: "Ajouter un acheteur" + new_customer: "Nouveau client" + customer_placeholder: "acheteur@exemple.org" + valid_email_error: Veuillez entrer un email valide + add_a_new_customer_for: Ajouter un nouvel acheteur pour %{shop_name} + code: Code + duplicate_code: "Ce code est déjà utilisé." + bill_address: "Adresse de facturation" + ship_address: "Adresse de livraison" + update_address_success: 'Adresse mise à jour avec succès.' + update_address_error: 'Oups! Veuillez remplir tous les champs obligatoires!' + edit_bill_address: 'Modifier l''adresse de facturation' + edit_ship_address: 'Modifier l''adresse de livraison' + required_fileds: 'Les champs obligatoires sont indiqués avec un astérisque *' + select_country: 'Choisir le pays' + select_state: 'Choisir le département' + edit: 'Modifier' + update_address: 'Mettre à jour l''adresse' + confirm_delete: 'Confirmer suppression?' + search_by_email: "Recherche par email/code..." + destroy: + has_associated_orders: 'Suppression impossible: des commandes sont associées à cette boutique' + cache_settings: + show: + title: Mise en cache + distributor: Hub-distributeur + order_cycle: Cycle de vente + status: Statut + diff: Diff + error: Erreur + contents: + edit: + title: Contenu + header: Titre + home_page: Page d'accueil + producer_signup_page: Page d'inscription Producteur + hub_signup_page: Page d'inscription Hub + group_signup_page: Page d'inscription Groupe + footer_and_external_links: Pied de page et Liens Externes + your_content: Votre contenu + enterprise_fees: + index: + title: Marges et Commissions + enterprise: Entreprise + fee_type: Type de commissions + name: Nom + tax_category: TVA applicable + calculator: Calculateur + calculator_values: Valeurs applicables + enterprise_groups: + index: + new_button: Nouveau groupe d'entreprises + enterprise_roles: + form: + manages: gère + enterprise_role: + manages: gère + products: + unit_name_placeholder: 'ex: bottes' + bulk_edit: + unit: Unité + display_as: Unité affichée + category: Catégorie + tax_category: TVA applicable + inherits_properties?: Hériter des propriétés? + available_on: Disponible via + av_on: "Disp. via" + import_date: importé + upload_an_image: Importer une image + product_search_keywords: Mots-clés de recherche produits + product_search_tip: Saisissez des mots qui peuvent simplifier la recherche de vos produits dans les boutiques. Laissez un espace entre chaque mot-clé. + SEO_keywords: Mot-clés de référencement web + seo_tip: Saisissez des mots qui peuvent simplifier la recherche de vos produits sur le web. Laissez un espace entre chaque mot-clé. + Search: Rechercher + properties: + property_name: Nom du label + inherited_property: Label producteur appliqué par défaut + variants: + to_order_tip: "Les articles fabriqués sur commande n'ont pas un niveau de stock défini, comme des pains faits à la main." + product_distributions: "Lieux de distribution" + group_buy_options: "Options d'achat par lot" + back_to_products_list: "Retour à la liste produits" + product_import: + title: import produit + file_not_found: Fichier non trouvé ou impossible à ouvrir + no_data: Aucune donnée trouvée dans le tableau + confirm_reset: "Cela va définir le stock à zero sur tous les produits des entreprises non présentes dans le fichier téléchargé." + model: + no_file: "erreur : aucun document importé" + could_not_process: "impossible de traiter le fichier : type de fichier invalide" + incorrect_value: valeur incorrecte + conditional_blank: Champ obligatoire si le type d'unité est vide + no_product: 'aucun produit trouvé ' + not_found: n'a pas été trouvé dans la base de donnée + blank: Champ obligatoire + products_no_permission: vous n'avez pas les droits requis pour gérer les produits de cette entreprise + inventory_no_permission: Vous n'avez pas la permission de créer un catalogue de produits pour ce producteur + none_saved: n'a pu sauvegarder aucun produit :-( + line: Ligne + index: + select_file: Sélectionner une feuille de calcul à uploader + spreadsheet: Feuille de calcul + import_into: "Importer" + product_list: Liste produit + inventories: Catalogues produits + import: Importer + upload: Télécharger + import: + review: Vérifier + proceed: Procéder + save: Enregistrer + results: Résultats + save_imported: Sauvegarder les produits importés + no_valid_entries: Pas d'entrées valides trouvées + none_to_save: Aucune donnée n'a pu être sauvegardée + some_invalid_entries: Le fichier importé contient des entrées invalides + save_valid?: Sauvegarder les entrées valides et supprimer les autres? + no_errors: Aucune erreur détectée ! + save_all_imported?: Sauvegarder les produits importés? + options_and_defaults: Options d'import + no_permission: vous n'avez pas les droits requis pour gérer les produits de cette entreprise + not_found: entreprise non trouvée dans la base de donnée + no_name: Pas de nom + blank_supplier: certains produits ne sont associés à aucun fournisseur + reset_absent?: Mettre à zéro le produits absents ? + overwrite_all: Modifier tous + overwrite_empty: Modifier si vide + default_stock: Indiquer niveau de stock + default_tax_cat: Indiquer le type de taxe + default_shipping_cat: Indiquer condition de transport + default_available_date: Indiquer date de disponibilité + validation_overview: 'Aperçu des entrées produits ' + entries_found: Informations trouvées dans le fichier importé + entries_with_errors: Certaines lignes contiennent des erreurs et les produits correspondant ne seront pas importés + products_to_create: Ces produits vont être crées + products_to_update: Ces produits vont être mis à jour + inventory_to_create: Les produits du catalogue seront crées + inventory_to_update: Les produits du catalogue seront mis à jour + products_to_reset: Le stock des produits existants va être remis à zero + inventory_to_reset: Les produits du catalogue auront leurs stocks remis à zéro + line: Ligne + item_line: Ligne produit concernée + save: + inventory_created: Les produits du catalogue ont été crées + inventory_updated: Les produits du catalogue ont été mis à jour + inventory_reset: Les stocks des produits du catalogue ont été remis à zéro + view_inventory: voir catalogue + variant_overrides: + loading_flash: + loading_inventory: Catalogue de produits en cours de chargement... + index: + title: Catalogue de produits + description: Utilisez cette page pour gérer le catalogue de votre entreprise. Les détails produits saisis ici remplaceront ceux de la page "Produit" pour votre entreprise uniquement. + enable_reset?: Autoriser réinitialisation du stock (retour configurations par défaut)? + inherit?: Hériter? + add: Ajouter + hide: Masquer + import_date: importé + select_a_shop: Choisir une boutique + review_now: Vérifier maintenant + new_products_alert_message: Il y a %{new_product_count} nouveaux produits disponibles pouvant être ajoutés à votre catalogue. + currently_empty: Votre catalogue est actuellement vide + no_matching_products: Pas de produits correspondants dans votre catalogue + no_hidden_products: Aucun produit masqué dans ce catalogue + no_matching_hidden_products: Aucune produit masqué ne répond à la recherche + no_new_products: Pas de nouveaux produits à ajouter à ce catalogue + no_matching_new_products: Pas de nouveaux produits répondant à la recherche + inventory_powertip: Ceci est votre catalogue produits. Pour ajouter des produits à votre catalogue, sélectionnez "Nouveaux Produits" dans le menu déroulant. + hidden_powertip: Ces produits ont été masqués de votre catalogue, vous ne pourrez pas les proposer dans votre boutique. Vous pouvez cliquer sur "Ajouter" pour ajouter un produit à votre catalogue. + new_powertip: Ces produits peuvent être ajoutés à votre catalogue. Cliquez sur "Ajouter" pour ajouter un produit à votre catalogue, ou 'Masquer" pour ne plus l'afficher. Vous pourrez changer d'avis plus tard! + controls: + back_to_my_inventory: Retour à mon catalogue de produits + orders: + index: + capture: "Payée" + ship: "Expédier" + invoice_email_sent: 'L''email de facturation a bien été envoyé' + order_email_resent: 'L''email de commande a de nouveau été envoyé' + bulk_management: + tip: "Utilisez cette page pour changer les quantités d'un produit sur plusieurs commandes. Les produits peuvent aussi être supprimés de toutes les commandes, si nécessaire." + shared: "Ressource partagée?" + order_no: "N° commande" + order_date: "Date commande" + max: "Max" + product_unit: "Produit: Unité" + weight_volume: "Poids/Volume" + ask: "Demander?" + page_title: "Gestion des commandes" + actions_delete: "Supprimer la sélection" + loading: "Commandes en cours de chargement" + no_results: "Aucune commande trouvée." + group_buy_unit_size: "Quantité totale du lot" + total_qtt_ordered: "Quantité totale commandée" + max_qtt_ordered: "Quantité max commandée" + current_fulfilled_units: "Nombre d'unités commandées" + max_fulfilled_units: "Nombre max d'unités commandées" + order_error: "Des erreurs doivent être résolues avant de pouvoir mettre à jour les commandes.\nLes champs entourés en rouge contiennent des erreurs." + variants_without_unit_value: "ATTENTION: certaines variantes n'ont pas de nombre d'unités" + select_variant: "Choisir une variante" + enterprise: + select_outgoing_oc_products_from: Sélectionner les produits sortants pour le cycle de vente parmi + enterprises: + index: + title: Entreprises + new_enterprise: Nouvelle entreprise + producer?: "Producteur?" + package: Pack + status: Statut + manage: Gérer + form: + about_us: + desc_short: Description courte + desc_short_placeholder: Parlez de votre entreprise en une ou deux phrases + desc_long: A propos + desc_long_placeholder: Parlez de vous à vos acheteurs ! Ces informations seront visibles sur votre profil public. + business_details: + abn: SIRET + abn_placeholder: 'ex: 404 833 048 00022' + acn: n° TVA intracommunautaire + acn_placeholder: 'ex: 404 833 048' + display_invoice_logo: Afficher le logo sur la facture + invoice_text: Ajouter une mention spécifique en bas des factures + contact: + name: Nom + name_placeholder: 'ex: Bernard Michelet' + email_address: Adresse email publique + email_address_placeholder: 'ex: labelleferme@maferme.fr' + email_address_tip: "Cette adresse email sera affichée sur votre profil public" + phone: n° téléphone + phone_placeholder: 'ex: 06 13 24 35 46' + website: Site internet + website_placeholder: 'ex: www.maferme.fr' + enterprise_fees: + name: Nom + fee_type: Type de commissions + manage_fees: Gérer les marges et commissions + no_fees_yet: Vous n'avez pas encore défini de commissions + create_button: Créer une commission + images: + logo: Logo + promo_image_placeholder: 'Cette image est affichée dans "A propos"' + promo_image_note1: 'ATTENTION:' + promo_image_note2: Votre bannière doit mesurer 1200 x 260, toute image non conforme sera rognée. + promo_image_note3: La bannière est affichée en haut de la page de votre entreprise et dans sa version condensée (pop-up). + inventory_settings: + text1: Vous pouvez choisir de gérer vos stocks et prix via votre + inventory: catalogue de produits + text2: > + Si vous utilisez l'outil "catalogue de produits", vous pouvez choisir + si les nouveaux produits ajoutés par vos fournisseurs doivent être référencés + dans votre catalogue de produits avant qu'ils puissent mis en vente + dans votre boutique. Si vous n'utilisez pas cet outil, choisissez l'option + indiquant "recommandé" ci-dessous: + preferred_product_selection_from_inventory_only_yes: Les nouveaux produits des producteurs peuvent être ajoutés à ma boutique en ligne (recommandé) + preferred_product_selection_from_inventory_only_no: Les nouveaux produits des producteurs doivent être ajoutés à mon catalogue de produits avant de pouvoir être ajoutés à ma boutique en ligne + payment_methods: + name: Nom + applies: Active? + manage: Gérer les méthodes de paiement + not_method_yet: Vous n'avez pas encore de méthode de paiement. + create_button: Créer une nouvelle méthode de paiement + create_one_button: En créer une maintenant + primary_details: + name: Nom + name_placeholder: 'ex: La ferme bio de Bernard' + groups: Groupes + groups_tip: Sélectionnez les groupes desquels vous êtes membres. Cela améliorera votre visibilité et permettra aux acheteurs de vous trouver plus facilement. + groups_placeholder: Commencer à taper pour voir les groupes disponibles... + primary_producer: Producteur? + primary_producer_tip: Cochez "producteur" si vous vendez des aliments que vous produisez vous-même (bruts ou transformés) + producer: Producteur + any: Tous + none: Aucun + own: Les siens + sells: Produits vendus + sells_tip: "Aucun - l'entreprise ne vend pas en direct aux acheteurs.
Les miens - l'entreprise vend ses propres produits aux acheteurs.
Tous - l'entreprise vend ses propres produits et/ou les produits d'autres entreprises.
" + visible_in_search: Apparaît dans la recherche? + visible_in_search_tip: Indiquez si vous souhaitez ou ne souhaitez pas que votre entreprise apparaisse sur la carte et dans la liste des boutiques. + visible: Visible + not_visible: Invisible + permalink: Nom pour URL (sans espace) + permalink_tip: "Ce nom permanent est utilisé pour créer l'url de votre boutique: %{link}ma-boutique/shop" + link_to_front: Lien URL de la boutique + link_to_front_tip: C'est le lien qui permet d'accéder en direct à votre boutique sur Open Food Network. + shipping_methods: + name: Nom + applies: Active? + manage: Gérer les méthodes de livraison + create_button: Créer nouvelle méthode de livraison + create_one_button: En créer une maintenant + no_method_yet: Vous n'avez pas encore paramétré de méthode de livraison. + shop_preferences: + shopfront_requires_login: "Boutique visible par tous?" + shopfront_requires_login_tip: "Choisissez si les acheteurs doivent être logués pour voir la boutique ou si la boutique est visible par tout le monde." + shopfront_requires_login_false: "Visible par tous" + shopfront_requires_login_true: "Visible uniquement pour les acheteurs logués" + recommend_require_login: "Nous recommandons de demander aux utilisateurs de se connecter si vous souhaitez leur permettre de modifier leur commande." + allow_guest_orders: "Commandes des invités" + allow_guest_orders_tip: "Autoriser la commande en tant qu'invité ou demander que l'acheteur soit logué." + allow_guest_orders_false: "Demander que l'acheteur se logue pour pouvoir commander" + allow_guest_orders_true: "Autoriser les commandes en mode invité" + allow_order_changes: "Modifier la commande" + allow_order_changes_tip: "Permettre aux acheteurs de modifier leur commande tant que le cycle de vente est ouvert." + allow_order_changes_false: "Les commandes validées ne peuvent plus être modifiées / annulées" + allow_order_changes_true: "Les acheteurs peuvent modifier / valider leurs commandes tant que le cycle de vente est ouvert" + enable_subscriptions: "Abonnements" + enable_subscriptions_tip: "Activer la fonction abonnements?" + enable_subscriptions_false: "Désactivée" + enable_subscriptions_true: "Activée" + shopfront_message: Message d'accueil de la boutique ouverte + shopfront_message_placeholder: > + Vous pouvez ici expliquer à vos acheteurs comment votre boutique fonctionne. + Ce texte s'affiche dans votre boutique, au-dessus de la liste de produits. + shopfront_closed_message: Message d'accueil de la boutique fermée + shopfront_closed_message_placeholder: > + Vous pouvez ici expliquer à vos acheteurs pourquoi votre boutique est + fermée et/ou quand elle ouvrira. Ce texte s'affiche uniquement quand + il n'y a pas de cycle de vente en cours (donc quand votre boutique est + fermée). + shopfront_category_ordering: Ordre d'affichage des catégories + open_date: Date d'ouverture + close_date: Date de fermeture + social: + twitter_placeholder: ex. @OpenFoodNet_fr + stripe_connect: + connect_with_stripe: "Connecter avec Stripe" + stripe_connect_intro: "Pour accepter des paiements utilisant la carte bancaire, vous devez connecter votre compte Stripe à Open Food Network. Cliquez sur le bouton à droite pour commencer." + stripe_account_connected: "Compte Stripe connecté." + disconnect: "Déconnecter le compte" + confirm_modal: + title: Connecter avec Stripe + part1: Stripe est un système de paiement qui permet aux boutiques sur Open Food Network d'accepter des paiements par carte bancaire de leurs acheteurs. + part2: Pour utiliser cette fonctionnalité, vous devez connecter votre compte Stripe à Open Food Network. En cliquant sur "J'accepte" ci-dessous, vous serez redirigé vers le site internet de Stripe, où vous pourrez connecter votre compte existant ou en créer un si vous n'en avez pas encore. + part3: Cela permettra à Open Food Network d'accepter en votre nom les paiements par carte de crédit en provenance de vos acheteurs. Veuillez noter que c'est à vous de gérer votre compte Stripe, de payer les frais dus à Stripe et de gérer les éventuels remboursements et le service après vente. + i_agree: J'accepte + cancel: Annuler + tag_rules: + default_rules: + by_default: Règles à appliquer "par défaut" + no_rules_yet: Aucune règle par défaut + add_new_button: '+ Ajouter une règle par défaut' + no_tags_yet: Aucun tag défini par cette entreprise pour le moment + no_rules_yet: Aucune règle ne concerne ce tag pour le moment + for_customers_tagged: 'Pour les acheteurs avec le tag:' + add_new_rule: '+ Ajouter une nouvelle règle' + add_new_tag: '+ Ajouter un nouveau tag' + users: + email_confirmation_notice_html: "L'email de confirmation n'a pas encore été validé. Il a été envoyé à %{email}." + resend: Renvoyer + owner: 'Manager principal' + contact: "Contact" + contact_tip: "Le manager qui recevra les emails de confirmation de commande et autres notifications de l'entreprise. Il doit avoir confirmé son adresse email pour pouvoir être sélectionné." + owner_tip: Manager principal de cette entreprise. + notifications: Notifications + notifications_tip: Une notification de commande sera envoyée à cette adresse email pour chaque commande passée dans votre boutique. + notifications_placeholder: 'ex: bernard@maferme.fr' + notifications_note: 'A noter: si vous saisissez une nouvelle adresse, un email de confirmation sera envoyé à cette adresse avec un lien de validation à cliquer.' + managers: Managers + managers_tip: 'Sélectionner ici les utilisateurs ayant la permission de gérer cette entreprise. ' + invite_manager: "Inviter un manager" + invite_manager_tip: "Inviter un nouvel utilisateur à créer son compte et le nommer comme manager de cette entreprise." + add_unregistered_user: "Ajouter un nouvel utilisateur" + email_confirmed: "Email confirmé" + email_not_confirmed: "Email non confirmé" + actions: + edit_profile: Modifier le profil + properties: Labels / propriétés + payment_methods: Méthodes de paiement + payment_methods_tip: Cette entreprise n'a pas paramétré de méthode de paiement + shipping_methods: Méthodes de livraison + shipping_methods_tip: Cette entreprise a paramétré des méthodes de paiement + enterprise_fees: Marges et commissions + enterprise_fees_tip: Cette entreprise n'a pas paramétré de marges et commissions + admin_index: + name: Nom + role: Role + sells: Produits vendus + visible: Visible? + owner: Gérant + producer: Producteur + change_type_form: + producer_profile: Profil producteur + connect_ofn: Gagnez en visibilité via OFN + always_free: GRATUIT + producer_description_text: Saisissez votre catalogue produits sur Open Food Network, ce qui permettra aux hubs-distributeurs utilisant la plateforme de les proposer dans leurs boutiques (sur votre autorisation). + producer_shop: Boutique Producteur + sell_your_produce: Vendez vos propres produits + producer_shop_description_text: Vendez vos produits en direct aux mangeurs/restaurateurs/etc. via votre propre Boutique Producteur sur Open Food Network. + producer_shop_description_text2: Une Boutique Producteur vous permet de vendre uniquement vos propres produits. Si vous voulez vendre d'autres produits, sélectionnez "Hub Producteur" + producer_hub: Hub Producteur + producer_hub_text: Vendez vos produits et ceux d'autres fournisseurs + producer_hub_description_text: Vous pouvez vendre non seulement vos produits, mais aussi des produits d'autres producteurs de votre région, artisans, ou distributeurs afin de proposer une offre complète dans votre boutique. Vous soutenez ainsi le développement de votre système alimentaire territorial ! + profile: Profil uniquement + get_listing: Référencez votre hub/point de vente + profile_description_text: Les visiteurs peuvent vous trouver sur Open Food Network et vous contacter. Votre entreprise sera visible sur la carte. + hub_shop: Boutique Hub + hub_shop_text: Vendez des produits de multiples fournisseurs + hub_shop_description_text: Vous proposez des produits de différents producteurs de votre région, artisans, ou distributeurs afin de proposer une offre complète dans votre boutique. Vous soutenez ainsi le développement de votre système alimentaire territorial ! + choose_option: Veuilliez choisir l'une des options ci-dessus. + change_now: Changer + enterprise_user_index: + loading_enterprises: CHARGEMENT DES ENTREPRISES + no_enterprises_found: Aucune entreprise trouvée. + search_placeholder: Recherche par nom + manage: Gérer + new_form: + owner: Gérant + owner_tip: L'utilisateur principal est l'individu qui porte la responsabilité principale de l'entreprise dans le contexte de l'utilisation d'Open Food Network. + i_am_producer: Je suis un producteur + contact_name: Nom du contact principal + edit: + editing: 'En modification:' + back_link: Revenir à la liste des entreprises + new: + title: Nouvelle entreprise + back_link: Revenir à la liste des entreprises + welcome: + welcome_title: Bienvenue sur Open Food Network ! + welcome_text: 'Vous avez créé avec succès ' + next_step: Etape suivante + choose_starting_point: 'Choisir le type de compte souhaité' + invite_manager: + user_already_exists: "Le compte existe déjà" + error: "Un problème est survenu" + order_cycles: + edit: + advanced_settings: Paramétrages avancés + update_and_close: Mettre à jour et fermer + choose_products_from: 'Choisir produits depuis :' + exchange_form: + pickup_time_tip: Quand des commandes liées à ce cycle de vente seront prêtes à être soumises à l'acheteur + pickup_instructions_placeholder: "Modalités de retrait/livraison" + pickup_instructions_tip: Ces instructions sont affichées aux acheteurs après passage d'une commande + pickup_time_placeholder: "Prêt pour (ex : jour + créneau horaire)" + receival_instructions_placeholder: "Modalités de livraison" + add_fee: 'Ajouter une commission' + selected: 'sélectionné' + add_exchange_form: + add_supplier: 'Ajouter un fournisseur' + add_distributor: 'Ajouter un distributeur' + advanced_settings: + title: Paramétrages avancés + choose_product_tip: Vous pouvez choisir de limiter le choix des produits pouvant être mis en vente dans votre boutique à ceux figurant dans le catalogue de produits de %{inventory}. + preferred_product_selection_from_coordinator_inventory_only_here: Uniquement les produits du catalogue du coordinateur + preferred_product_selection_from_coordinator_inventory_only_all: Tous les produits disponibles dans les catalogues producteurs + save_reload: Sauvegarder et rafraichir la page + coordinator_fees: + add: Ajouter commission coordinateur + form: + incoming: Produits entrants (pouvant être mis en vente par les hubs) + supplier: Fournisseur + receival_details: Détails livraison produits + fees: Commissions + outgoing: Produits sortants (mis en vente par/via un ou plusieurs hubs) + distributor: Hub (distributeur) + products: Produits + tags: Tags + add_a_tag: Ajouter un tag + delivery_details: Précisions retrait / livraison + debug_info: Informations de débogage + index: + involving: Concernant + schedule: Rythme d'abonnement + schedules: Rythmes d'abonnement + adding_a_new_schedule: Ajouter un nouveau rythme d'abonnement + updating_a_schedule: Mettre à jour un rythme d'abonnement + new_schedule: Nouveau rythme d'abonnement + create_schedule: Créer rythme d'abonnement + update_schedule: Mettre à jour rythme d'abonnement + delete_schedule: Supprimer rythme d'abonnement + created_schedule: Créer rythme d'abonnement + updated_schedule: Mettre à jour rythme d'abonnement + deleted_schedule: Supprimer rythme d'abonnement + schedule_name_placeholder: Nom du rythme d'abonnement + name_required_error: Veuillez saisir un nom pour ce rythme d'abonnement + no_order_cycles_error: Veuillez saisir au moins un cycle de vente (glisser déposer) + name_and_timing_form: + name: Nom + orders_open: Commandes à partir de + coordinator: Coordinateur + orders_close: Commandes jusqu'au + row: + suppliers: fournisseurs + distributors: Distributeurs + variants: variantes + simple_form: + ready_for: 'Prêt pour ' + ready_for_placeholder: Date / Heure + customer_instructions: Précisions pour l'acheteur + customer_instructions_placeholder: Commentaires pour le retrait / la livraison + products: Produits + fees: Commissions + destroy_errors: + orders_present: Ce cycle de vente a déjà été utilisé par un acheteur et ne peut être supprimé. Pour empêcher aux acheteurs d'y accéder, veuillez plutôt le fermer. + schedule_present: Ce cycle de vente est lié à un rythme d'abonnement et ne peut pas être supprimé. Veuillez d'abord supprimer ce lien ou supprimer le rythme d'abonnement. + producer_properties: + index: + title: Propriétés / labels du producteur + proxy_orders: + cancel: + could_not_cancel_the_order: La commande n'a pas pu être supprimée + resume: + could_not_resume_the_order: La commande n'a pas pu être reprise + shared: + user_guide_link: + user_guide: Guide utilisateur + invoice_settings: + edit: + title: Paramètres de facturation + invoice_style2?: Utiliser le modèle de facture alternatif qui détaille le montant de TVA agrégé par taux et l'information du taux de TVA par produit (pas adapté pour les instances affichant les prix HT) + enable_receipt_printing?: Afficher les options d'impression de tickets de caisse dans le menu déroulant des commandes? + overview: + enterprises_header: + ofn_with_tip: Les Entreprises sont des Producteurs et/ou Hubs distributeurs, et sont donc les organisations de base qui utilisent Open Food Network. + enterprises_hubs_tabs: + has_no_payment_methods: "%{enterprise} n'a pas défini de méthode de paiement" + has_no_shipping_methods: "%{enterprise} n'a pas défini de méthode de livraison" + has_no_enterprise_fees: "%{enterprise} n'a pas défini de marges et commissions" + enterprise_issues: + create_new: Créer Nouveau + resend_email: Renvoyer l'email + has_no_payment_methods: "%{enterprise} n'a pas de méthode de paiement active" + has_no_shipping_methods: "%{enterprise} n'a pas de méthode de livraison active" + email_confirmation: "L'adresse e-mail doit être confirmée. Nous avons envoyé un lien de confirmation à %{email}." + not_visible: "%{enterprise}n'est pas visible et ne peut être trouvé sur la carte ou dans les recherches sur le site." + reports: + hidden: Masqué + unitsize: Unité de mesure + total: Total + total_items: Nb Articles + supplier_totals: Totaux Cycle de Vente par Producteur + supplier_totals_by_distributor: Totaux Cycle de Vente par Producteur pour chaque Hub Distributeur + totals_by_supplier: Totaux Cycle de Vente par Hub Distributeur pour chaque Producteur + customer_totals: Totaux Cycle de Vente par Acheteur + all_products: Tous les produits + inventory: Catalogue produits (en stock) + lettuce_share: LettuceShare + mailing_list: Liste de mails + addresses: Adresses + payment_methods: Rapport Méthodes de Paiement + delivery: Rapport de Livraison + tax_types: Par type de taxe + tax_rates: Par taux de taxe + pack_by_customer: Préparation des commandes par Acheteur + pack_by_supplier: Préparation des commandes par Producteur + orders_and_distributors: + name: Commandes et Hubs Distributeurs + description: Liste des Commandes avec les détails des Hubs Ditributeurs + bulk_coop: + name: Achat groupés en vrac + description: Rapports achats groupés vrac + payments: + name: Rapports des paiements + description: Rapports des paiements reçus + orders_and_fulfillment: + name: Rapports des commandes + customers: + name: Acheteurs + products_and_inventory: + name: Produits et Catalogues + sales_total: + name: Total des Ventes + description: Total des Ventes pour toutes les Commandes + users_and_enterprises: + name: Utilisateurs & Entreprises + description: Gérance de l'Entreprise & Droits + order_cycle_management: + name: Gestion des Cycles de Vente + sales_tax: + name: TVA + xero_invoices: + name: Facture Xero + description: Factures pour import dans Xero + packing: + name: Rapports de préparation des paniers + subscriptions: + subscriptions: Abonnements + new: Nouvel abonnement + create: Créer abonnement + index: + please_select_a_shop: Veuillez choisir une boutique + edit_subscription: Mettre à jour Abonnement + pause_subscription: Mettre en pause Abonnement + unpause_subscription: Reprendre Abonnement + cancel_subscription: Annuler Abonnement + setup_explanation: + just_a_few_more_steps: 'Encore quelques étapes avant de pouvoir commencer:' + enable_subscriptions: "Activez la fonction abonnements pour au moins une de vos boutiques" + enable_subscriptions_step_1_html: 1. Allez à %{enterprises_link}, trouvez votre boutique, et cliquez sur "Gérer" + enable_subscriptions_step_2: 2. Sous "Préférences boutiques", activez la fonction Abonnements + set_up_shipping_and_payment_methods_html: Paramétrez au moins une méthode d'%{shipping_link} et une méthode de %{payment_link} + set_up_shipping_and_payment_methods_note_html: Notez bien que seules des méthodes de paiement de type "cash" ou "Stripe" pourront
être utilisées pour les Abonnements + ensure_at_least_one_customer_html: Assurez-vous qu'au moins un %{customer_link} est enregistré dans votre liste d'acheteurs. + create_at_least_one_schedule: Créez au moins un rythme d'abonnement + create_at_least_one_schedule_step_1_html: 1. Allez à la page %{order_cycles_link} + create_at_least_one_schedule_step_2: 2. Créez un cycle de vente si ce n'est pas encore fait + create_at_least_one_schedule_step_3: 3. Cliquez sur "+ Nouveau Rythme d'abonnement", et remplissez le formulaire + once_you_are_done_you_can_html: Une fois que c'est fait, vous pouvez %{reload_this_page_link} + reload_this_page: recharger cette page + steps: + details: 1. Informations de base + address: 2. Adresse + products: 3. Ajouter des produits + review: 4. Vérifier et Enregistrer + details: + details: Informations + invalid_error: Oups! Veuillez remplir tous les champs obligatoires... + allowed_payment_method_types_tip: Seules des méthodes de paiement de type "cash" ou "Stripe" peuvent être utilisées pour le moment + credit_card: Carte de crédit + no_cards_available: Pas de carte disponible + loading_flash: + loading: Abonnements en cours de chargement + review: + details: Informations + address: Adresse + products: Produits + product_already_in_order: Ce produit a déjà été ajouté à la commande. Veuillez directement modifier la quantité. + orders: + number: Nombre + confirm_edit: Voulez-vous vraiment modifier cette commande? Si vous poursuivez, la synchronisation automatique des modifications de l'abonnement pourrait être plus difficile à l'avenir. + confirm_cancel_msg: Voulez-vous vraiment annuler cet abonnement? Cette action sera irréversible. + cancel_failure_msg: 'Désolé, l''annulation a échoué!' + confirm_pause_msg: Voulez-vous vraiment mettre en pause cet abonnement? + pause_failure_msg: 'Désolé, la mise en pause a échoué!' + confirm_unpause_msg: Voulez-vous vraiment annuler la mise en pause de cet abonnement? + unpause_failure_msg: 'Désolé, l''annulation de la mise en pause a échoué!' + confirm_cancel_open_orders_msg: "Cet abonnement a des commandes ouvertes. Les acheteurs ont été notifiés que leur commande allait être passée. Voulez-vous annulez ces commandes ou les conserver?" + resume_canceled_orders_msg: "Certaines commandes pour cet abonnement peuvent être réouvertes dès maintenant. Vous pouvez les réouvrir depuis la liste des commandes." + yes_cancel_them: Les annuler + no_keep_them: Les conserver + yes_i_am_sure: Oui, je confirme + order_update_issues_msg: Certaines commandes n'ont pas pu être mises à jour automatiquement, probablement car elles ont été manuellement modifiées. Veuillez revoir les erreurs listées ci-dessous et effectuer si nécessaire les ajustements nécessaires sur les commandes individuelles. + no_results: + no_subscriptions: Pas encore d'abonnements... + why_dont_you_add_one: Pourquoi ne pas en créer un? :) + no_matching_subscriptions: Aucun abonnement correspondant trouvé + schedules: + destroy: + associated_subscriptions_error: Ce rythme d'abonnement ne peut pas être supprimé car il est associé à des abonnements. + stripe_connect_settings: + edit: + title: "Stripe Connect" + settings: "Paramètres" + stripe_connect_enabled: Permettre aux boutiques d'accepter les paiements via Stripe Connect ? + no_api_key_msg: Aucun compte Stripe n'existe pour cette entreprise. + configuration_explanation_html: Pour des instructions précises sur comment configurer Stripe Connect, veuillez consulter ce guide. + status: Statut + ok: Ok + instance_secret_key: Clé Secrète de l'Instance + account_id: Identifiant Compte + business_name: Nom de l'entreprise + charges_enabled: Frais activés + charges_enabled_warning: "Attention : les Frais ne sont pas activés pour votre compte" + auth_fail_error: La clé de l'API est invalide + empty_api_key_error_html: Aucune clé d'API Stripe n'a été fournie. Pour mettre en place votre clé d'API, veuillez suivre ces instructions + controllers: + enterprises: + stripe_connect_cancelled: "La connexion avec Stripe a été annulée" + stripe_connect_success: "Compte Stripe connecté avec succès" + stripe_connect_fail: Désolé, la connexion de votre compte Stripe a échoué :-( + stripe_connect_settings: + resource: Configuration de Stripe Connect + checkout: + already_ordered: + cart: "panier" + message_html: "Vous avez déjà passé une commande pour ce cycle de vente. Vérifiez votre %{cart} pour voir les produits commandés. Vous pouvez annuler ou modifier votre commande jusqu'à la fermeture du cycle de vente." + shops: + hubs: + show_closed_shops: "Afficher les boutiques fermées" + hide_closed_shops: "Masquer les boutiques fermées" + show_on_map: "Tout afficher sur la carte" + shared: + menu: + cart: + checkout: "Poursuivre la commande" + already_ordered_products: "Déjà commandé dans ce cycle de vente" + register_call: + selling_on_ofn: "Vous souhaitez proposer vos produits sur Open Food Network?" + register: "Démarrez ici" + shop: + messages: + login: "se connecter" + register: "s'inscrire" + contact: "contact" + require_customer_login: "La boutique est réservée aux membres." + require_login_html: "Déjà inscrit? %{login}. Sinon, %{register} pour pouvoir faire vos achats." + require_customer_html: "Veuillez %{contact} %{enterprise} pour devenir membre." + card_could_not_be_updated: La carte n'a pu être mise à jour + card_could_not_be_saved: la carte n'a pas pu être sauvegardée + spree_gateway_error_flash_for_checkout: "Il y a eu un problème avec vos informations de paiement : %{error}" + invoice_billing_address: "Adresse de facturation :" + invoice_column_tax: "TVA" + invoice_column_price: "Prix" + invoice_column_item: "Produit" + invoice_column_qty: "Qté" + invoice_column_unit_price_with_taxes: "Prix unitaire TTC" + invoice_column_unit_price_without_taxes: "Prix unitaire HT" + invoice_column_price_with_taxes: "Prix total TTC" + invoice_column_price_without_taxes: "Prix total HT" + invoice_column_tax_rate: "TVA applicable" + invoice_tax_total: "Total TVA :" + tax_invoice: "FACTURE" + tax_total: "Total taxe (%{rate}) :" + total_excl_tax: "Total HT :" + total_incl_tax: "Total TTC :" + abn: "SIRET" + acn: "n° TVA intracommunautaire" + invoice_issued_on: "Date de facture :" + order_number: "N° de facture :" + date_of_transaction: "Date de la transaction :" + ticket_column_qty: "Qté" + ticket_column_item: "Produit" + ticket_column_unit_price: "Prix unitaire" + ticket_column_total_price: "Prix total" + logo: "Logo (640x130)" + logo_mobile: "Logo smartphone (75x26)" + logo_mobile_svg: "Logo smartphone (SVG)" + home_hero: "Bannière" + home_show_stats: "Afficher statistiques" + footer_logo: "Logo (220x76)" + footer_facebook_url: "Facebook URL" + footer_twitter_url: "Twitter URL" + footer_instagram_url: "Instagram URL" + footer_linkedin_url: "LinkedIn URL" + footer_googleplus_url: "Google Plus URL" + footer_pinterest_url: "Pinterest URL" + footer_email: "Email" + footer_links_md: "Liens" + footer_about_url: "A propos URL" + footer_tos_url: "Conditions d'utilisation URL" + name: Nom + first_name: Prénom + last_name: Nom de famille + email: Email + phone: Téléphone + next: Suivant + address: Adresse + address_placeholder: 'ex: 24 rue de la croix verte' + address2: Adresse (suite) + city: Ville + city_placeholder: 'ex: Nantes' + postcode: Code postal + postcode_placeholder: 'ex: 44000' + state: Département + country: Pays + unauthorized: Non authorisé + terms_of_service: "Conditions d'utilisation" + on_demand: A volonté + none: Aucun + not_allowed: Non autorisé + no_shipping: pas de méthode de livraison + no_payment: pas de méthode de paiement + no_shipping_or_payment: pas de méthode de livraison ou de paiement + unconfirmed: non confirmé + days: jours + label_shop: "Boutique" + label_shops: "Boutiques" + label_map: "Carte" + label_producer: "Producteur" + label_producers: "Producteurs" + label_groups: "Groupes" + label_about: "A propos" + label_connect: "Se connecter" + label_learn: "Apprendre" + label_blog: "Blog" + label_support: "Soutien" + label_shopping: "Achats" + label_login: "Se connecter" + label_logout: "Déconnexion" + label_signup: "Inscription" + label_administration: "Administration" + label_admin: "Admin" + label_account: "Compte" + label_more: "Afficher plus" + label_less: "Masquer" + label_notices: "Informations" + cart_items: "Produits" + cart_headline: "Votre panier" + total: "Total" + cart_updating: "Mettre à jour le panier" + cart_empty: "Panier vide" + cart_edit: "Modifier votre panier" + card_number: Numéro de carte + card_securitycode: "Cryptogramme visuel" + card_expiry_date: Date d'expiration + card_masked_digit: "X" + card_expiry_abbreviation: "Exp" + new_credit_card: "Nouvelle carte de crédit" + my_credit_cards: Mes cartes bancaires + add_new_credit_card: Ajouter nouvelle carte de crédit + saved_cards: Sauvegarder cartes + add_a_card: Ajouter une Carte + add_card: Ajouter Carte + you_have_no_saved_cards: Vous n'avez pas encore sauvegardé de carte + saving_credit_card: Enregistrement de la carte de crédit... + card_has_been_removed: "Votre carte a été supprimée (numéro : %{number})" + card_could_not_be_removed: Désolée, la carte n'a pas pu être supprimée :-( + ie_warning_headline: "Votre navigateur n'est pas à jour :-(" + ie_warning_text: "Pour une expérience optimale sur Open Food Network, nous vous recommandons fortement de mettre à jour votre navigateur:" + ie_warning_chrome: Télécharger Chrome + ie_warning_firefox: Télécharger Firefox + ie_warning_ie: Mettre à jour Internet Explorer + ie_warning_other: "Impossible de mettre à jour votre navigateur? Essayez Open Food Network sur votre smartphone :-)" + footer_global_headline: "OFN Global" + footer_global_home: "Accueil" + footer_global_news: "News" + footer_global_about: "A propos" + footer_global_contact: "Contact" + footer_sites_headline: "Sites OFN" + footer_sites_developer: "Developpeur" + footer_sites_community: "Communauté" + footer_sites_userguide: "Guide utilisateur" + footer_secure: "Fiable et sécurisé." + footer_secure_text: "Open Food Network utilise un certificat type SSL (2048 bit RSA) pour garantir la confidentialité de votre commandes et données bancaires. Nos serveurs ne conservent pas vos données bancaires et les paiements sont effectués conformément aux normes de sécurité PCI." + footer_contact_headline: "Restez en contact" + footer_contact_email: "Nous écrire" + footer_nav_headline: "Naviguer" + footer_join_headline: "Nous rejoindre" + footer_join_body: "Créer un profil, une boutique ou un groupe sur Open Food Network." + footer_join_cta: "Je veux en savoir plus!" + footer_legal_call: "Lire nos" + footer_legal_tos: "Termes et conditions" + footer_legal_visit: "Nous trouver sur" + footer_legal_text_html: "Open Food Network est une plateforme logicielle open source, libre et gratuite. Nos données sont protégées sous licence %{content_license} et notre code sous %{code_license}." + home_shop: Faire mes courses + brandstory_headline: "Des aliments porteurs de sens." + brandstory_intro: "Parfois, le meilleur moyen de réparer le système, c'est d'en inventer un autre..." + brandstory_part1: "Tout commence dans le sol. Avec ces paysans, agriculteurs, producteurs, engagés pour une agriculture durable et régénératrice, et désireux de partager leur histoire et leur passion avec fierté. Avec ces distributeurs souhaitant reconnecter les individus à leurs aliments et aux gens qui les produisent, soutenir les prises de conscience, dans une démarche de transparence, d'honnêteté, en assurant une juste rémunération des producteurs. Avec ces acheteurs qui croient que de meilleures décisions d'achats peuvent véritablement changer le monde." + brandstory_part2: "Nous avons besoin d'un outil pour rendre tout ça réel. Un moyen de redonner le pouvoir à ceux qui cultivent, vendent et achètent la nourriture. Un moyen de raconter les histoires, de gérer la logistique. Un moyen de transformer chaque jour les transactions en actions porteuses de changement." + brandstory_part3: "C'est pour cela que nous construisons cette plateforme, ce \"marché en ligne\", afin de rééquilibrer les échanges et redistribuer le pouvoir. Elle est transparente, pour assurer des relations équitables et favoriser les prises de conscience. Elle est open source, donc possédée par tout le monde. Elle se déploie aux échelles régionales et nationales, et des gens lancent de multiples versions à travers le monde." + brandstory_part4: "Elle fonctionne partout. Elle change tout." + brandstory_part5_strong: "Cette plateforme s'appelle Open Food Network." + brandstory_part6: "Nous aimons notre nourriture. Maintenant nous pouvons aussi aimer notre système alimentaire." + learn_body: "Explorer les modèles, les histoires et les ressources disponibles pour vous aider à développer votre propre initiative de commerce/organisation oeuvrant pour un système alimentaire équitable et juste. Trouver des outils pour vous former, des événements et autres opportunités d'apprendre de vos pairs." + learn_cta: "Découvrir " + connect_body: "Rechercher dans le répertoire des producteurs, hubs et groupes pour trouver des commerçants éthiques à côté de chez vous. Inscrivez votre commerce ou organisation sur OFN pour que les acheteurs puissent vous trouver. Rejoignez la communauté pour recevoir du soutien et résoudre ensemble les problèmes." + connect_cta: "Explorer" + system_headline: "Faire mes courses - comment ça marche?" + system_step1: "1. Recherche" + system_step1_text: "Recherchez des produits locaux, de saison, parmi nos multiples boutiques indépendantes. Filtrez par localisation ou catégorie de produits, livraison en point retrait ou à domicile." + system_step2: "2. Achat" + system_step2_text: "Transformez vos achats en choisissant des produits locaux et abordables, proposés par les divers producteurs et hubs. Découvrez les histoires et les personnes qui se cachent derrière les produits!" + system_step3: "3. Retrait / Livraison" + system_step3_text: "Réceptionnez vos produits à domicile, ou rendez vous chez votre producteur ou hub pour rencontrer les gens qui se cachent derrière les produits. Au delà de la bio-diversité, nous cultivons l'éco-diversité: vivez des expériences d'achat de nourriture uniques et humaines." + cta_headline: "Des achats qui rendent le monde un peu meilleur." + cta_label: "Je vote avec mes achats" + stats_headline: "Nous créons un nouveau système alimentaire." + stats_producers: "agriculteurs et producteurs" + stats_shops: "boutiques" + stats_shoppers: "acheteurs" + stats_orders: "commandes" + checkout_title: Finalisation commande + checkout_now: Passer la commande + checkout_order_ready: Commande prête pour + checkout_hide: Masquer + checkout_expand: Afficher + checkout_headline: "Ok, prêt à finaliser la commande?" + checkout_as_guest: "Passer commande en mode invité" + checkout_details: "Vos informations" + checkout_billing: "Informations de facturation" + checkout_default_bill_address: "Sauvegarder comme adresse de facturation par défaut" + checkout_shipping: Informations de livraison + checkout_default_ship_address: "Sauvegarder comme adresse de livraison par défaut" + checkout_method_free: Pas de frais supplémentaires + checkout_address_same: Adresse de livraison identique à l'adresse de facturation? + checkout_ready_for: "Prêt pour:" + checkout_instructions: "Commentaires ou demandes spécifiques?" + checkout_payment: Paiement + checkout_send: Passer la commande + checkout_your_order: Votre commande + checkout_cart_total: Panier total + checkout_shipping_price: Livraison + checkout_total_price: Total + checkout_back_to_cart: "Retour au Panier" + cost_currency: "Coût de la devise" + order_paid: RÉGLÉ + order_not_paid: NON RÉGLÉ + order_total: Total commande + order_payment: "Payer via:" + order_billing_address: Adresse de facturation + order_delivery_on: Livraison prévue + order_delivery_address: Adresse de livraison + order_delivery_time: Créneau de livraison/retrait + order_special_instructions: "Vos commentaires:" + order_pickup_time: Prêt à être retiré + order_pickup_instructions: Instructions de retrait + order_produce: Produit + order_total_price: Total + order_includes_tax: (dont TVA) + order_payment_paypal_successful: Votre paiement via PayPal a été réalisé avec succès. + order_hub_info: Hub Info + order_back_to_store: Retour à la boutique + order_back_to_cart: Retour au panier + bom_tip: "Utilisez cette page pour modifier les quantités sur plusieurs commandes à la fois. Les produits peuvent aussi être supprimés des commandes si nécessaire." + unsaved_changes_warning: "Des modifications n'ont pas été enregistrées et seront perdues si vous continuez." + unsaved_changes_error: "Les champs entourés en rouge contiennent des erreurs." + products: "Produits" + products_in: "dans %{oc}" + products_at: "à %{distributor}" + products_elsewhere: "Produits trouvés ailleurs" + email_welcome: "Bienvenue" + email_confirmed: "Veuillez confirmer votre adresse email." + email_registered: "fait maintenant partie de" + email_userguide_html: "Le Guide Utilisateur expliquant comment mettre en place son profil producteur ou son hub est accessible ici: %{link}" + email_admin_html: "Vous pouvez gérer votre compte en vous connectant ici %{link} ou en cliquant sur la roue en haut à droite de la page d'accueil et en sélectionnant Administration." + email_community_html: "Nous avons aussi un forum de discussion en ligne (en anglais) pour échanger avec la communauté sur des questions liées au logiciel OFN et aux défis de la gestion d'un food hub. Nous vous invitons à y participer. Nous sommes en constante évolution et vos contributions à ce forum vont façonner les prochaines étapes. %{link}" + join_community: "Rejoindre la communauté" + email_confirmation_activate_account: "Avant de pouvoir activer votre compte, nous devons nous assurer de la validité de votre adresse email." + email_confirmation_greeting: "Bonjour %{contact}!" + email_confirmation_profile_created: "Le profil pour %{name} a été créé avec succès! Pour activer votre Profil nous devons vérifier cette adresse email." + email_confirmation_click_link: "Veuillez cliquer sur le lien ci-dessous pour confirmer votre email et continuer la configuration de votre compte." + email_confirmation_link_label: "Confirmer cette adresse email »" + email_confirmation_help_html: "Après confirmation de votre email, vous pourrez accéder au compte d'administration de cette entreprise. Voir %{link} pour en savoir plus à propos de %{sitename} et commencer à utiliser votre profil et/ou boutique en ligne." + email_social: "Nous suivre:" + email_contact: "Nous écrire:" + email_signoff: "Cordialement," + email_signature: "L'équipe %{sitename}" + email_confirm_customer_greeting: "Bonjour %{name}," + email_confirm_customer_intro_html: "Merci d'avoir passé commande chez %{distributor}!" + email_confirm_customer_number_html: "Confirmation de commande #%{number}" + email_confirm_customer_details_html: "Détails de votre commande chez %{distributor}:" + email_confirm_customer_signoff: "Cordialement," + email_confirm_shop_greeting: "Bonjour %{name}," + email_confirm_shop_order_html: "Bravo! Vous avez reçu une nouvelle commande pour %{distributor}!" + email_confirm_shop_number_html: "Confirmation de commande #%{number}" + email_order_summary_item: "Produit" + email_order_summary_quantity: "Qté" + email_order_summary_price: "Prix" + email_order_summary_subtotal: "Sous-total:" + email_order_summary_total: "Total:" + email_order_summary_includes_tax: "(dont TVA)" + email_payment_paid: RÉGLÉ + email_payment_not_paid: NON RÉGLÉ + email_payment_summary: Résumé du paiement + email_payment_method: "Payer via :" + email_so_placement_intro_html: "Vous avez une nouvelle commande pour %{distributor}" + email_so_placement_details_html: "Voici les détails de votre commande pour %{distributor}:" + email_so_placement_changes: "Malheureusement, certains produits demandés n'étaient pas disponibles. Les quantités d'origine demandées apparaissent barrées ci-dessous." + email_so_payment_success_intro_html: "Un paiement automatique a été effectué pour votre commande auprès de %{distributor}." + email_so_placement_explainer_html: "Cette commande a été créée automatiquement dans le cadre de votre abonnement." + email_so_edit_true_html: "Vous pouvez effectuer des modifications jusqu'à la fermeture de la période de commande le %{orders_close_at}." + email_so_edit_false_html: "Vous pouvez consulter les détails de cette commande à tout moment." + email_so_contact_distributor_html: "Pour toute question contactez %{distributor} via %{email}." + email_so_confirmation_intro_html: "Votre commande auprès de %{distributor} est maintenant confirmée" + email_so_confirmation_explainer_html: "Cette commande a été automatiquement passée pour vous dans le cadre de votre abonnement, et a maintenant été confirmée." + email_so_confirmation_details_html: "Voici les détails concernant cette commande auprès de %{distributor}:" + email_so_empty_intro_html: "Nous avons essayé de passer votre commande auprès de %{distributor}, mais une erreur est survenue..." + email_so_empty_explainer_html: "Malheureusement, aucun des produits demandés n'étaient disponibles, nous n'avons donc pas pu passer votre commande. Les quantités d'origine demandées apparaissent barrées ci-dessous." + email_so_empty_details_html: "Voici les détails concernant la commande qui n'a pas pu être passée auprès de %{distributor}:" + email_so_failed_payment_intro_html: "Nous avons essayé d'effectuer un paiement, mais une erreur est survenue..." + email_so_failed_payment_explainer_html: "Le paiement pour l'abonnement auprès de %{distributor} a échoué à cause d'un problème avec votre carte de crédit. %{distributor} a été notifié de ce problème de paiement." + email_so_failed_payment_details_html: "Voici les détails concernant l'erreur fournis par la passerelle de paiement:" + email_shipping_delivery_details: Détails de livraison + email_shipping_delivery_time: "Livré le:" + email_shipping_delivery_address: "Adresse de livraison:" + email_shipping_collection_details: Détails de retrait + email_shipping_collection_time: "Prêt pour retrait:" + email_shipping_collection_instructions: "Instructions de retrait:" + email_special_instructions: "Vos commentaires:" + email_signup_greeting: Bonjour! + email_signup_welcome: "Bienvenue sur %{sitename}!" + email_signup_confirmed_email: "Merci d'avoir confirmé votre email." + email_signup_shop_html: "Vous pouvez maintenant vous connecter sur %{link}." + email_signup_text: "Merci d'avoir rejoint le réseau. Si vous êtes un client, nous sommes impatients de vous faire découvrir de nombreux agriculteurs fantastiques, de merveilleux hubs de distribution et des plats délicieux! Si vous êtes un producteur ou autre entreprise alimentaire, nous sommes ravis de vous compter parmi les membres du réseau." + email_signup_help_html: "Vos questions et feedbacks sont les bienvenus! Cliquez sur le bouton Envoyer un commentaire sur le site ou envoyez-nous un email à %{email}" + invite_email: + greeting: "Bonjour!" + invited_to_manage: "Vous avez été invité(e) à gérer %{enterprise} sur %{instance}." + confirm_your_email: "Vous avez reçu ou allez recevoir prochainement un email avec un lien de validation. Vous n'aurez pas accès au profil de l'entreprise %{enterprise} avant d'avoir cliqué sur ce lien." + set_a_password: "Vous serez ensuite invité(e) à choisir un mot de passe avant de pouvoir accéder et gérer le profil de l'entreprise." + mistakenly_sent: "Vous ne savez pas pourquoi vous avez reçu cet email? Veuillez contacter %{owner_email} pour plus d'informations." + producer_mail_greeting: "Cher(ère)" + producer_mail_text_before: "Nous avons reçu toutes les commandes pour la prochaine livraison." + producer_mail_order_text: "Voilà la liste et les quantités des produits commandés vous concernant:" + producer_mail_delivery_instructions: "Modalités de livraison des produits:" + producer_mail_signoff: "Merci et belle fin de journée!" + shopping_oc_closed: La boutique est actuellement fermée + shopping_oc_closed_description: "Veuillez attendre l'ouverture du prochain cycle de vente (ou contactez-nous directement pour voir si nous pouvons accepter une commande tardive)" + shopping_oc_last_closed: "Le dernier cycle de vente s'est terminé il y a %{distance_of_time}" + shopping_oc_next_open: "Le prochain cycle de vente ouvrira dans %{distance_of_time}" + shopping_tabs_about: "A propos de %{distributor}" + shopping_tabs_contact: "Contact" + shopping_contact_address: "Adresse" + shopping_contact_web: "Contact" + shopping_contact_social: "Suivre" + shopping_groups_part_of: "fait partie de:" + shopping_producers_of_hub: "Les producteurs de %{hub}:" + enterprises_next_closing: "Clôture des commandes pour ce cycle" + enterprises_ready_for: "Prêt pour" + enterprises_choose: "Choisissez votre option:" + maps_open: "Ouvre" + maps_closed: "Fermée" + hubs_buy: "Acheter:" + hubs_shopping_here: "Achats en cours" + hubs_orders_closed: "Boutique fermée" + hubs_profile_only: "Fiche profil" + hubs_delivery_options: "Options de livraison" + hubs_pickup: "Retrait" + hubs_delivery: "Livraison" + hubs_producers: "Nos producteurs" + hubs_filter_by: "Filtrer par" + hubs_filter_type: "Catégorie" + hubs_filter_delivery: "Livraison" + hubs_filter_property: "Propriétés / labels" + hubs_matches: "Vous voulez dire?" + hubs_intro: Passez commande près de chez vous + hubs_distance: Le plus près de + hubs_distance_filter: "Afficher les boutiques près de %{location}" + shop_changeable_orders_alert_html: + one: Votre commande avec %{shop} / %{order} est ouverte pour vérification. Vous pouvez effectuer des modification jusqu'à %{oc_close}. + other: Vous avez %{count} commandes avec %{shop}ouvertes à la vérification. Vous pouvez effectuer des modifications jusqu'à %{oc_close}. + orders_changeable_orders_alert_html: Cette commande a été confirmée, mais vous pouvez effectuer des modifications jusqu'à %{oc_close}. + products_clear_all: Vider + products_showing: "Afficher:" + products_with: avec + products_search: "Recherche par produit ou producteur" + products_loading: "Produits en cours de chargement..." + products_updating_cart: "Actualisation du panier..." + products_cart_empty: "Panier vide" + products_edit_cart: "Valider votre panier" + products_from: de + products_change: "Aucun changement à sauvegarder." + products_update_error: "Échec de l'enregistrement dû à:" + products_update_error_msg: "Échec de l'enregistrement." + products_update_error_data: "Échec de l'enregistrement dû à des données non valides." + products_changes_saved: "Modifications enregistrées." + search_no_results_html: "Désolé, aucun résultat pour %{query}. Autre recherche?" + components_profiles_popover: "Certaines entreprises ont juste créé leur profil sur Open Food Network mais ne vendent pas via la plateforme. Elles ont peut-être une boutique physique, ou une boutique en ligne sur une autre plateforme." + components_profiles_show: "Afficher aussi les profils" + components_filters_nofilters: "Pas de filtre" + components_filters_clearfilters: "Vider les filtres" + groups_title: Groupes + groups_headline: Groupes / réseaux territoriaux + groups_text: "Chaque producteur est unique. Chaque entreprise peut offrir quelque chose de différent. Nos groupes sont des collectifs de producteurs, des plateformes et des distributeurs qui partagent une proximité géographique, un marché fermier ou des valeurs. C'est ce qui rend votre expérience d'achat plus simple. Explorez donc ces groupes sélectionnés." + groups_search: "Recherche par nom ou mot-clé" + groups_no_groups: "Aucun groupe trouvé" + groups_about: "A propos" + groups_producers: "Nos producteurs" + groups_hubs: "Nos hubs" + groups_contact_web: Contact + groups_contact_social: Suivre + groups_contact_address: Adresse + groups_contact_email: Nous écrire + groups_contact_website: Visiter notre site web + groups_contact_facebook: Nous suivre sur Facebook + groups_signup_title: S'inscrire en tant que groupe + groups_signup_headline: Inscription groupe + groups_signup_intro: "Nous sommes une plate-forme très efficace pour le marketing collaboratif, une excellente manière pour vos membres et parties prenantes d'atteindre de nouveaux marchés. Nous sommes à but non lucratif, abordable et simple." + groups_signup_email: Nous écrire + groups_signup_motivation1: Nous transformons les systèmes alimentaires pour remettre de l'équité dans les échanges. + groups_signup_motivation2: C'est pourquoi nous sortons du lit chaque matin. Nous sommes une organisation à but non lucratif, basée sur un code source ouvert. Nous opérons en toute transparence. + groups_signup_motivation3: Vous avez de belles idées, et nous voulons vous aider. Nous partageons nos connaissances, réseaux et ressources. Nous savons que l'isolement ne crée pas le changement, alors coopérons. + groups_signup_motivation4: Nous venons à votre rencontrer. + groups_signup_motivation5: Vous êtes un réseau de circuits de distribution alternatifs, de producteurs, de distributeurs, une administration liée à l'industrie alimentaire ou une autorité locale? + groups_signup_motivation6: Quel que soit votre rôle dans la relocalisation des systèmes alimentaires, nous sommes prêts à vous soutenir. Si vous vous demandez à quoi Open Food Network ressemble / pourrait ressembler dans votre coin du monde, contactez-nous. + groups_signup_motivation7: Nous contribuons à remettre du sens dans les systèmes alimentaires. + groups_signup_motivation8: Vous avez besoin de connecter et d'outiller vos réseaux, nous offrons une plate-forme pour la coopération et l'action. Vous souhaitez de l'engagement. Nous vous aidons à atteindre les acteurs, les parties-prenantes, les secteurs. + groups_signup_motivation9: Vous avez besoin de ressources. Nous mettons à votre service notre expérience. Vous avez besoin de coopération. Nous vous connectons à un large réseau d'acteurs et d'organisations soeurs partout dans le monde. + groups_signup_pricing: Compte groupe + groups_signup_studies: Etudes de cas + groups_signup_contact: Vous voulez discuter? + groups_signup_contact_text: "Prenez contact et découvrez ce qu'Open Food Network peut faire pour vous:" + groups_signup_detail: "Plus de précisions." + login_invalid: "Email ou mot de passe erroné" + modal_hubs: "Food Hubs" + modal_hubs_abstract: Nos food hubs sont les points de contact entre vous et les personnes qui produisent votre nourriture! + modal_hubs_content1: Vous pouvez chercher le hub qui vous convient par localisation ou par nom. Certains hubs ont de multiples points de retrait de vos achats, et certains proposent également la livraison à domicile. Chaque food hub est un point de vente et gère de façon indépendante ses opérations et sa logistique - attendez-vous donc à des disparités de fonctionnement entre les hubs. + modal_hubs_content2: Vous pouvez uniquement faire vos courses dans un hub à la fois. + modal_groups: "Groupes / réseaux territoriaux" + modal_groups_content1: Voilà les organisations et les relations inter-hubs qui constituent l'Open Food Network. + modal_groups_content2: Certains groupes sont regroupés pas localisation ou région, d'autres sur des smilitudes non géographiques. + modal_how: "Comment ça marche" + modal_how_shop: Faire vos courses sur Open Food Network + modal_how_shop_explained: Recherchez un food hub près de chez vous et commencez vos achats! Vous pouvez afficher plus d'infos sur chaque food hub pour voir le type de produits qu'il propose, et cliquer sur le hub pour commencer vos achats. (Vous ne pouvez faire vos courses que dans un food hub à la fois.) + modal_how_pickup: Frais de retrait, livraison et transport + modal_how_pickup_explained: Certains food hubs livrent à domicile, d'autres vous demandent de venir récupérer vos achats dans un point de retrait. Vous pouvez voir quelle options sont proposées sur la page d'accueil du hub, et sélectionner votre choix au moment de la validation de la commande. La livraison à domicile coûtera souvent plus cher, et les prix diffèrent selon le hub. Chaque food hub est un point de vente et gère de façon indépendante ses opérations et sa logistique - attendez-vous donc à des disparités de fonctionnement entre les hubs. + modal_how_more: En savoir plus + modal_how_more_explained: "Pour en savoir plus sur Open Food Network, comment ça marche, et contribuer, allez voir:" + modal_producers: "Producteurs" + modal_producers_explained: "Nos producteurs font pousser et fabriquent tous les délicieux produits que vous pouvez acheter sur Open Food Network." + producers_about: A propos + producers_buy: Acheter + producers_contact: Contact + producers_contact_phone: Appeler + producers_contact_social: Suivre + producers_buy_at_html: "Acheter les produits de %{enterprise} dans les boutiques suivantes:" + producers_filter: Filtrer par + producers_filter_type: Catégorie + producers_filter_property: Propriété + producers_title: Producteurs + producers_headline: Trouvez un producteur local + producers_signup_title: S'inscrire en tant que producteur + producers_signup_headline: Des producteurs, indépendants + producers_signup_motivation: Vendez vos produits et racontez vos histoires pour toucher de nouveaux marchés. Gagnez du temps et de l'argent sur la gestion des opérations courantes. Vous pouvez innover sans prendre de risque. Nous nivellons le terrain de jeu pour des échanges plus équitables. + producers_signup_send: Rejoindre le réseau + producers_signup_enterprise: Comptes entreprises + producers_signup_studies: Les histoires de nos producteurs. + producers_signup_cta_headline: Rejoindre le réseau! + producers_signup_cta_action: Rejoindre le réseau + producers_signup_detail: Comment ça marche. + products_item: Produit + products_description: Description + products_variant: Variante + products_quantity: Quantité + products_available: Disponible? + products_producer: "Producteur" + products_price: "Prix" + register_title: S'inscrire + sell_title: "S'inscrire" + sell_headline: "Aller sur Open Food France!" + sell_motivation: "Mettez en avant vos beaux aliments." + sell_producers: "Producteurs" + sell_hubs: "Hubs" + sell_groups: "Groupes" + sell_producers_detail: "Créer un profil pour votre entreprise sur OFNetwork en quelques minutes. A tout moment vous pourrez créer une boutique en ligne pour vendre vos produits en direct aux acheteurs." + sell_hubs_detail: "Créer un profil pour votre entreprise de distribution ou organisation sur OFN. A tout moment vous pourrez créer une boutique multi-fournisseurs." + sell_groups_detail: "Créer un répertoire sur mesure (regroupant différents producteurs et hubs de distribution) pour votre région ou votre organisation." + sell_user_guide: "En savoir plus en explorant le guide utilisateur." + sell_listing_price: "La création d'un profil sur OFN est entièrement libre. Si vous ouvrez et gérez une boutique sur OFN, ou créez un groupe pour votre organisation ou réseau régional, nous vous invitons à contribuer au commun Open Food Network que vous utilisez. En effet, faire tourner la plateforme Open Food Network a un coût, et nous comptons sur VOUS pour contribuer à couvrir ces frais de fonctionnement (location et maintenance des serveurs, support utilisateur, nouveaux développements...). Par exemple, en reversant sous forme de don à l'association 2% de votre chiffre d'affaire, et/ou un montant fixe tous les mois. Vous pouvez aussi contribuer au commun \"en compétences\" (développement de fonctionnalités, recherche de financement, support utilisateur, etc.)." + sell_embed: "Nous pouvons aussi intégrer votre boutique OFN dans votre propre site web ou construire un site web d'alimentation locale sur mesure pour votre région." + sell_ask_services: "Nous consulter sur les services de OFN." + shops_title: Boutiques + shops_headline: Des achats qui transforment. + shops_text: Les aliments poussent selon des cycles naturels, les fermiers récoltent en cycles. Alors ici, nous achetons aussi en cycles. Si un cycle de vente est terminé, attendez le suivant ou demandez des infos au hub ! + shops_signup_title: S'inscrire en tant que hub + shops_signup_headline: Des hubs divers et variés + shops_signup_motivation: Quel que soit votre modèle, vous pouvez vous appuyer sur Open Food Network. Si vous voulez le faire évoluer, nous sommes là pour vous aider. Nous agissons selon des principes de non-lucrativité, d'indépendance, et de transparence. Et nous faisons tout notre possible pour répondre à vos besoins et vous accompagner en toute circonstance. + shops_signup_action: Rejoindre le réseau + shops_signup_pricing: Comptes entreprises + shops_signup_stories: Histoires de hubs. + shops_signup_help: Nous sommes là pour vous aider. + shops_signup_help_text: Vous avez besoin de pouvoir travailler de manière efficace. Vous avez besoin de nouveaux acheteurs et de partenaires logistiques. Vous souhaitez que votre histoire soit racontée tout au long du circuit, que l'acheteur final sache qui se trouve derrière les produits. + shops_signup_detail: Comment ça marche. + orders: Commandes + orders_fees: Frais... + orders_edit_title: Panier + orders_edit_headline: Votre panier + orders_edit_time: Commande prête pour + orders_edit_continue: Retour à la boutique + orders_edit_checkout: Finalisation commande + orders_form_empty_cart: "Vider le panier" + orders_form_subtotal: Sous-total + orders_form_admin: Admin & gestion + orders_form_total: Total + orders_oc_expired_headline: Les commandes ne sont plus possibles pour ce cycle de vente. + orders_oc_expired_text: "Désolé, les commandes pour ce cycle de vente ont été clôturées il y a %{time}! Veuillez contacter directement le hub pour voir s'il accepte les commandes tardives." + orders_oc_expired_text_others_html: "Désolé, les commandes pour ce cycle de vente ont été clôturées il y a %{time}! Veuillez contacter directement le hub pour voir s'il accepte les commandes tardives %{link}." + orders_oc_expired_text_link: "ou voir si d'autres cycles de vente sont ouverts pour ce hub" + orders_oc_expired_email: "Email:" + orders_oc_expired_phone: "Téléphone:" + orders_show_title: Confirmation de commande + orders_show_time: Commande prête pour + orders_show_order_number: "Commande #%{number}" + orders_show_cancelled: Annulée + orders_show_confirmed: Confirmée + orders_your_order_has_been_cancelled: "Votre commande a été annulée" + orders_could_not_cancel: "Désolé, la commande n'a pas pu être annulée" + orders_cannot_remove_the_final_item: "Impossible de supprimer le dernier produit d'une commande, si vous souhaitez supprimer l'ensemble des produits, veuillez annuler la commande." + orders_bought_items_notice: + one: "Un produit ajouté a bien été confirmé pour ce cycle de vente" + other: "%{count} produits ajoutés ont déjà été confirmés pour ce cycle de vente." + orders_bought_edit_button: Modifier les produits confirmés + orders_bought_already_confirmed: "* déjà confirmé" + orders_confirm_cancel: Voulez-vous vraiment annuler cette commande ? + products_cart_distributor_choice: "Distributeur pour votre commande:" + products_cart_distributor_change: "Vore distributeur pour cette commande sera dorénavant %{name} si vous ajoutez ce produit à votre panier." + products_cart_distributor_is: "Votre distributeur pour cette commande est %{name}." + products_distributor_error: "Terminez votre commande chez %{link} avant de faire vos courses chez un autre distributeur." + products_oc: "Cycle de vente pour votre commande:" + products_oc_change: "Votre cycle de vente pour cette commande sera dorénavant %{name} si vous ajoutez ce produit à votre panier." + products_oc_is: "Votre cycle de vente pour cette commande est %{name}." + products_oc_error: "Veuillez terminer votre commande pour %{link} avant de faire vos courses pour un autre cycle de vente." + products_oc_current: "votre cycle de vente actuel" + products_max_quantity: Quantité max + products_distributor: Distributeur + products_distributor_info: Quand vous choisissez un distributeur pour votre commande, les adresse et date de retrait seront affichées ici. + products_distribution_adjustment_label: "Distribution par %{distributor}du produit %{product}" + shop_trial_expires_in: "Votre période d'essai se termine dans" + shop_trial_expired_notice: "Bonne nouvelle ! Nous avons décidé d'étendre votre période d'essai jusqu'à nouvel ordre." + password: Mot de passe + remember_me: Se souvenir de moi + are_you_sure: "Confirmer?" + orders_open: Boutique ouverte + closing: "Fermeture " + going_back_to_home_page: "Retour à la page d'accueil" + creating: Création + updating: Mettre à jour + failed_to_create_enterprise: "Impossible de créer votre entreprise" + failed_to_create_enterprise_unknown: "Impossible de créer votre entreprise.\nVérifiez que tous les champs sont remplis." + failed_to_update_enterprise_unknown: "Impossible de mettre à jour votre entreprise.\nVérifiez que tous les champs sont remplis." + enterprise_confirm_delete_message: "Cette action supprimera également le produit %{product} que cette entreprise distibue. Voulez-vous vraiment continuer ?" + order_not_saved_yet: "Votre commande n'a pas encore été enregistrée. Attendez quelques secondes!" + filter_by: "Filtrer par" + hide_filters: "Masquer les filtres" + one_filter_applied: "1 filtre appliqué" + x_filters_applied: "filtres appliqués" + submitting_order: "Votre commande est en cours d'envoi : veuillez patienter" + confirm_hub_change: "Confirmer? Cette action modifiera la boutique sélectionnée et tous les articles de votre panier seront effacés." + confirm_oc_change: "Confirmer? Cette action modifiera le cycle de vente sélectionné et tous les articles de votre panier seront effacés." + location_placeholder: "Saisissez une localisation..." + error_required: "Champ obligatoire" + error_number: "saisir un nombre" + error_email: "saisir une adresse email" + error_not_found_in_database: "%{name} n'a pas été trouvé dans la base de donnée" + error_no_permission_for_enterprise: "\"%{name}\" : vous n'avez pas les droits requis pour gérer les produits de cette entreprise" + item_handling_fees: "Frais logistiques (inclus dans le prix affiché)" + january: "Janvier" + february: "Février" + march: "Mars" + april: "Avril" + may: "Mai" + june: "Juin" + july: "Juillet" + august: "Août" + september: "Septembre" + october: "Octobre" + november: "Novembre" + december: "Décembre" + email_not_found: "Adresse email non trouvée" + email_unconfirmed: "Vous devez confirmer votre email avant de mettre à jour votre mot de passe." + email_required: "Vous devez saisir une adresse email" + logging_in: "Veuillez patienter, connexion en cours" + signup_email: "Votre email" + choose_password: "Choisissez un mot de passe" + confirm_password: "Confirmez votre mot de passe" + action_signup: "S'inscrire" + welcome_to_ofn: "Bienvenue sur Open Food Network" + signup_or_login: "Commencez par vous inscrire (ou vous connecter)" + have_an_account: "Déjà inscrit?" + action_login: "Se connecter." + forgot_password: "Mot de passe oublié?" + password_reset_sent: "Un email contenant les instructions pour changer votre mot de passe a été envoyé!" + reset_password: "Changer de mot de passe" + who_is_managing_enterprise: "Qui gère %{enterprise}?" + update_and_recalculate_fees: "Mettre à jour et recalculer les frais" + enterprise: + registration: + modal: + steps: + details: + title: 'Détails' + headline: "Commençons !" + enterprise: "Hey ! Nous avons d'abord besoin de quelques informations sur votre entreprise :" + producer: "Hey ! Nous avons d'abord besoin de quelques informations sur votre ferme :" + enterprise_name_field: "Nom de l'entreprise :" + producer_name_field: "Nom de la ferme :" + producer_name_field_placeholder: "ex: La Ferme du Marais" + producer_name_field_error: "Veuillez choisir le nom de votre entreprise" + address1_field: "Adresse ligne 1" + address1_field_placeholder: "ex : 35 rue du bac" + address1_field_error: "Veuillez saisir une adresse" + address2_field: "Adresse ligne 2" + suburb_field: "Ville :" + suburb_field_placeholder: "ex : Nantes" + suburb_field_error: "Veuillez saisir une ville" + postcode_field: "Code postal :" + postcode_field_placeholder: "ex : 44000" + postcode_field_error: "Veuillez saisir le code postal" + state_field: "Département :" + state_field_error: "Veuillez saisir un Département" + country_field: "Pays :" + country_field_error: "Veuillez saisir une Pays" + contact: + title: 'Contact' + contact_field: 'Personne référente' + contact_field_placeholder: 'Nom du contact principal' + contact_field_required: "Vous devez saisir une personne référente" + email_field: 'Adresse email' + email_field_placeholder: 'ex : robert@mabelleferme.fr' + phone_field: 'Numéro de téléphone' + phone_field_placeholder: 'ex : 06 24 53 26 53' + type: + title: 'Catégorie' + headline: "Dernière étape pour ajouter %{enterprise} !" + question: "Etes-vous un producteur ?" + yes_producer: "Oui, je suis un producteur" + no_producer: "Non, je ne suis pas un producteur" + producer_field_error: "Veuillez faire un choix. Etes vous un producteur?" + yes_producer_help: "Un producteur fabrique de bonnes choses à boire et à manger. Vous êtes un producteur si vous les faites pousser, les élevez, les pétrissez, transformez, fermentez, les réduisez en grains, etc." + no_producer_help: "Si vous n'êtes pas un producteur, vous êtes probablement un revendeur ou distributeur alimentaire : un \"hub\", une coopérative, un groupement d'achat, un revendeur, un grossiste, ou autre." + about: + title: 'A propos' + images: + title: 'Images' + social: + title: 'Réseaux sociaux' + enterprise_contact: "Personne référente" + enterprise_contact_placeholder: "Nom du contact principal" + enterprise_contact_required: "Vous devez saisir une personne référente" + enterprise_email_address: "Adresse email" + enterprise_email_placeholder: "ex : robert@mabelleferme.fr" + enterprise_phone: "Numéro de téléphone" + enterprise_phone_placeholder: "ex : 06 24 53 26 53" + back: "Retour" + continue: "Suivant" + limit_reached_headline: "Oh non!" + limit_reached_message: "Vous avez atteint la limite!" + limit_reached_text: "Vous avez atteint la limite du nombre d'entreprises que vous êtes autorisés à gérer sur" + limit_reached_action: "Retour sur la page d'accueil" + select_promo_image: "Etape 3. Sélectionnez une image promotionnelle" + promo_image_tip: "Conseil: affichée en format bannière, taille optimale 1200×260px" + promo_image_label: "Choisissez une image promotionnelle" + action_or: "OU" + promo_image_drag: "Glissez déplacez votre image promotionnelle ici" + review_promo_image: "Etape 4. Validez votre bannière promotionnelle" + review_promo_image_tip: "Conseil: pour un résultat optimal, votre image promotionnelle doit être adaptée à l'espace disponible" + promo_image_placeholder: "Votre logo apparaîtra ici pour validation une fois uploadé" + uploading: "Upload en cours..." + select_logo: "Etape 1. Insérez votre logo" + logo_tip: "Conseil: utilisez un format d'image carré de préférence, min 300×300px" + logo_label: "Insérez votre logo" + logo_drag: "Glissez déplacez votre logo ici" + review_logo: "Etape 2: Validez votre logo" + review_logo_tip: "Conseil: pour un résultat optimal, votre logo doit être adapté à l'espace disponible" + logo_placeholder: "Votre logo apparaîtra ici pour validation une fois uploadé" + enterprise_about_headline: "Bien joué!" + enterprise_about_message: "A présent, allons un peu plus dans les détails concernant" + enterprise_success: "Opération réussie! %{enterprise} a été ajoutée à Open Food Network" + enterprise_description: "Description courte" + enterprise_description_placeholder: "Une phrase pour décrire votre organisation" + enterprise_long_desc: "Description longue" + enterprise_long_desc_placeholder: "Vous pouvez ici raconter l'histoire de votre organisation - votre projet, les valeurs que vous défendez. Nous vous conseillons de ne pas dépasser 600 caractères ou 150 mots." + enterprise_long_desc_length: "%{num} caractères / inférieur à 600 recommandé" + enterprise_limit: Nombre max d'entreprises + enterprise_abn: "SIRET" + enterprise_abn_placeholder: "ex: 404 833 048 00022" + enterprise_acn: "n° TVA intracommunautaire" + enterprise_acn_placeholder: "ex: 404 833 048" + enterprise_tax_required: "Merci de choisir." + enterprise_final_step: "Dernière étape!" + enterprise_social_text: "Comment trouver la boutique en ligne %{enterprise} ?" + website: "Site internet" + website_placeholder: "ex: openfoodnetwork.ca" + facebook: "Facebook" + facebook_placeholder: "ex: www.facebook.com/NomDeLaPage" + linkedin: "LinkedIn" + linkedin_placeholder: "ex: www.linkedin.com/VotreNom" + twitter: "Twitter" + twitter_placeholder: "ex: @twitter_pseudo" + instagram: "Instagram" + instagram_placeholder: "ex: @instagram_pseudo" + registration_greeting: "Bonjour!" + registration_intro: "Vous pouvez maintenant créer votre profil \"Producteur\" ou \"Hub\"" + registration_action: "Démarrons!" + registration_checklist: "Vous aurez besoin de" + registration_time: "5-10 minutes" + registration_enterprise_address: "L'adresse de l'entreprise" + registration_contact_details: "Les détails du contact référent" + registration_logo: "Votre logo" + registration_promo_image: "Une image bannière pour votre profil" + registration_about_us: "Un texte \"A propos\"" + registration_outcome_headline: "Qu'est-ce que ça m'apporte?" + registration_outcome1_html: "Votre profil permet aux gens de vous trouver et de vous contacter via Open Food Network." + registration_outcome2: "Utilisez cet espace pour raconter l'histoire de votre entreprise, et stimuler les visites vers vos points de présence en ligne." + registration_outcome3: "C'est aussi le premier pas vers la vente via Open Food Network, ou l'ouverture de votre boutique en ligne." + registration_finished_headline: "C'est terminé!" + registration_finished_thanks: "Merci d'avoir complété le profil de %{enterprise}" + registration_finished_login: "Vous pouvez modifier ou mettre à jour les détails de votre entreprise à tout moment en vous connectant sur Open Food Network, rubrique Admin." + registration_finished_action: "Accueil Open Food Network" + registration_contact_name: 'Nom du contact principal' + registration_type_headline: "Dernière étape pour ajouter %{enterprise}!" + registration_type_question: "Etes-vous un producteur?" + registration_type_producer: "Oui, je suis un producteur" + registration_type_no_producer: "Non, je ne suis pas un producteur" + registration_type_error: "Veuillez faire un choix. Etes vous un producteur?" + registration_type_producer_help: "Un producteur fabrique de bonnes choses à boire et à manger. Vous êtes un producteur si vous les faites pousser, les élevez, les pétrissez, transformez, fermentez, les réduisez en grains, etc." + registration_type_no_producer_help: "Si vous n'êtes pas un producteur, vous êtes probablement un revendeur ou distributeur alimentaire: un \"hub\", une coopérative, un groupement d'achat, un revendeur, un grossiste, ou autre." + create_profile: "Créer votre profil" + registration_images_headline: "Merci!" + registration_images_description: "Ajoutez maintenant de jolies photos pour que votre profil soit attractif! :)" + registration_detail_headline: "Commençons" + registration_detail_enterprise: "Woohoo! Dites-nous déjà quelques mots à propos de votre entreprise:" + registration_detail_producer: "Woohoo! Dites-nous déjà quelques mots à propos de votre ferme:" + registration_detail_name_enterprise: "Nom de l'entreprise:" + registration_detail_name_producer: "Nom de la ferme:" + registration_detail_name_placeholder: "ex: La super ferme de Charlie" + registration_detail_name_error: "Veuillez choisir le nom de votre entreprise" + registration_detail_address1: "Adresse ligne 1" + registration_detail_address1_placeholder: "ex: 123 rue des étangs" + registration_detail_address1_error: "Veuillez saisir une adresse" + registration_detail_address2: "Adresse ligne 2" + registration_detail_suburb: "Ville:" + registration_detail_suburb_placeholder: "ex: Montréal" + registration_detail_suburb_error: "Veuillez saisir une ville" + registration_detail_postcode: "Code postal:" + registration_detail_postcode_placeholder: "ex: J3H 3K5" + registration_detail_postcode_error: "Veuillez saisir le code postal" + registration_detail_state: "Région:" + registration_detail_state_error: "Veuillez saisir une Région" + registration_detail_country: "Pays:" + registration_detail_country_error: "Veuillez saisir un Pays" + shipping_method_destroy_error: "Cette méthode de livraison ne peut pas être supprimée car elle est référencée dans une commande : %{number}." + accounts_and_billing_task_already_running_error: "Une autre tache est en cours, merci de patienter un instant..." + accounts_and_billing_start_task_notice: "Tache mise en file d'attente" + fees: "Frais" + item_cost: "Coût du produit" + bulk: "Vrac" + shop_variant_quantity_min: "min" + shop_variant_quantity_max: "max" + follow: "Suivre" + shop_for_products_html: "Acheter les produits de %{enterprise} dans les boutiques suivantes:" + change_shop: "Changer de boutique pour:" + shop_at: "Acheter maintenant :" + price_breakdown: "Détail du prix:" + admin_fee: "Frais de gestion admin" + sales_fee: "Frais de ventes/marketing" + packing_fee: "Frais de conditionnement" + transport_fee: "Frais logistiques" + fundraising_fee: "Frais recherche de financement" + price_graph: "Légende détail du prix" + included_tax: "Inclut TVA" + balance: "Solde" + transaction: "Transaction" + transaction_date: "Date" + payment_state: "Statut du paiement" + shipping_state: "Statut de la livraison" + value: "Nb unités" + balance_due: "Montant dû" + credit: "Crédit" + Paid: "Payé" + Ready: "Prêt" + ok: OK + not_visible: invisible + you_have_no_orders_yet: "Vous n'avez pas encore de commande" + running_balance: "Solde courant" + outstanding_balance: "Solde restant" + admin_entreprise_relationships: "Permissions inter-entreprises" + admin_entreprise_relationships_everything: "Tout" + admin_entreprise_relationships_permits: "autorise" + admin_entreprise_relationships_seach_placeholder: "Chercher" + admin_entreprise_relationships_button_create: "Créer" + admin_entreprise_groups: "Groupes d'entreprises" + admin_entreprise_groups_name: "Nom" + admin_entreprise_groups_owner: "Gérant" + admin_entreprise_groups_on_front_page: "Sur la page d'accueil?" + admin_entreprise_groups_entreprise: "Entreprises" + admin_entreprise_groups_data_powertip: "L'utilisateur principal en charge de ce groupe." + admin_entreprise_groups_data_powertip_logo: "Il s'agit du logo du groupe" + admin_entreprise_groups_data_powertip_promo_image: "Cette image est affichée en haut du profil Groupe." + admin_entreprise_groups_contact: "Contact" + admin_entreprise_groups_contact_phone_placeholder: "ex: 98 7654 3210" + admin_entreprise_groups_contact_address1_placeholder: "ex: 24 rue de la croix verte" + admin_entreprise_groups_contact_city: "Ville" + admin_entreprise_groups_contact_city_placeholder: "ex: Bordeaux" + admin_entreprise_groups_contact_zipcode: "Code postal" + admin_entreprise_groups_contact_zipcode_placeholder: "ex: 14120" + admin_entreprise_groups_contact_state_id: "Département" + admin_entreprise_groups_contact_country_id: "Pays" + admin_entreprise_groups_web: "Liens web" + admin_entreprise_groups_web_twitter: "ex: @OpenFoodNet_fr" + admin_entreprise_groups_web_website_placeholder: "ex: www.monepicerieenligne.fr" + admin_order_cycles: "Gérer les cycles de vente" + open: "Ouvre" + close: "Ferme" + create: "Créer" + search: "Rechercher" + supplier: "Fournisseurs" + product_name: "Nom du Produit" + product_description: "Description du Produit" + units: "Unité de mesure" + coordinator: "Coordinateur" + distributor: "Distributeur" + enterprise_fees: "Marges et commissions" + process_my_order: "Valider ma Commande" + delivery_instructions: Instructions de Livraison + delivery_method: Méthode de Livraison + fee_type: "Type de marge" + tax_category: "TVA applicable" + calculator: "Calculateur" + calculator_values: "Valeurs applicables" + flat_percent_per_item: "Pourcentage net" + flat_rate_per_item: "Montant fixe par article (hors articles au poids/volume)" + flat_rate_per_order: "Montant fixe par commande" + flexible_rate: "Montant variable selon nb articles" + price_sack: "Montant variable selon total commande" + new_order_cycles: "Nouveaux cycles de vente" + new_order_cycle: "Nouveau Cycle de Vente" + select_a_coordinator_for_your_order_cycle: "Choisissez un coordinateur pour votre cycle de vente" + notify_producers: 'Notifier les producteurs' + edit_order_cycle: "Modifier le cycle de vente" + roles: "Roles" + update: "Mettre à jour" + delete: Supprimer + add_producer_property: "Ajouter une propriété" + in_progress: "En cours" + started_at: "Commencé à" + queued: "En attente" + scheduled_for: "Prévu pour" + customers: "Acheteurs" + please_select_hub: "Veuillez sélectionner un Hub" + loading_customers: "Chargement de la liste des acheteurs" + no_customers_found: "Aucun acheteur trouvé" + go: "Lancer" + hub: "Hub" + producer: "Producteur" + product: "Produit" + price: "Prix" + on_hand: "En stock" + save_changes: "Sauvegarder les modifications" + order_saved: "Commande Sauvegardée" + no_products: Pas de Produits + spree_admin_overview_enterprises_header: "Mes entreprises" + spree_admin_overview_enterprises_footer: "GÉRER MES ENTREPRISES" + spree_admin_enterprises_hubs_name: "Nom" + spree_admin_enterprises_create_new: "CRÉER NOUVELLE" + spree_admin_enterprises_shipping_methods: "Méthodes de livraison" + spree_admin_enterprises_fees: "Marges et commissions" + spree_admin_enterprises_none_create_a_new_enterprise: "CRÉER UNE NOUVELLE ENTREPRISE" + spree_admin_enterprises_none_text: "Vous n'avez pas encore d'entreprise" + spree_admin_enterprises_tabs_hubs: "HUBS" + spree_admin_enterprises_producers_manage_products: "GÉRER LES PRODUITS" + spree_admin_enterprises_any_active_products_text: "Vous n'avez aucun produit actif." + spree_admin_enterprises_create_new_product: "CRÉER UN NOUVEAU PRODUIT" + spree_admin_single_enterprise_alert_mail_confirmation: "Veuillez confirmer l'adresse mail pour" + spree_admin_single_enterprise_alert_mail_sent: "Email envoyé à " + spree_admin_overview_action_required: "Action requise" + spree_admin_overview_check_your_inbox: "Veuillez vérifier votre boîte mail pour les prochaines étapes. Merci!" + spree_admin_unit_value: Nb Unités + spree_admin_unit_description: 'Description complémentaire (ex: "(vrac)")' + spree_admin_variant_unit: Unité + spree_admin_variant_unit_scale: Echelle unitaire (en g ou L) + spree_admin_supplier: Fournisseur + spree_admin_product_category: Catégorie Produit + spree_admin_variant_unit_name: Nom de la pièce (si vendu à la pièce) + change_package: "Changer de type de compte" + spree_admin_single_enterprise_hint: "Astuce: Pour permettre aux gens de vous trouver, activez votre visibilité " + spree_admin_eg_pickup_from_school: "ex : \"Retrait des produits à l'Ecole Marimati / Au Café du coin / chez Babette / ...\"" + spree_admin_eg_collect_your_order: "ex : \"Veuillez récupérer votre commande au 123 Parliament Street, Toronto, Ontario 3070 \"" + spree_classification_primary_taxon_error: "L'intitulé %{taxon}est l'intitulé de base pour %{product} et ne peut être supprimé" + spree_order_availability_error: "Le distributeur ne peut fournir les produits de votre panier pour ce cycle de vente." + spree_order_populator_error: "Le distributeur ne peut fournir tous les produits de votre panier pour ce cycle de vente. Merci de choisir un autre distributeur ou un autre cycle de vente." + spree_order_populator_availability_error: "Ce produit n'est pas disponible pour ce cycle de vente / distributeur." + spree_distributors_error: "Veuillez sélectionner au moins un hub" + spree_user_enterprise_limit_error: "^ %{email} ne peut pas créer de nouvelles entreprises (limite actuelle : %{enterprise_limit} entreprises )." + spree_variant_product_error: doit avoir au moins une variante + your_profil_live: "Votre profil en ligne" + on_ofn_map: "sur la carte Open Food Network" + see: "Voir" + live: "en ligne" + manage: "Gérer" + resend: "Renvoyer" + trial: Découverte + add_and_manage_products: "Ajouter & gérer des produits" + add_and_manage_order_cycles: "Ajouter & gérer des cycles de vente" + manage_order_cycles: "Gérer les cycles de vente" + manage_products: "Gérer les produits" + edit_profile_details: "Modifier les informations du profil" + edit_profile_details_etc: "Modifier la description, les images, etc." + order_cycle: "Cycle de vente" + order_cycles: "Cycles de Vente" + enterprises: "Entreprises" + enterprise_relationships: "Permissions inter-entreprises" + remove_tax: "Retirer TVA" + enterprise_terms_of_service: "Conditions Générales d'Utilisation" + enterprises_require_tos: "Les entreprises doivent accepter les Conditions Générales d'Utilisation" + enterprise_tos_link: "Lien vers les Conditions Générales d'Utilisation" + enterprise_tos_message: "Nous soutenons la mise en place d'un système alimentaire résilient et durable, et souhaitons œuvrer avec des entreprises qui partagent nos valeurs et notre vision. Ainsi, nous demandons aux entreprises s'enregistrant sur Open Food Network de valider nos " + enterprise_tos_link_text: "Conditions d'utilisation" + enterprise_tos_agree: "J'adhère aux valeurs d'Open Food Network et valide les Conditions Générales d'Utilisation." + tax_settings: "Paramètres TVA" + products_require_tax_category: "vous devez choisir la TVA applicable" + admin_shared_address_1: "Adresse" + admin_shared_address_2: "Adresse (suite)" + admin_share_city: "Ville" + admin_share_zipcode: "Code postal" + admin_share_country: "Pays" + admin_share_state: "Département" + hub_sidebar_hubs: "Hubs" + hub_sidebar_none_available: "Aucun disponible" + hub_sidebar_manage: "Gérer" + hub_sidebar_at_least: "Sélectionnez un/des hubs" + hub_sidebar_blue: "bleu" + hub_sidebar_red: "rouge" + shop_trial_in_progress: "Votre période de test se termine dans %{days}." + report_customers_distributor: "Distributeur" + report_customers_supplier: "Fournisseurs" + report_customers_cycle: "Cycle de vente" + report_customers_type: "Type de rapport" + report_customers_csv: "Télécharger en csv" + report_producers: "Producteurs:" + report_type: "Type de rapport: " + report_hubs: "Hubs:" + report_payment: "Méthodes de paiement:" + report_distributor: "Distributeurs:" + report_payment_by: 'Paiements par type' + report_itemised_payment: 'Détail du paiement' + report_payment_totals: 'Total des paiements' + report_all: 'tous' + report_order_cycle: "Cycle de vente:" + report_entreprises: "Entreprises:" + report_users: "Utilisateurs" + report_tax_rates: TVA par taux + report_tax_types: TVA par type de produit/service + report_header_order_cycle: Cycle de Vente + report_header_user: Utilisateur + report_header_email: Email + report_header_status: Statut + report_header_comments: Commentaire + report_header_first_name: Prénom + report_header_last_name: Nom + report_header_phone: n° tel + report_header_suburb: Ville + report_header_address: Adresse + report_header_billing_address: Adresse de facturation + report_header_relationship: Droits + report_header_hub: Hub + report_header_hub_address: Adresse du Hub + report_header_to_hub: Distributeur + report_header_hub_code: Code du Hub + report_header_code: Code + report_header_paid: Payé ? + report_header_delivery: Livré ? + report_header_shipping: Livraison + report_header_shipping_method: Méthode de Livraison + report_header_shipping_instructions: Instructions de Livraison + report_header_ship_street: Rue Livraison + report_header_ship_street_2: ' Rue (2) Livraison' + report_header_ship_city: Ville Livraison + report_header_ship_postcode: Code Postal Livraison + report_header_ship_state: Département Livraison + report_header_billing_street: Rue Facturation + report_header_billing_street_2: Rue (2) Facturation + report_header_billing_street_3: Rue (3) Facturation + report_header_billing_street_4: Rue (4) Facturation + report_header_billing_city: Ville Facturation + report_header_billing_postcode: Code Postal Facturation + report_header_billing_state: Département Facturation + report_header_incoming_transport: Transport réception + report_header_special_instructions: Note au producteur + report_header_order_number: N° commande + report_header_date: Date + report_header_confirmation_date: Date de confirmation + report_header_tags: Tags + report_header_items: Produits + report_header_items_total: "Montant total des produits %{currency_symbol}" + report_header_taxable_items_total: "Montant produits soumis à TVA (%{currency_symbol})" + report_header_sales_tax: "TVA sur produits (%{currency_symbol})" + report_header_delivery_charge: "Frais de livraison (%{currency_symbol})" + report_header_tax_on_delivery: "TVA sur livraison (%{currency_symbol})" + report_header_tax_on_fees: "TVA sur commission hub (%{currency_symbol})" + report_header_total_tax: "Total TVA (%{currency_symbol})" + report_header_enterprise: Entreprise + report_header_customer: Acheteur + report_header_customer_code: Code acheteur + report_header_product: Produit + report_header_product_properties: Propriétés / labels Produits + report_header_quantity: Nb commandé + report_header_max_quantity: Quantité Max + report_header_variant: Variante + report_header_variant_value: Nb Unités Variante + report_header_variant_unit: Unité + report_header_total_available: Total disponible + report_header_unallocated: Non alloué + report_header_max_quantity_excess: Dépassement Qté Max + report_header_taxons: Intitulés + report_header_supplier: Fournisseur + report_header_producer: Producteur + report_header_producer_suburb: Ville Producteur + report_header_unit: Unité + report_header_group_buy_unit_quantity: Nb d'unités achetées (vente par lots) + report_header_cost: Coût + report_header_shipping_cost: Coût de livraison + report_header_curr_cost_per_unit: Prix prod unitaire + report_header_total_shipping_cost: Total coût de livraison + report_header_payment_method: Méthode de paiement + report_header_sells: Vend + report_header_visible: Visible + report_header_price: Prix + report_header_unit_size: Unité de mesure + report_header_distributor: Distributeur + report_header_distributor_address: Adresse Hub Distributeur + report_header_distributor_city: Ville Distributeur + report_header_distributor_postcode: Code Postal Distributeur + report_header_delivery_address: Adresse Livraison + report_header_delivery_postcode: Code Postal Livraison + report_header_bulk_unit_size: Quantité totale du lot + report_header_weight: Poids + report_header_sum_total: Somme Totale + report_header_date_of_order: Date de Commande + report_header_amount_owing: Montant dû + report_header_amount_paid: Montant payé + report_header_units_required: Nb Unités Requises + report_header_remainder: Reste à payer + report_header_order_date: Date de commande + report_header_order_id: N° Commande + report_header_item_name: Nom de la pièce + report_header_temp_controlled_items: Article à température contrôlée ? + report_header_customer_name: Nom Acheteur + report_header_customer_email: E-mail Acheteur + report_header_customer_phone: Tel Acheteur + report_header_customer_city: Ville Acheteur + report_header_payment_state: Statut du Paiement + report_header_payment_type: Type de Paiement + report_header_item_price: "Coût produits (%{currency})" + report_header_item_fees_price: "Coût produits + Marge (%{currency})" + report_header_admin_handling_fees: "Admin et gestion (%{currency})" + report_header_ship_price: "Frais de livraison (%{currency})" + report_header_pay_fee_price: "Frais de Transaction (%{currency})" + report_header_total_price: "Total (%{currency})" + report_header_product_total_price: "Total Produit (%{currency})" + report_header_shipping_total_price: "Total Livaison (%{currency})" + report_header_outstanding_balance_price: "Solde (%{currency})" + report_header_eft_price: "TEF / Transfert Electronique (%{currency})" + report_header_paypal_price: "Paypal (%{currency})" + report_header_sku: Référence Produit + report_header_amount: Quantité + report_header_balance: Solde + report_header_total_cost: "Coût Total" + report_header_total_ordered: Total Commandé + report_header_total_max: Max Total + report_header_total_units: Vol. total + report_header_sum_max_total: "Somme Max Total" + report_header_total_excl_vat: "Total HT (%{currency_symbol})" + report_header_total_incl_vat: "Total TTC (%{currency_symbol})" + report_header_temp_controlled: Temp Contrôlée ? + report_header_is_producer: Producteur ? + report_header_not_confirmed: Non confirmé + report_header_gst_on_income: TVA sur revenu + report_header_gst_free_income: Revenu TVA déduite + report_header_total_untaxable_produce: Total produits non taxable + report_header_total_taxable_produce: Total produits soumis à TVA (inclut TVA) + report_header_total_untaxable_fees: Total marges et frais annexes non taxables + report_header_total_taxable_fees: Total marges et frais annexes soumis à TVA (inclut TVA) + report_header_delivery_shipping_cost: Coût de Livraison (incl. TVA) + report_header_transaction_fee: Frais de Transaction (TVA non incluse) + report_header_total_untaxable_admin: Total ajustements non taxables + report_header_total_taxable_admin: Total ajustments soumis à TVA (inclut TVA) + initial_invoice_number: "N° de facture initial:" + invoice_date: "Date de facture:" + due_date: "Date d'échéance:" + account_code: "Code compte:" + equals: "Egal" + contains: "contient" + discount: "Réduction" + filter_products: "Filtrer les produits" + delete_product_variant: "La variante ne peut pas être supprimée!" + progress: "en cours" + saving: "Enregistrement..." + success: "succès" + failure: "échec" + unsaved_changes_confirmation: "Les changements non sauvegardés seront perdus. Continuer?" + one_product_unsaved: "Des changements sur un produit n'ont pas été sauvegardés." + products_unsaved: "Des changements sur %{n} produits n'ont pas été sauvegardés." + is_already_manager: "est déjà manager!" + no_change_to_save: "Pas de changement à sauvegarder" + user_invited: "%{email}a été invité à gérer cette entreprise" + add_manager: "Ajouter un utilisateur existant" + users: "Utilisateurs" + about: "A propos" + images: "Images" + web: "Web" + primary_details: "Informations de base" + adrdress: "Adresse" + contact: "Contact" + social: "Réseaux sociaux" + business_details: "Informations juridiques" + properties: "Propriétés / labels" + shipping: "Expédition" + shipping_methods: "Méthodes de livraison" + payment_methods: "Méthodes de paiement" + payment_method_fee: "Frais de transaction" + inventory_settings: "paramètres catalogue de produits" + tag_rules: "Règles de tag" + shop_preferences: "Préférences boutique" + enterprise_fee_whole_order: Commande totale + enterprise_fee_by: "%{type}marges/frais par %{role} %{enterprise_name}" + validation_msg_relationship_already_established: "^Un lien est déjà établi." + validation_msg_at_least_one_hub: "^Sélectionnez au moins un hub" + validation_msg_product_category_cant_be_blank: "^Veuillez sélectionner la catégorie produit" + validation_msg_tax_category_cant_be_blank: "^Veuillez sélectionner la TVA applicable" + validation_msg_is_associated_with_an_exising_customer: "est associé à un acheteur existant" + content_configuration_pricing_table: "(A FAIRE : Tableau des tarifs)" + content_configuration_case_studies: "(A FAIRE : Etudes de Cas)" + content_configuration_detail: "(A FAIRE : Détails)" + enterprise_name_error: "Est déjà prit. Si c'est votre entreprise et que vous souhaitez revendiquer la propriété, ou si vous souhaitez échanger avec elle, veuillez contacter le manager actuel de ce profil " + enterprise_owner_error: "^ %{email} ne peut pas créer de nouvelles entreprises (limite actuelle : %{enterprise_limit} entreprises )." + enterprise_role_uniqueness_error: "^Ce rôle existe déjà." + inventory_item_visibility_error: doit être vrai ou faux + product_importer_file_error: "erreur : aucun document importé" + product_importer_spreadsheet_error: "impossible de traiter le fichier : type de fichier invalide" + product_importer_products_save_error: n'a pu sauvegarder aucun produit :-( + product_import_file_not_found_notice: 'Fichier non trouvé ou impossible à ouvrir' + product_import_no_data_in_spreadsheet_notice: 'Aucune donnée trouvée dans le tableau' + order_choosing_hub_notice: Votre hub a été sélectionné. + order_cycle_selecting_notice: Votre cycle de vente a été sélectionné. + adjustments_tax_rate_error: "^Veuillez vérifier la TVA applicable pour cet ajustement." + active_distributors_not_ready_for_checkout_message_singular: >- + Le hub %{distributor_names} est sélectionné dans un cycle de vente actif, mais + n'a pas paramétré de méthode de livraison et/ou de paiement. La boutique de + ce hub restera inaccessible jusqu'à ce qu'une méthode de livraison et une méthode + de paiement aient été paramétrées. + active_distributors_not_ready_for_checkout_message_plural: >- + Les hubs %{distributor_names} sont sélectionnés dans un cycle de vente actif, + mais n'ont pas paramétré de méthode de livraison et/ou de paiement. Les boutiques + de ces hubs resteront inaccessibles jusqu'à ce qu'une méthode de livraison et + une méthode de paiement aient été paramétrées. + enterprise_fees_update_notice: Les marges et commissions de votre entreprise ont été mises à jour. + enterprise_fees_destroy_error: "Cette marge ou commission ne peut être supprimée car elle est utilisée par la vente suivante : %{id} - %{name}." + enterprise_register_package_error: "Veuillez choisir une option" + enterprise_register_error: "L'inscription a échoué pour %{enterprise}" + enterprise_register_success_notice: "Bravo ! L'entreprise %{enterprise} est maintenant inscrite sur Open Food Network :-)" + enterprise_bulk_update_success_notice: "Entreprises mises à jour avec succès" + enterprise_bulk_update_error: 'Echec dans la mise à jour' + order_cycles_create_notice: 'Votre cycle de vente a été créé.' + order_cycles_update_notice: 'Votre cycle de vente a été mis à jour.' + order_cycles_bulk_update_notice: 'Des cycles de vente ont été mis à jour.' + order_cycles_clone_notice: "Votre cycle de vente %{name} a été dupliqué." + order_cycles_email_to_producers_notice: 'Les emails à destination des producteurs ont été mis en file d''attente pour envoi.' + order_cycles_no_permission_to_coordinate_error: "Aucune de vos entreprises n'a les droits requis pour coordonner un cycle de vente" + order_cycles_no_permission_to_create_error: "Vous n'avez pas les droits requis pour créer un cycle de vente coordonné par cette entreprise" + back_to_orders_list: "Retour à la liste des commandes" + no_orders_found: "Aucune commande trouvée" + order_information: "Info commande" + date_completed: "Date d'opération" + amount: "Montant" + state_names: + ready: Prêt + pending: En attente + shipped: Expédié + js: + saving: 'Enregistrement en cours...' + changes_saved: 'Modifications sauvegardées.' + save_changes_first: Veuillez d'abord sauvegarder les modifications. + all_changes_saved: Toutes les modifications ont été sauvegardées. + unsaved_changes: Des modifications n'ont pas été sauvegardées + all_changes_saved_successfully: Toutes les modifications ont été sauvegardées avec succès + oh_no: "Oups ! Nous n'avons pas réussi à sauvegarder vos modification :-(" + unauthorized: "Vous n'avez pas les droits d'accès à cette page." + error: Erreur + unavailable: Non disponible + profile: Profil + hub: Hub + shop: Boutique + choose: Choisir + resolve_errors: Veuillez corriger les erreurs suivantes + more_items: "+ %{count} en plus" + default_card_updated: La carte bancaire par défaut a été mise à jour + admin: + enterprise_limit_reached: "Vous avez atteint le nombre limite d'entreprises autorisées par défaut. Ecrivez à %{contact_email}si vous avez besoin d'augmenter cette limite." + modals: + got_it: J'ai compris + close: "Fermer" + invite: "Inviter" + invite_title: "Inviter un nouvel utilisateur" + tag_rule_help: + title: Règles de tag + overview: Aperçu + overview_text: > + Les règles de tag vous permettent de paramétrer ce qui est vu ou pas + par tel ou tel type d'acheteur. Par exemple des options de livraison, + des méthodes de paiement, des produits, ou des cycles de vente. + by_default_rules: "Règles à appliquer \"par défaut\"" + by_default_rules_text: > + Les règles de tag par défaut vous permettent de masquer des éléments + par défaut. Vous pouvez ensuite permettre à certains acheteurs, selon + les tags attribués, de voir ces éléments. + customer_tagged_rules: "Règles pour les acheteur avec un tag" + customer_tagged_rules_text: > + En créant une règle spécifique à un tag, vous pouvez modifier le contenu + vu par défaut (afficher ou masquer) par les acheteurs associés à ce + tag. + panels: + save: Enregistrer + saved: Enregistré + saving: En cours d'enregistrement + enterprise_package: + hub_profile: Profil Hub + hub_profile_cost: "COÛT: CONTRIBUTION LIBRE" + hub_profile_text1: > + Les visiteurs voient votre profil sur la carte, et peuvent vous contacter. + Vous augmentez ainsi votre visibilité. + hub_profile_text2: > + Créez votre profil et utilisez le réseau Open Food Network pour vous + connecter à votre système alimentaire territorial, sera toujours gratuit. + hub_shop: Boutique Hub + hub_shop_text1: > + Vous proposez des produits de différents producteurs de votre région, + artisans, ou distributeurs afin de proposer une offre complète dans + votre boutique. Vous soutenez ainsi le développement de votre système + alimentaire territorial ! + hub_shop_text2: > + Un hub n'a pas de modèle figé, il peut s'agir d'un groupement d'achat, + d'une épicerie coopérative, d'une épicerie locale de quartier ou épicerie + en circuit court en ligne, etc. + hub_shop_text3: > + Si vous produisez et voulez également vendre vos propres produits, vous + devez modifier le statut de votre entreprise, elle doit apparaitre en + tant que "producteur". + choose_package: Choisir le type de compte souhaité + choose_package_text1: > + Votre entreprise ne sera activée et visible que lorsque vous aurez choisi + le type de compte souhaité parmi les options à gauche. + choose_package_text2: > + Cliquez sur une option pour voir le détail du compte proposé, puis une + fois votre choix fait, cliquez sur le bouton rouge ENREGISTRER ! + profile_only: Profil uniquement + profile_only_cost: "COÛT: CONTRIBUTION LIBRE" + profile_only_text1: > + Gagnez en visibilité, racontez votre histoire, et affichez vos coordonnées + pour pouvoir être contactés. + profile_only_text2: > + Si vous souhaitez vous concentrer sur votre activité de production, + et laisser à d'autre le soin de distribuer vos produits, vous n'avez + pas besoin d'une boutique sur Open Food Network. + profile_only_text3: > + Saisissez votre catalogue produits sur Open Food Network, ce qui permettra + aux hubs-distributeurs utilisant la plateforme de les proposer dans + leurs boutiques (sur votre autorisation). + producer_shop: Boutique Producteur + producer_shop_text1: > + Vendez vos produits en direct aux clients finaux. via votre propre Boutique + Producteur sur Open Food Network. + producer_shop_text2: > + Une Boutique Producteur vous permet de vendre uniquement vos propres + produits. Si vous voulez vendre d'autres produits, sélectionnez "Hub + Producteur" + producer_hub: Hub Producteur + producer_hub_text1: > + Vous pouvez vendre non seulement vos produits, mais aussi des produits + d'autres producteurs de votre région, artisans, ou distributeurs afin + de proposer une offre complète dans votre boutique. Vous soutenez ainsi + le développement de votre système alimentaire territorial ! + producer_hub_text2: > + Un hub producteur peut prendre différentes formes, une boutique de vente + directe, un magasin de producteurs en ligne, un drive fermier, etc. + producer_hub_text3: > + Open Food Network soutient tous les modèles de hubs alimentaires, nous + pensons que la résilience du système viendra de la diversité des modèles. + Donc quel que soit votre modèle, nous souhaitons vous apporter les outils + de gestion donc vous avez besoin pour opérer votre circuit court. + get_listing: Référencez votre entreprise + always_free: GRATUIT + sell_produce_others: 'Vendez des produits de multiples fournisseurs ' + sell_own_produce: Vendez vos propres produits + sell_both: Vendez vos produits et ceux d'autres fournisseurs + enterprise_producer: + producer: Producteur + producer_text1: > + Un producteur fabrique de bonnes choses à boire et à manger. Vous êtes + un producteur si vous les faites pousser, les élevez, les pétrissez, + transformez, fermentez, les réduisez en grains, etc. + producer_text2: > + Un producteur peut aussi avoir d'autres rôles, comme par exemple stocker + et distribuer des produits d'autres producteurs à travers une boutique + sur Open Food Network. + non_producer: Non-producteur + non_producer_text1: > + Les entreprises qui ne produisent pas ne peuvent pas créer leur propre + catalogue produits pour les vendre sur Open Food Network. + non_producer_text2: > + Ces entreprises vont plutôt faire le lien entre des producteurs et des + clients finaux, en proposant un modèle opérationnel pour agréger, préparer + les commandes, ou encore livrer les produits. + producer_desc: 'Producteurs:' + producer_example: 'ex: maraichers, boulangers, brasseurs, artisans' + non_producer_desc: Autres entreprises de distribution alimentaire + non_producer_example: 'ex: épiceries, coopératives, groupements d''achats' + enterprise_status: + status_title: "%{name} est en place et prêt à démarrer!" + severity: Rigueur + description: Description + resolve: Résoudre + new_tag_rule_dialog: + select_rule_type: "Choisir le type de règle:" + out_of_stock: + reduced_stock_available: Stock disponible + out_of_stock_text: > + Pendant que vous faisiez vos achats, le niveau de stock disponible pour + un ou plusieurs produits dans votre panier est devenu insuffisant pour répondre + à votre demande. Voilà les modifications opérées: + now_out_of_stock: est maintenant en rupture de stock. + only_n_remainging: "plus que %{num} en stock." + variant_overrides: + inventory_products: "Produits du Catalogue " + hidden_products: "Produits Masqués" + new_products: "Nouveaux Produits" + reset_stock_levels: Réinitialiser les niveaux de stock (par défaut) + changes_to: Devient + one_override: une modification + overrides: modifications + remain_unsaved: n'a pas encore été sauvegardé. + no_changes_to_save: Aucune modification à sauvegarder.' + no_authorisation: "Nous n'avons pas pu sauvegarder ces modifications, elles ne sont donc pas enregistrées." + some_trouble: "Nous n'avons pas pu sauvegarder : %{errors}" + changing_on_hand_stock: Modification des niveaux de stock en cours... + stock_reset: Les niveaux de stock ont été réinitiatlisés (valeurs par défaut) + tag_rules: + show_hide_variants: 'Afficher ou Masquer les variantes dans ma boutique' + show_hide_shipping: 'Afficher ou Montrer les méthodes de livraison lors de la finalisation de commande' + show_hide_payment: 'Afficher ou Montrer les méthodes de paiement lors de la finalisation de commande' + show_hide_order_cycles: 'Afficher ou Masquer les cycles de vente de ma boutique' + visible: VISIBLE + not_visible: INVISIBLE + services: + unsaved_changes_message: Des modifications n'ont pas encore été sauvegardées, sauvegarder maintenant ou ignorer ? + save: SAUVEGARDER + ignore: IGNORER + add_to_order_cycle: "vendre les produits (ajouter au cycle de vente)" + manage_products: "Gérer les produits" + edit_profile: "modifier le profil" + add_products_to_inventory: "ajouter les produits au catalogue de produits" + resources: + could_not_delete_customer: 'L''acheteur n''a pas pu être supprimé' + product_import: + confirmation: | + Cette action remettra tous les niveaux de stock à zero pour cette + entreprises pour les produits non présents dans ce fichier. + order_cycles: + update_success: 'Votre cycle de vente a été mis à jour.' + no_distributors: Il n'y a pas de distributeur pour ce cycle de vente. Il ne sera pas visible aux acheteurs tant qu'il n'y aura pas de distributeur. Voulez-vous tout de même sauvegarder ce cycle de vente ? + enterprises: + producer: "Producteur" + non_producer: "Non-producteur" + customers: + select_shop: 'Veuillez d''abord choisir une boutique' + could_not_create: Oups ! Création impossible... + subscriptions: + closes: ferme + closed: fermé + close_date_not_set: Date de fin non renseignée + producers: + signup: + start_free_profile: "Commencez par créer votre profil entreprise, et changez de formule quand vous êtes prêt !" + spree: + email: Email + account_updated: "Compte mis à jour!" + my_account: "Mon compte" + date: "Date" + time: "Heure" + admin: + orders: + invoice: + issued_on: Editée le + tax_invoice: FACTURE + code: Code + from: De + to: A + form: + distribution_fields: + title: Distribution + distributor: "Distributeur : " + order_cycle: "Cycle de vente : " + overview: + order_cycles: + order_cycles: "Cycles de vente" + order_cycles_tip: "Les cycles de vente définissent quand et où vos produits peuvent être commandés par vos acheteurs." + you_have_active: + zero: "Vous n'avez aucun cycle de vente actif." + one: "Vous avez un cycle de vente actif." + other: "Vous avez %{count} cycles de vente actifs." + manage_order_cycles: "GERER LES CYCLES DE VENTE" + payment_methods: + stripe_connect: + enterprise_select_placeholder: Choisir... + loading_account_information_msg: Informations de compte en cours de chargement depuis Stripe, veuillez patienter... + stripe_disabled_msg: Les paiements via Stripe ont été désactivés par l'administrateur système. + request_failed_msg: Désolé, une erreur est survenue lors de la vérification du compte par Stripe... + account_missing_msg: Aucun compte Stripe n'existe pour cette entreprise. + connect_one: En connecter un + access_revoked_msg: L'accès à ce compte Stripe a été révoqué, veuillez reconnecter votre compte. + status: Statut + connected: Connecté + account_id: Identifiant Compte + business_name: Nom de l'entreprise + charges_enabled: Frais activés + payments: + source_forms: + stripe: + no_payment_via_admin_backend: La création de paiements via Stripe depuis le back office d'administration n'est pas possible pour le moment + products: + new: + title: 'Nouveau Produit' + unit_name_placeholder: 'ex: botte' + bulk_edit: + header: + title: Gestion du catalogue produits + indicators: + title: CHARGEMENT DES PRODUITS + no_products: "Aucun produit trouvé. Ajouter un produit ?" + no_results: "Désolé, aucun résultat trouvé" + products_head: + name: Produit/Variante + unit: Unité + display_as: Unité affichéé + category: Catégorie + tax_category: TVA applicable + inherits_properties?: Hériter des propriétés producteur? + available_on: Disponible via + av_on: "Disp. via" + import_date: "Date d'import" + products_variant: + variant_has_n_overrides: "Cette variante a été modifiée %{n} fois dans des catalogues boutiques" + new_variant: "Nouvelle variante" + product_name: Nom du Produit + primary_taxon_form: + product_category: Catégorie Produit + group_buy_form: + group_buy: "Achat groupé de lots fixes ?" + bulk_unit_size: Quantité totale du lot + display_as: + display_as: Unité affichéé + reports: + table: + select_and_search: "Sélectionner les filtres et cliquez sur RECHERCHER pour accéder aux données." + bulk_coop: + bulk_coop_supplier_report: 'Achats groupés - Totaux par Producteur' + bulk_coop_allocation: 'Achats groupés - Allocation' + bulk_coop_packing_sheets: 'Achats groupés - Feuilles de préparation des paniers' + bulk_coop_customer_payments: 'Achats groupés - Paiement des acheteurs' + shared: + configuration_menu: + stripe_connect: Stripe Connect + variants: + autocomplete: + producer_name: Producteur + checkout: + payment: + stripe: + choose_one: En choisir un + enter_new_card: Entrer les informations pour la nouvelle carte + used_saved_card: "Utiliser une carte sauvegardée :" + or_enter_new_card: "Ou entrez les informations pour utiliser une nouvelle carte :" + remember_this_card: Se souvenir de cette carte ? + date_picker: + format: '%Y-%m-%d' + js_format: 'yy-mm-dd' + inventory: Catalogue de produits + orders: + bought: + item: "Déjà commandé dans ce cycle de vente" + order_mailer: + invoice_email: + hi: "Bonjour %{name}" + invoice_attached_text: 'Veuillez trouver ci-joint la facture pour votre récente commande auprès de ' + order_state: + address: adresse + adjustments: ajustements + awaiting_return: attente du retour + canceled: annulé + cart: panier + complete: terminer + confirm: confirmer + delivery: livraison + paused: mis en pause + payment: paiement + pending: en attente + resumed: recommencé + returned: retourné + skrill: cash + subscription_state: + active: actif + pending: en attente + ended: terminé + paused: mis en pause + canceled: annulé + payment_states: + balance_due: solde dû + completed: effectué + checkout: passer commande + credit_owed: crédit acheteur + failed: échec + paid: payé + pending: en attente + processing: en traitement + void: faire un avoir + invalid: invalide + shipment_states: + backorder: réapprovisionnement + partial: partiel + pending: en attente + ready: prêt + shipped: envoyé + user_mailer: + reset_password_instructions: + request_sent_text: | + Votre demande de nouveau mot de passe a bien été prise en compte. + Si vous n'avez pas demandé de nouveau mot de passe, veuillez ignorer cet e-mail. + link_text: > + Si vous êtes bien à l'origine de cette demande, veuillez cliquer sur le + lien ci-dessous : + issue_text: | + Si le lien ne fonctionne pas, essayez de le copier - coller dans la barre d'adresse de votre navigateur. + Si le problème persiste, n'hésitez pas à nous contacter. + confirmation_instructions: + subject: Veuillez confirmer votre compte + weight: Poids (au kg) + zipcode: Code postal + users: + form: + account_settings: Paramètres du Compte + show: + tabs: + orders: Commandes + cards: Cartes bancaires + transactions: Achats + settings: Paramètres du Compte + unconfirmed_email: "Attente de validation pour l'email: %{unconfirmed_email}. Votre adresse email sera mise à jour quand le nouvel email aura été confirmé." + orders: + open_orders: Commandes Ouvertes + past_orders: Commandes Passées + transactions: + transaction_history: Historique des Transactions + open_orders: + order: Commander + shop: Faire mes courses + changes_allowed_until: Modifications permises jusqu'à + items: Pièce + total: Total + edit: Modifier + cancel: Annuler + closed: Fermée + until: Jusqu'à + past_orders: + order: Commande + shop: Boutique + completed_at: 'Passée à :' + items: Produits + total: Total + paid?: Payé ? + view: Afficher + saved_cards: + default?: Par default? + delete?: Effacer? + localized_number: + invalid_format: n'est pas un format valide. Veuillez entrer un nombre. From d1019fcc5ed6d4f7cb67524e5899f230dd60a259 Mon Sep 17 00:00:00 2001 From: luisramos0 Date: Tue, 12 Jun 2018 15:40:56 +0100 Subject: [PATCH 184/206] changed the country and state selectors to angular on enterprises admin (new and edit screens) so that states are dynamically updated when country is changed --- .../controllers/country_controller.js.coffee | 25 +++++++++++++++++++ .../api/admin/enterprise_serializer.rb | 1 + .../admin/enterprises/_form_data.html.haml | 1 + .../admin/enterprises/_new_form.html.haml | 20 ++++++++------- .../admin/enterprises/form/_address.html.haml | 9 ++++--- app/views/admin/enterprises/new.html.haml | 4 +++ spec/features/admin/enterprises_spec.rb | 4 ++- 7 files changed, 50 insertions(+), 14 deletions(-) create mode 100644 app/assets/javascripts/admin/enterprises/controllers/country_controller.js.coffee diff --git a/app/assets/javascripts/admin/enterprises/controllers/country_controller.js.coffee b/app/assets/javascripts/admin/enterprises/controllers/country_controller.js.coffee new file mode 100644 index 0000000000..0f11642afb --- /dev/null +++ b/app/assets/javascripts/admin/enterprises/controllers/country_controller.js.coffee @@ -0,0 +1,25 @@ +angular.module("admin.enterprises").controller 'countryCtrl', ($scope, availableCountries) -> + + $scope.countries = availableCountries + + $scope.countriesById = $scope.countries.reduce (obj, country) -> + obj[country.id] = country + obj + , {} + + $scope.countryOnChange = (stateSelectElemId) -> + $scope.clearState() unless $scope.addressStateMatchesCountry() + $scope.refreshStateSelector(stateSelectElemId) + + $scope.clearState = -> + $scope.enterprise_address_attributes_state = {} + + $scope.addressStateMatchesCountry = -> + $scope.countriesById[$scope.enterprise_address_attributes_country.id].states.some (state) -> state.id == $scope.enterprise_address_attributes_state?.id + + $scope.refreshStateSelector = (stateSelectElemId) -> + # workaround select2 (using jQuery and setTimeout) to force a refresh of the selected value + setTimeout -> + selectedState = jQuery('#' + stateSelectElemId) + jQuery('#' + stateSelectElemId).select2("val", selectedState) + , 500 \ No newline at end of file diff --git a/app/serializers/api/admin/enterprise_serializer.rb b/app/serializers/api/admin/enterprise_serializer.rb index 2429ce21a9..7597011fde 100644 --- a/app/serializers/api/admin/enterprise_serializer.rb +++ b/app/serializers/api/admin/enterprise_serializer.rb @@ -5,6 +5,7 @@ class Api::Admin::EnterpriseSerializer < ActiveModel::Serializer attributes :preferred_product_selection_from_inventory_only attributes :owner, :contact, :users, :tag_groups, :default_tag_group attributes :require_login, :allow_guest_orders, :allow_order_changes + attributes :address has_one :owner, serializer: Api::Admin::UserSerializer has_many :users, serializer: Api::Admin::UserSerializer diff --git a/app/views/admin/enterprises/_form_data.html.haml b/app/views/admin/enterprises/_form_data.html.haml index ee3d17d4aa..14f83b79bf 100644 --- a/app/views/admin/enterprises/_form_data.html.haml +++ b/app/views/admin/enterprises/_form_data.html.haml @@ -3,3 +3,4 @@ = admin_inject_payment_methods = admin_inject_shipping_methods = admin_inject_enterprise_permissions += admin_inject_available_countries(module: 'admin.enterprises') diff --git a/app/views/admin/enterprises/_new_form.html.haml b/app/views/admin/enterprises/_new_form.html.haml index 9d25fbff7a..353b89a126 100644 --- a/app/views/admin/enterprises/_new_form.html.haml +++ b/app/views/admin/enterprises/_new_form.html.haml @@ -87,15 +87,17 @@ = af.text_field :city, { placeholder: t(:city_placeholder)} .five.columns.omega = af.text_field :zipcode, { placeholder: t(:postcode_placeholder)} - .row - .three.columns.alpha - = af.label :state_id, t(:state) - \/ - = af.label :country_id, t(:country) - .four.columns - = af.collection_select :state_id, af.object.country.states, :id, :name, {}, :class => "select2 fullwidth" - .five.columns.omega - = af.collection_select :country_id, available_countries, :id, :name, {}, :class => "select2 fullwidth" + %div{ "ng-controller" => "countryCtrl" } + .row + .three.columns.alpha + = af.label :state_id, t(:state) + \/ + = af.label :country_id, t(:country) + .four.columns + = af.collection_select :state_id, {}, :id, :name, {}, :class => "select2 fullwidth", "ng-options" => "s as s.name for s in countriesById[enterprise_address_attributes_country.id].states track by s.id", "ng-model" => "enterprise_address_attributes_state" + .five.columns.omega + = af.collection_select :country_id, {}, :id, :name, {}, :class => "select2 fullwidth", "ng-options" => "c as c.name for c in countries track by c.id", "ng-model" => "enterprise_address_attributes_country", "ng-init" => "enterprise_address_attributes_country = { id: \"#{Spree::Config[:default_country_id]}\" }", "ng-change" => "countryOnChange('s2id_enterprise_address_attributes_state_id')" + .row .twelve.columns.alpha   diff --git a/app/views/admin/enterprises/form/_address.html.haml b/app/views/admin/enterprises/form/_address.html.haml index a1485d485f..2f30ca3599 100644 --- a/app/views/admin/enterprises/form/_address.html.haml +++ b/app/views/admin/enterprises/form/_address.html.haml @@ -31,7 +31,8 @@ \/ = af.label :country_id, t(:country) %span.required * - .four.columns - = af.collection_select :state_id, af.object.country.states, :id, :name, {}, :class => "select2 fullwidth" - .four.columns.omega - = af.collection_select :country_id, available_countries, :id, :name, {}, :class => "select2 fullwidth" + %div{ "ng-controller" => "countryCtrl" } + .four.columns + = af.collection_select :state_id, {}, :id, :name, {}, :class => "select2 fullwidth", "ng-init" => "enterprise_address_attributes_state.id = Enterprise.address.address.state_id", "ng-options" => "s as s.name for s in countriesById[enterprise_address_attributes_country.id || Enterprise.address.address.country_id].states track by s.id", "ng-model" => "enterprise_address_attributes_state" + .four.columns.omega + = af.collection_select :country_id, {}, :id, :name, {}, :class => "select2 fullwidth", "ng-init" => "enterprise_address_attributes_country.id = Enterprise.address.address.country_id", "ng-options" => "c as c.name for c in countries track by c.id", "ng-model" => "enterprise_address_attributes_country", "ng-change" => "countryOnChange('s2id_enterprise_address_attributes_state_id')" \ No newline at end of file diff --git a/app/views/admin/enterprises/new.html.haml b/app/views/admin/enterprises/new.html.haml index 4676d117fb..53a148fc74 100644 --- a/app/views/admin/enterprises/new.html.haml +++ b/app/views/admin/enterprises/new.html.haml @@ -6,6 +6,10 @@ - content_for :page_actions do %li= button_link_to t('.back_link'), main_app.admin_enterprises_path, icon: 'icon-arrow-left' +- content_for :app_wrapper_attrs do + = "ng-app='admin.enterprises'" + += admin_inject_available_countries(module: 'admin.enterprises') -# Form diff --git a/spec/features/admin/enterprises_spec.rb b/spec/features/admin/enterprises_spec.rb index 6ed07f5bd3..1a35aebffe 100644 --- a/spec/features/admin/enterprises_spec.rb +++ b/spec/features/admin/enterprises_spec.rb @@ -53,7 +53,7 @@ feature %q{ fill_in 'enterprise_address_attributes_address1', :with => '35 Ballantyne St' fill_in 'enterprise_address_attributes_city', :with => 'Thornbury' fill_in 'enterprise_address_attributes_zipcode', :with => '3072' - select2_search 'Australia', :from => 'Country' + # default country (Australia in this test) should be selected by default select2_search 'Victoria', :from => 'State' click_button 'Create' @@ -342,6 +342,8 @@ feature %q{ fill_in 'enterprise_address_attributes_address1', with: 'z' fill_in 'enterprise_address_attributes_city', with: 'z' fill_in 'enterprise_address_attributes_zipcode', with: 'z' + select2_select 'Australia', from: 'enterprise_address_attributes_country_id' + select2_select 'Victoria', from: 'enterprise_address_attributes_state_id' end scenario "without violating rules" do From dabef16606c3d7a268115a0137549af27342be07 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Thu, 14 Jun 2018 12:35:03 +1000 Subject: [PATCH 185/206] Use ofn-select2 directive for country and state selectors on enterprise forms --- .../controllers/country_controller.js.coffee | 17 +++++------------ .../new_enterprise_controller.js.coffee | 5 +++++ .../api/admin/enterprise_serializer.rb | 2 +- app/views/admin/enterprises/_new_form.html.haml | 4 ++-- .../admin/enterprises/form/_address.html.haml | 4 ++-- app/views/admin/enterprises/new.html.haml | 3 ++- 6 files changed, 17 insertions(+), 18 deletions(-) create mode 100644 app/assets/javascripts/admin/enterprises/controllers/new_enterprise_controller.js.coffee diff --git a/app/assets/javascripts/admin/enterprises/controllers/country_controller.js.coffee b/app/assets/javascripts/admin/enterprises/controllers/country_controller.js.coffee index 0f11642afb..085a992a36 100644 --- a/app/assets/javascripts/admin/enterprises/controllers/country_controller.js.coffee +++ b/app/assets/javascripts/admin/enterprises/controllers/country_controller.js.coffee @@ -1,5 +1,5 @@ +# 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) -> @@ -7,19 +7,12 @@ angular.module("admin.enterprises").controller 'countryCtrl', ($scope, available obj , {} - $scope.countryOnChange = (stateSelectElemId) -> + $scope.$watch 'Enterprise.address.country_id', (newID, oldID) -> $scope.clearState() unless $scope.addressStateMatchesCountry() - $scope.refreshStateSelector(stateSelectElemId) $scope.clearState = -> - $scope.enterprise_address_attributes_state = {} + $scope.Enterprise.address.state_id = null $scope.addressStateMatchesCountry = -> - $scope.countriesById[$scope.enterprise_address_attributes_country.id].states.some (state) -> state.id == $scope.enterprise_address_attributes_state?.id - - $scope.refreshStateSelector = (stateSelectElemId) -> - # workaround select2 (using jQuery and setTimeout) to force a refresh of the selected value - setTimeout -> - selectedState = jQuery('#' + stateSelectElemId) - jQuery('#' + stateSelectElemId).select2("val", selectedState) - , 500 \ No newline at end of file + $scope.countriesById[$scope.Enterprise.address.country_id].states.some (state) -> + state.id == $scope.Enterprise.address.state_id diff --git a/app/assets/javascripts/admin/enterprises/controllers/new_enterprise_controller.js.coffee b/app/assets/javascripts/admin/enterprises/controllers/new_enterprise_controller.js.coffee new file mode 100644 index 0000000000..06677f5184 --- /dev/null +++ b/app/assets/javascripts/admin/enterprises/controllers/new_enterprise_controller.js.coffee @@ -0,0 +1,5 @@ +angular.module("admin.enterprises").controller 'NewEnterpriseController', ($scope, defaultCountryID) -> + $scope.Enterprise = + address: + country_id: defaultCountryID + state_id: null diff --git a/app/serializers/api/admin/enterprise_serializer.rb b/app/serializers/api/admin/enterprise_serializer.rb index 7597011fde..e29954992f 100644 --- a/app/serializers/api/admin/enterprise_serializer.rb +++ b/app/serializers/api/admin/enterprise_serializer.rb @@ -5,10 +5,10 @@ class Api::Admin::EnterpriseSerializer < ActiveModel::Serializer attributes :preferred_product_selection_from_inventory_only attributes :owner, :contact, :users, :tag_groups, :default_tag_group attributes :require_login, :allow_guest_orders, :allow_order_changes - attributes :address has_one :owner, serializer: Api::Admin::UserSerializer has_many :users, serializer: Api::Admin::UserSerializer + has_one :address, serializer: Api::AddressSerializer def tag_groups object.tag_rules.prioritised.reject(&:is_default).each_with_object([]) do |tag_rule, tag_groups| diff --git a/app/views/admin/enterprises/_new_form.html.haml b/app/views/admin/enterprises/_new_form.html.haml index 353b89a126..84102ce63b 100644 --- a/app/views/admin/enterprises/_new_form.html.haml +++ b/app/views/admin/enterprises/_new_form.html.haml @@ -94,9 +94,9 @@ \/ = af.label :country_id, t(:country) .four.columns - = af.collection_select :state_id, {}, :id, :name, {}, :class => "select2 fullwidth", "ng-options" => "s as s.name for s in countriesById[enterprise_address_attributes_country.id].states track by s.id", "ng-model" => "enterprise_address_attributes_state" + %input.ofn-select2.fullwidth#enterprise_address_attributes_state_id{ name: 'enterprise[address_attributes][state_id]', type: 'number', data: 'countriesById[Enterprise.address.country_id].states', placeholder: t('admin.choose'), ng: { model: 'Enterprise.address.state_id' } } .five.columns.omega - = af.collection_select :country_id, {}, :id, :name, {}, :class => "select2 fullwidth", "ng-options" => "c as c.name for c in countries track by c.id", "ng-model" => "enterprise_address_attributes_country", "ng-init" => "enterprise_address_attributes_country = { id: \"#{Spree::Config[:default_country_id]}\" }", "ng-change" => "countryOnChange('s2id_enterprise_address_attributes_state_id')" + %input.ofn-select2.fullwidth#enterprise_address_attributes_country_id{ name: 'enterprise[address_attributes][country_id]', type: 'number', data: 'countries', placeholder: t('admin.choose'), ng: { model: 'Enterprise.address.country_id' } } .row .twelve.columns.alpha diff --git a/app/views/admin/enterprises/form/_address.html.haml b/app/views/admin/enterprises/form/_address.html.haml index 2f30ca3599..cb278df5e0 100644 --- a/app/views/admin/enterprises/form/_address.html.haml +++ b/app/views/admin/enterprises/form/_address.html.haml @@ -33,6 +33,6 @@ %span.required * %div{ "ng-controller" => "countryCtrl" } .four.columns - = af.collection_select :state_id, {}, :id, :name, {}, :class => "select2 fullwidth", "ng-init" => "enterprise_address_attributes_state.id = Enterprise.address.address.state_id", "ng-options" => "s as s.name for s in countriesById[enterprise_address_attributes_country.id || Enterprise.address.address.country_id].states track by s.id", "ng-model" => "enterprise_address_attributes_state" + %input.ofn-select2.fullwidth#enterprise_address_attributes_state_id{ name: 'enterprise[address_attributes][state_id]', type: 'number', data: 'countriesById[Enterprise.address.country_id].states', placeholder: t('admin.choose'), ng: { model: 'Enterprise.address.state_id' } } .four.columns.omega - = af.collection_select :country_id, {}, :id, :name, {}, :class => "select2 fullwidth", "ng-init" => "enterprise_address_attributes_country.id = Enterprise.address.address.country_id", "ng-options" => "c as c.name for c in countries track by c.id", "ng-model" => "enterprise_address_attributes_country", "ng-change" => "countryOnChange('s2id_enterprise_address_attributes_state_id')" \ No newline at end of file + %input.ofn-select2.fullwidth#enterprise_address_attributes_country_id{ name: 'enterprise[address_attributes][country_id]', type: 'number', data: 'countries', placeholder: t('admin.choose'), ng: { model: 'Enterprise.address.country_id' } } diff --git a/app/views/admin/enterprises/new.html.haml b/app/views/admin/enterprises/new.html.haml index 53a148fc74..dd830054f5 100644 --- a/app/views/admin/enterprises/new.html.haml +++ b/app/views/admin/enterprises/new.html.haml @@ -10,10 +10,11 @@ = "ng-app='admin.enterprises'" = admin_inject_available_countries(module: 'admin.enterprises') += admin_inject_json "admin.enterprises", "defaultCountryID", Spree::Config[:default_country_id] -# Form = form_for [main_app, :admin, @enterprise], html: { "nav-check" => '', "nav-callback" => '' } do |f| .row - .twelve.columns.fullwidth_inputs{ ng: { app: "admin.users" } } + .twelve.columns.fullwidth_inputs{ ng: { controller: "NewEnterpriseController" } } = render 'new_form', f: f From 55e619af8debffa33cf53923bcf65c6d48dd8922 Mon Sep 17 00:00:00 2001 From: Transifex-Openfoodnetwork Date: Tue, 19 Jun 2018 10:59:37 +1000 Subject: [PATCH 186/206] Updating translations for config/locales/de_DE.yml --- config/locales/de_DE.yml | 274 ++++++++++++++++++++++++++------------- 1 file changed, 184 insertions(+), 90 deletions(-) diff --git a/config/locales/de_DE.yml b/config/locales/de_DE.yml index 299c4fe89a..2d9e8ac0e8 100644 --- a/config/locales/de_DE.yml +++ b/config/locales/de_DE.yml @@ -10,14 +10,20 @@ de_DE: email: E-Mail des Kunden spree/payment: amount: Menge + order_cycle: + orders_close_at: Schlussdatum errors: models: spree/user: attributes: email: - taken: "Es gibt bereits einen Account für diese Email-Adresse. Bitte versuche Dich einzuloggen oder setze Dein Passwort zurück." + taken: "Es gibt bereits ein Konto für diese E-Mail-Adresse. Bitte versuchen Sie sich einzuloggen oder setzen Sie Ihr Passwort zurück." spree/order: no_card: Es sind keine gültigen Kreditkarten verfügbar + order_cycle: + attributes: + orders_close_at: + after_orders_open_at: muss nach dem Öffnungsdatum sein activemodel: errors: models: @@ -25,21 +31,21 @@ de_DE: attributes: subscription_line_items: at_least_one_product: "^ Bitte fügen Sie mindestens ein Produkt hinzu" - not_available: "^ %{name} ist im ausgewählten seitplan nicht verfügbar" + not_available: "^ %{name} ist im ausgewählten Plan nicht verfügbar" ends_at: - after_begins_at: "Muss danach sein beginnt um" + after_begins_at: "Muss nach \"beginnt um\" sein " customer: does_not_belong_to_shop: "gehört nicht zu %{shop}" schedule: not_coordinated_by_shop: "wird nicht von %{shop} koordiniert" payment_method: not_available_to_shop: "ist nicht verfügbar für %{shop}" - invalid_type: "muss eine Cash- oder Stripe-Methode sein" + invalid_type: "muss eine Bar- oder Stripe-Methode sein" shipping_method: not_available_to_shop: "ist nicht verfügbar für %{shop}" credit_card: not_available: "ist nicht verfügbar" - blank: "Wird benötigt" + blank: "wird benötigt" devise: confirmations: send_instructions: "Sie erhalten in einigen Minuten eine E-Mail mit Anweisungen zur Bestätigung Ihres Kontos." @@ -53,11 +59,15 @@ de_DE: failure: invalid: | Ungültige E-Mail-Adresse oder Passwort. - Hast du als Gast bestellt? Vielleicht brauchst du ein neues Konto oder musst dein Passwort zurücksetzen. + Haben Sie als Gast bestellt? Vielleicht brauchen Sie ein neues Konto oder müssen Ihr Passwort zurücksetzen. unconfirmed: "Sie müssen Ihr Konto bestätigen, bevor Sie fortfahren." + already_registered: "Diese E-Mail ist bereits registriert. Bitte loggen Sie sich ein oder verwenden Sie eine andere E-Mail-Adresse." + user_passwords: + spree_user: + updated_not_active: "Ihr Passwort wurde zurückgesetzt, aber ihre E-Mail muss noch bestätigt werden." enterprise_mailer: confirmation_instructions: - subject: "Bitte bestätige die E-Mail-Adresse für %{enterprise}" + subject: "Bitte bestätigen Sie die E-Mail-Adresse für %{enterprise}" welcome: subject: "%{enterprise} ist jetzt auf %{sitename}" invite_manager: @@ -69,13 +79,13 @@ de_DE: placement_summary_email: subject: Eine Zusammenfassung der kürzlich aufgegebenen Abonnementbestellungen greeting: "Hallo %{name}," - intro: "Im Folgenden finden Sie eine Zusammenfassung der Abonnementaufträge, die gerade für %{shop} erteilt wurden." + intro: "Im Folgenden finden Sie eine Zusammenfassung der Abonnementbestellungen, die gerade für %{shop} erteilt wurden." confirmation_summary_email: subject: Eine Zusammenfassung der kürzlich bestätigten Abonnementbestellungen greeting: "Hallo %{name}," - intro: "Im Folgenden finden Sie eine Zusammenfassung der Abonnementaufträge, die gerade für %{shop} abgeschlossen wurden." + intro: "Im Folgenden finden Sie eine Zusammenfassung der Abonnementbestellungen, die gerade für %{shop} abgeschlossen wurden." summary_overview: - total: Insgesamt wurden die Abonnements %{count} für die automatische Verarbeitung markiert. + total: Insgesamt wurden %{count} Abonnements für die automatische Verarbeitung markiert. success_zero: Von diesen wurde keine erfolgreich verarbeitet. success_some: Von diesen wurde %{count} erfolgreich verarbeitet. success_all: Alle wurden erfolgreich verarbeitet. @@ -87,7 +97,7 @@ de_DE: explainer: Diese Bestellungen wurden bearbeitet, aber für einige angeforderte Artikel war nicht genügend Lagerbestand verfügbar empty: title: Keine Lagerbestellung (%{count} Bestellungen) - explainer: Diese Bestellungen konnten nicht bearbeitet werden, da für angeforderte Artikel kein Bestand verfügbar war + explainer: Diese Bestellungen konnten nicht bearbeitet werden, da für die angeforderten Artikel kein Bestand verfügbar war complete: title: Bereits verarbeitet (%{count} Bestellungen) explainer: Diese Bestellungen wurden bereits als vollständig markiert und daher nicht verändert @@ -106,10 +116,10 @@ de_DE: site_meta_description: "Wir starten von Grund auf, in dem die Landwirte und Landwirtinnen sowie die Erzeuger und Erzeugerinnen Ihre Geschichten voll Stolz und wahrhaftig erzählen können. Und indem die Verteilenden uns Konsumierende gerecht und ehrlich Zugang zu den Produkten bieten können. Mit Konsumierenden, die glauben, dass dies die bessere Entscheidung für den Wocheneinkauf darstellen kann." search_by_name: Suche nach Name oder Ort... producers_join: 'Wir laden Deutsche Produzenten ein, jetzt dem Open Food Network beizutreten. ' - charges_sales_tax: Gebühren GST? + charges_sales_tax: Berechnet Steuern? print_invoice: "Rechnung drucken" print_ticket: "Ticket drucken" - select_ticket_printer: "Wähle einen Drucker für die Tickets" + select_ticket_printer: "Wählen Sie einen Drucker für die Tickets" send_invoice: "Rechnung senden" resend_confirmation: "Bestätigung erneut senden" view_order: "Bestellung zeigen" @@ -143,13 +153,13 @@ de_DE: loading: Laden... show_more: Mehr anzeigen show_all: Alles anzeigen - show_all_with_more: "Alles anzeigen (%{num} More)" + show_all_with_more: "Alles anzeigen (%{num} mehr)" cancel: Abbrechen edit: Bearbeite clone: Klonen distributors: Verteilende distribution: Verteilung - bulk_order_management: Sammelbestellung + bulk_order_management: Massenbearbeitung von Bestellungen enterprise_groups: Gruppen reports: Berichte variant_overrides: Inventar @@ -157,12 +167,12 @@ de_DE: all: Alle current: Aktuell available: Verfügbar - dashboard: Instrumententafel + dashboard: Übersicht undefined: undefiniert unused: ungebraucht admin_and_handling: Admin & Handhabung profile: Profil - supplier_only: Nur für Versorgende + supplier_only: Nur Anbieter weight: Gewicht volume: Menge items: Artikel @@ -185,19 +195,19 @@ de_DE: pick_up: Abholen copy: Kopieren actions: - create_and_add_another: "Erstellen und Hinzufügen eines anderen" + create_and_add_another: "Erstellen und weitere hinzufügen" admin: begins_at: Beginnt um - begins_on: Beginnt an + begins_on: Beginnt am customer: Kunde date: Datum - email: 'E-Mail:' - ends_at: Endet am + email: E-Mail + ends_at: Endet um ends_on: Endet am name: Name on_hand: verfügbar on_demand: Auf Anfrage - on_demand?: Auf Anfrage? + on_demand?: Unbegrenzt? order_cycle: Bestellungszyklus payment: Zahlung payment_method: Bezahlverfahren @@ -207,7 +217,7 @@ de_DE: image: Bild product: Produkt quantity: Menge - schedule: Zeitplan + schedule: Plan shipping: Versand shipping_method: Versandart shop: Shop @@ -227,7 +237,10 @@ de_DE: form_invalid: "Das Formular beinhaltet fehlende oder ungültige Felder" clear_filters: Filter löschen clear: klar - show_more: Zeig mehr + save: Speichern + cancel: Abrechen + back: Zurück + show_more: Mehr zeigen show_n_more: Zeige %{num} mehr choose: "Wählen..." please_select: Bitte auswählen... @@ -238,7 +251,7 @@ de_DE: whats_this: Was ist das? tag_has_rules: "Vorhandene Regeln für dieses Tag: %{num}" has_one_rule: "hat eine Regel" - has_n_rules: "hat %{num} Regel" + has_n_rules: "hat %{num} Regel(n)" unsaved_confirm_leave: "Es gibt ungespeicherte Änderungen auf dieser Seite. Möchten Sie ohne Speichern fortfahren?" unsaved_changes: "Sie haben ungespeicherte Änderungen" accounts_and_billing_settings: @@ -246,25 +259,25 @@ de_DE: default_accounts_payment_method: "Zahlungsart des Standardkontos" default_accounts_shipping_method: "Lieferart des Standardkontos" edit: - accounts_and_billing: "Konto und Rechung" + accounts_and_billing: "Konten und Abrechung" accounts_administration_distributor: "Kontenadministrator" admin_settings: "Einstellungen" update_invoice: "Rechnungen aktualisieren" auto_update_invoices: "Rechnungen automatisch jede Nacht um 1:00 Uhr aktualisieren" finalise_invoice: "Rechnungen fertigstellen" auto_finalise_invoices: "Rechnungen automatisch am 2. jedes Monats um 1.30 Uhr fertigstellen" - manually_run_task: "Task manuell ausführen" - update_user_invoice_explained: "Verwenden Sie diese Schaltfläche, um die Rechnungen für den jeweiligen Monat für jeden Unternehmensbenutzer im System sofort zu aktualisieren. Diese Aufgabe kann so eingerichtet werden, dass sie jede Nacht automatisch ausgeführt wird." + manually_run_task: "Vorgang manuell ausführen" + update_user_invoice_explained: "Verwenden Sie diesen Button, um die Rechnungen für den jeweiligen Monat für jeden Unternehmensbenutzer im System sofort zu aktualisieren. Diese Aufgabe kann so eingerichtet werden, dass sie jede Nacht automatisch ausgeführt wird." finalise_user_invoices: "Benutzerrechnungen fertigstellen" - finalise_user_invoice_explained: "Nütze diesen Button, um alle Rechnungen des vergangenen Kalendermonats in diesem System fertig zu machen." + finalise_user_invoice_explained: "Verwenden Sie diesen Button, um die Rechnungen für den Vormonat zu aktualisieren. Diese Aufgabe kann so eingerichtet werden, dass sie einmal im Monat automatisch abläuft." update_user_invoices: "Benutzerrechungen aktualisieren" errors: - accounts_distributor: Bitte wählen, wenn Du eine Rechnung für Unternehmen erstellen möchtest. - default_payment_method: Bitte wählen, wenn Du eine Rechnung für Unternehmen erstellen möchtest. - default_shipping_method: Bitte wählen, wenn Du eine Rechnung für Unternehmen erstellen möchtest. + accounts_distributor: Muss gewählt werden, wenn Sie Rechnungen für Unternehmen erstellen möchten. + default_payment_method: Muss gewählt werden, wenn Sie Rechnungen für Unternehmen erstellen möchten. + default_shipping_method: Muss gewählt werden, wenn Sie Rechnungen für Unternehmen erstellen möchten. shopfront_settings: embedded_shopfront_settings: "Eingebettete Shopfront-Einstellungen" - enable_embedded_shopfronts: "Aktivieren Sie eingebettete Shopfronts" + enable_embedded_shopfronts: "Eingebettete Shopfronts erlauben" embedded_shopfronts_whitelist: "Externe Domains Whitelist" number_localization: number_localization_settings: "Nummernlokalisierungseinstellungen" @@ -274,23 +287,23 @@ de_DE: business_model_configuration: "Geschäftsmodel" business_model_configuration_tip: "Konfigurieren Sie die Rate, mit der die Geschäfte jeden Monat für die Nutzung des Open Food Network berechnet werden." bill_calculation_settings: "Rechnungsberechnungseinstellungen" - bill_calculation_settings_tip: "Passen Sie den Betrag an, den Unternehmen jeden Monat für die Nutzung des OFN in Rechnung stellen." - shop_trial_length: "Shop Testlänge (Tage)" + bill_calculation_settings_tip: "Passen Sie den Betrag an, der Unternehmen jeden Monat für die Nutzung des OFN in Rechnung gestellt wird." + shop_trial_length: "Laden Testlänge (Tage)" shop_trial_length_tip: "Die Zeitdauer (in Tagen), die Unternehmen, die als Geschäfte eingerichtet sind, als Testphase ausführen können." - fixed_monthly_charge: "Monatliche Fixkosten" - fixed_monthly_charge_tip: "Die monatlichen Fixkosten aller Unternehmen, die als Laden laufen und ein Minimum an rechnungsfähigem Umsatz erreicht haben (falls gemacht)" + fixed_monthly_charge: "feste Monatsgebühren" + fixed_monthly_charge_tip: "Die monatliche Gebühr für Unternehmen, die als Laden laufen und ein Minimum an rechnungsfähigem Umsatz erreicht haben (falls verwendet)." percentage_of_turnover: "Prozentsatz des Umsatzes" - percentage_of_turnover_tip: "Falls die größer als Null" - monthly_cap_excl_tax: "Monatliche Obergrenze (exkl. GST)" - monthly_cap_excl_tax_tip: "Falls er größer als Null ist, wird dieser Wert als Obergrenze für den Betrag, den die Läden jeden Monat zahlen müssen, festgelegt." + percentage_of_turnover_tip: "Falls nicht null, wird dieser Prozentsatz (0.0 - 1.0) dem Umsatz eines Ladens angerechnet und zu jeglichen Festgebühren (links) addiert, um daraus die monatliche Rechnung zu erstellen. " + monthly_cap_excl_tax: "Monatliche Obergrenze (exkl. Steuer)" + monthly_cap_excl_tax_tip: "Falls größer als Null, wird dieser Wert als Obergrenze für den Betrag, den die Läden jeden Monat zahlen müssen, festgelegt." tax_rate: "Steuerrate" tax_rate_tip: "Steuersatz, der für die monatliche Rechnung gilt, die den Unternehmen für die Nutzung des Systems in Rechnung gestellt wird." - minimum_monthly_billable_turnover: "Minimaler monatlicher abrechenbarer Umsatz" - minimum_monthly_billable_turnover_tip: "Der monatliche Mindestumsatz vor einer Ladenfront wird für die Nutzung von OFN berechnet. Unternehmen, die in einem Monat weniger als diesen Betrag abwickeln, werden weder als Prozentsatz noch als Festsatz berechnet." - example_bill_calculator: "Beispiel Rechnungsrechner" + minimum_monthly_billable_turnover: "Monatlicher Mindestumsatz" + minimum_monthly_billable_turnover_tip: "Der monatliche Mindestumsatz unter dem ein Laden nicht berechnet wird. Geschäften die weniger umsetzen wird weder eine Festgebühr noch ein Prozentsatz in Rechnung gestellt. " + example_bill_calculator: "Beispielrechner" example_bill_calculator_legend: "Ändern Sie den Beispielumsatz, um den Effekt der Einstellungen auf der linken Seite zu visualisieren." example_monthly_turnover: "Beispiel monatlicher Umsatz" - example_monthly_turnover_tip: "Ein beispielhafter monatlicher Umsatz für ein Unternehmen, der zum Generieren verwendet wird, berechnet eine beispielhafte monatliche Rechnung unten." + example_monthly_turnover_tip: "Ein beispielhafter monatlicher Umsatz eines Unternehmens, mit dem unten eine Beispiel-Monatsrechnung erstellt wird." cap_reached?: "Limit erreicht ?" cap_reached?_tip: "Ob die Kappe (links angegeben) erreicht wurde, unter Berücksichtigung der Einstellungen und des Umsatzes." included_tax: "inkl. Steuer" @@ -303,27 +316,27 @@ de_DE: new_customer: "Neuer Kunde" customer_placeholder: "Kunde@beispiel.org" valid_email_error: Bitte geben Sie eine gültige E-Mail-Adresse ein - add_a_new_customer_for: Fügen Sie einen neuen Kunden für %{shop_name} hinzu - code: Code - duplicate_code: "Dieser Code wird bereits verwendet." + add_a_new_customer_for: Neuer Kunde für %{shop_name} hinzufügen + code: Kode + duplicate_code: "Dieser Kode wird bereits verwendet." bill_address: "Rechnungsadresse" ship_address: "Lieferadresse" update_address_success: 'Adresse wurde erfolgreich aktualisiert.' - update_address_error: 'Es tut uns leid! Bitte fülle alle erforderlichen Felder aus!' - edit_bill_address: 'Bearbeite die Rechnungsadresse.' - edit_ship_address: 'Bearbeite die Lieferadresse.' + update_address_error: 'Es tut uns leid! Bitte füllen Sie alle erforderlichen Felder aus!' + edit_bill_address: 'Rechnungsadresse bearbeiten' + edit_ship_address: 'Lieferadresse bearbeiten' required_fileds: 'Die erforderlichen Felder sind mit einem Sternchen gekennzeichnet.' - select_country: 'Wähle das Bundesland/Land.' - select_state: 'Wähle das Land/den Staat.' - edit: 'Bearbeite' - update_address: 'Aktualisiere die Adresse.' - confirm_delete: 'Sicher oder löschen?' - search_by_email: "Suche mittels Emailadresse/Code" + select_country: 'Land wählen' + select_state: 'Bundesland wählen' + edit: 'Bearbeiten' + update_address: 'Adresse aktualisieren' + confirm_delete: 'Sicher zu löschen?' + search_by_email: "Suche nach Email/Kode" destroy: - has_associated_orders: 'Löschen fehlgeschlagen: Kunde hat Bestellungen mit seinem Shop verknüpft' + has_associated_orders: 'Löschen fehlgeschlagen: Kunde hat Bestellungen mit diesem Laden' cache_settings: show: - title: Zwischenspeichern + title: Cachen distributor: Verteiler order_cycle: Bestellungszyklus status: Status @@ -335,10 +348,10 @@ de_DE: header: Header home_page: Homepage producer_signup_page: Hersteller-Anmeldeseite - hub_signup_page: Hub Anmeldeseite - group_signup_page: Gruppe Anmeldeseite + hub_signup_page: Hub-Anmeldeseite + group_signup_page: Gruppen-Anmeldeseite footer_and_external_links: Fußzeile und externe Links - your_content: Dein Inhalt + your_content: Ihr Inhalt enterprise_fees: index: title: Unternehmensgebühren @@ -357,29 +370,103 @@ de_DE: enterprise_role: manages: verwaltet products: - unit_name_placeholder: 'z.B. Trauben' + unit_name_placeholder: 'z.B. Bündel' bulk_edit: unit: Einheit - display_as: Darstellen als + display_as: Angezeigt als category: Kategorie tax_category: Steuerkategorie inherits_properties?: Vererbt Eigenschaften? - available_on: Verfügbar auf - av_on: "Ein V. Auf" - upload_an_image: Lade ein Bild hoch + available_on: Verfügbar am + av_on: "Verfüg. am" + import_date: Importiert + upload_an_image: Bild hochladen product_search_keywords: Keywords für die Produktsuche - product_search_tip: Geben Sie Wörter ein, um Ihre Produkte in den Geschäften zu durchsuchen. Verwenden Sie Leerzeichen, um jedes Keyword zu trennen. + product_search_tip: Geben Sie Wörter ein, um Ihre Produkte in den Geschäften zu suchen. Verwenden Sie Leerzeichen, um jedes Keyword zu trennen. SEO_keywords: SEO Schlüsselwörter seo_tip: Geben Sie Wörter ein, um Ihre Produkte im Internet zu durchsuchen. Verwenden Sie Leerzeichen, um jedes Keyword zu trennen. Search: Suche properties: - property_name: Name des Anwesens + property_name: Name der Eigenschaft inherited_property: Vererbte Eigenschaft variants: - to_order_tip: "Artikel, die auf Bestellung hergestellt werden, haben keinen festgelegten Lagerbestand, wie zum Beispiel frisch gebackene Brote." + to_order_tip: "Artikel, die auf Bestellung hergestellt werden, haben keinen festgelegten Lagerbestand." product_distributions: "Produktverteilungen" group_buy_options: "Gruppenkaufoptionen" back_to_products_list: "Zurück zur Produktliste" + product_import: + title: Produkt importieren + file_not_found: Datei nicht gefunden oder konnte nicht geöffnet werden + no_data: Keine Daten in der Tabelle gefunden + confirm_reset: "Dadurch wird der Lagerbestand für alle Produkte auf Null gesetzt\n Unternehmen, die in der hochgeladenen Datei nicht vorhanden sind" + model: + no_file: "Fehler: keine Datei hochgeladen" + could_not_process: "Datei konnte nicht verarbeitet werden: ungültiger Dateityp" + incorrect_value: falscher Wert + conditional_blank: Kann nicht leer sein, wenn unit_type leer ist + no_product: hat keine Produkte in der Datenbank gefunden + not_found: nicht in der Datenbank gefunden + blank: kann nicht leer sein + products_no_permission: Sie sind nicht berechtigt, Produkte für dieses Unternehmen zu verwalten + inventory_no_permission: Sie sind nicht berechtigt, Inventar für diesen Produzenten zu erstellen + none_saved: hat keine Produkte erfolgreich gespeichert + line: Linie + index: + select_file: Wählen Sie eine Tabelle zum Hochladen aus + spreadsheet: Kalkulationstabelle + import_into: "Importieren in:" + product_list: Produktliste + inventories: Vorräte + import: Einführen + upload: Hochladen + import: + review: Rezension + proceed: Vorgehen + save: Speichern + results: Ergebnisse + save_imported: Speichern Sie importierte Produkte + no_valid_entries: Keine gültigen Einträge gefunden + none_to_save: Es gibt keine Einträge, die gespeichert werden können + some_invalid_entries: Importierte Datei enthält einige ungültige Einträge + save_valid?: Gültige Einträge für jetzt speichern und die anderen verwerfen? + no_errors: Keine Fehler gefunden! + save_all_imported?: Alle importierten Produkte speichern? + options_and_defaults: Importoptionen und Standardwerte + no_permission: Sie sind nicht berechtigt, dieses Unternehmen zu verwalten + not_found: Unternehmen konnte nicht in der Datenbank gefunden werden + no_name: Kein Name + blank_supplier: Einige Produkte haben einen leeren Lieferantennamen + reset_absent?: Fehlende Produkte zurücksetzen? + overwrite_all: Alles überschreiben + overwrite_empty: Überschreiben, wenn leer + default_stock: Stellen Sie den Lagerbestand ein + default_tax_cat: Stellen Sie die Steuerkategorie ein + default_shipping_cat: Legen Sie die Versandkategorie fest + default_available_date: Stellen Sie das verfügbare Datum ein + validation_overview: Validierungsübersicht importieren + entries_found: Einträge in der importierten Datei gefunden + entries_with_errors: Artikel enthalten Fehler und werden nicht importiert + products_to_create: Produkte werden erstellt + products_to_update: Produkte werden aktualisiert + inventory_to_create: Inventargegenstände werden erstellt + inventory_to_update: Inventargegenstände werden aktualisiert + products_to_reset: Bestehende Produkte werden auf Null zurückgesetzt + inventory_to_reset: Bestehende Inventarartikel werden auf Null zurückgesetzt + line: Linie + item_line: Artikelzeile + save: + final_results: Importieren Sie die endgültigen Ergebnisse + products_created: Produkte erstellt + products_updated: Produkte aktualisiert + inventory_created: Inventarelemente erstellt + inventory_updated: Inventargegenstände aktualisiert + products_reset: Bei den Produkten wurde der Lagerbestand auf Null zurückgesetzt + inventory_reset: Bei Inventarartikeln wurde der Lagerbestand auf Null zurückgesetzt + all_saved: "Alle Artikel wurden erfolgreich gespeichert" + some_saved: "Elemente wurden erfolgreich gespeichert" + save_errors: Fehler speichern + view_products: Produkte anzeigen + view_inventory: Inventar anzeigen variant_overrides: loading_flash: loading_inventory: LADEN INVENTAR @@ -390,6 +477,7 @@ de_DE: inherit?: Übernehme add: Füge hinzu hide: Verstecke + import_date: Importiert select_a_shop: Wähle einen Laden review_now: Überprüfe jetzt new_products_alert_message: Es sind %{new_product_count} neue Produkte verfügbar, die ins Sortiment mit aufgenommen werden können @@ -419,7 +507,7 @@ de_DE: product_unit: "Produkt: Einheit" weight_volume: "Gewicht / Volumen" ask: "Fragen?" - page_title: "Sammelbestellung" + page_title: "Massenbearbeitung von Bestellungen" actions_delete: "Ausgewählte löschen" loading: "Bestellungen werden geladen" no_results: "Keine Bestellungen gefunden." @@ -553,19 +641,19 @@ de_DE: es wieder geöffnet wird. Dies wird in Ihrem Shop nur angezeigt, wenn Sie keine aktiven Bestellzyklen haben (dh Shop ist geschlossen). shopfront_category_ordering: Shopfront Kategorie Bestellung - open_date: Offenes Datum + open_date: Öffnungsdatum close_date: Abschlussdatum social: twitter_placeholder: z.B. @the_prof stripe_connect: connect_with_stripe: "Verbinde dich mit Streifen" stripe_connect_intro: "Um Zahlungen mit Kreditkarte zu akzeptieren, müssen Sie Ihr Stripe-Konto mit dem Open Food Network verbinden. Verwenden Sie den Knopf rechts, um loszulegen." - stripe_account_connected: "Streifenkonto verbunden." + stripe_account_connected: "Stripe-Konto verbunden." disconnect: "Trennen Sie das Konto" confirm_modal: title: Verbinde dich mit Streifen part1: Stripe ist ein Zahlungsverarbeitungsdienst, der es Geschäften im OFN ermöglicht, Kreditkartenzahlungen von Kunden zu akzeptieren. - part2: Um diese Funktion zu verwenden, müssen Sie Ihr Stripe-Konto mit dem OFN verbinden. Wenn Sie unten auf "Ich stimme zu" klicken, wird die Stripe-Website an Sie weitergeleitet, wo Sie ein bestehendes Stripe-Konto verbinden oder ein neues erstellen können, falls Sie noch kein Konto haben. + part2: Um diese Funktion zu verwenden, müssen Sie Ihr Stripe-Konto mit dem OFN verbinden. Wenn Sie unten auf "Ich stimme zu" klicken, wird die Stripe-Website an Sie weitergeleitet, wo Sie ein bestehendes Stripe-Konto verbinden oder ein neues erstellen können. part3: Dadurch kann das Open Food Network Kreditkartenzahlungen von Kunden in Ihrem Namen akzeptieren. Bitte beachten Sie, dass Sie ein eigenes Stripe-Konto unterhalten müssen, die Gebühren für Stripe-Gebühren bezahlen und etwaige Rückbuchungen und Kundenservice selbst vornehmen müssen. i_agree: Ich stimme zu cancel: Stornieren @@ -653,7 +741,7 @@ de_DE: welcome_title: Willkommen im Open Food Netzwerk! welcome_text: Sie haben erfolgreich eine erstellt next_step: Nächster Schritt - choose_starting_point: 'Wählen Sie Ihren Ausgangspunkt:' + choose_starting_point: 'Wählen Sie Ihr Paket:' invite_manager: user_already_exists: "Benutzer existiert bereits" error: "Etwas ist schief gelaufen" @@ -713,7 +801,7 @@ de_DE: name: Name orders_open: Bestellungen öffnen um coordinator: Koordinator - order_closes: Bestellungen schließen + orders_close: Bestellungen schließen row: suppliers: Lieferanten distributors: Händler @@ -728,6 +816,8 @@ de_DE: destroy_errors: orders_present: Dieser Bestellzyklus wurde von einem Kunden ausgewählt und kann nicht gelöscht werden. Um zu verhindern, dass Kunden darauf zugreifen, schließen Sie es stattdessen. schedule_present: Dieser Bestellzyklus ist mit einem Zeitplan verknüpft und kann nicht gelöscht werden. Bitte heben Sie die Verknüpfung auf oder löschen Sie den Zeitplan zuerst. + bulk_update: + no_data: Hm, etwas ist schief gelaufen. Keine Bestellzyklusdaten gefunden. producer_properties: index: title: Herstellereigenschaften @@ -824,7 +914,7 @@ de_DE: enable_subscriptions_step_1_html: 1. Gehen Sie zur Seite %{enterprises_link}, suchen Sie Ihren Shop und klicken Sie auf "Verwalten" enable_subscriptions_step_2: 2. Aktivieren Sie unter "Shop-Einstellungen" die Option Abonnements set_up_shipping_and_payment_methods_html: Richten Sie die Methoden %{shipping_link} und %{payment_link} ein - set_up_shipping_and_payment_methods_note_html: Beachten Sie, dass nur Cash- und Stripe-Zahlungsmethoden für Abonnements verwendet werden dürfen + set_up_shipping_and_payment_methods_note_html: Beachten Sie, dass nur Bar- und Stripe-Zahlungsmethoden für Abonnements verwendet werden dürfen ensure_at_least_one_customer_html: Stellen Sie sicher, dass mindestens eine %{customer_link} vorhanden ist create_at_least_one_schedule: Erstellen Sie mindestens einen Zeitplan create_at_least_one_schedule_step_1_html: 1. Gehen Sie auf die Seite %{order_cycles_link} @@ -840,7 +930,7 @@ de_DE: details: details: Einzelheiten invalid_error: Hoppla! Bitte füllen Sie alle erforderlichen Felder aus ... - allowed_payment_method_types_tip: Zurzeit können nur Cash- und Stripe-Zahlungsmethoden verwendet werden + allowed_payment_method_types_tip: Zurzeit können nur Bar- und Stripe-Zahlungsmethoden verwendet werden credit_card: Kreditkarte no_cards_available: Keine Karten verfügbar loading_flash: @@ -869,12 +959,15 @@ de_DE: no_subscriptions: Noch keine Abonnements why_dont_you_add_one: Warum fügst du keinen hinzu? :) no_matching_subscriptions: Keine passenden Abonnements gefunden + schedules: + destroy: + associated_subscriptions_error: Dieser Zeitplan kann nicht gelöscht werden, da ihm Subskriptionen zugeordnet sind stripe_connect_settings: edit: title: "Streifen verbinden" settings: "die Einstellungen" stripe_connect_enabled: Shops aktivieren, um Zahlungen über Stripe Connect zu akzeptieren? - no_api_key_msg: Für dieses Unternehmen existiert kein Streifenkonto. + no_api_key_msg: Für dieses Unternehmen existiert kein Stripe-Konto. configuration_explanation_html: Detaillierte Anweisungen zur Konfiguration der Stripe Connect-Integration finden Sie unter konsultieren Sie diese Anleitung . status: Status ok: OK @@ -915,8 +1008,9 @@ de_DE: register: "registrieren" contact: "Kontakt" require_customer_login: "Dieser Shop ist nur für Kunden." - require_login_html: "Bitte %{login}, wenn Sie bereits ein Konto haben. Andernfalls, %{register}, ein Kunde zu werden." + require_login_html: "Bitte %{login}, wenn Sie bereits ein Konto haben. Andernfalls, %{register}, um Kunde zu werden." require_customer_html: "Bitte %{contact} %{enterprise}, um Kunde zu werden." + card_could_not_be_updated: Die Karte konnte nicht aktualisiert werden card_could_not_be_saved: Karte konnte nicht gespeichert werden spree_gateway_error_flash_for_checkout: "Bei den Zahlungsinformationen ist ein Problem aufgetreten: %{error}" invoice_billing_address: "Rechnungsadresse:" @@ -1053,6 +1147,7 @@ de_DE: footer_legal_tos: "Geschäftsbedingungen" footer_legal_visit: "Finden Sie uns auf" footer_legal_text_html: "Open Food Network ist eine freie und Open-Source-Software-Plattform. Unser Inhalt ist mit %{content_license} und unserem Code mit %{code_license} lizenziert." + footer_skylight_dashboard_html: Leistungsdaten sind unter %{dashboard} verfügbar. home_shop: Jetzt einkaufen brandstory_headline: "Essen, ohne eigene Rechtspersönlichkeit." brandstory_intro: "Manchmal ist der beste Weg, das System zu reparieren, ein neues zu starten ..." @@ -1197,7 +1292,9 @@ de_DE: invite_email: greeting: "Hallo!" invited_to_manage: "Sie wurden eingeladen, %{enterprise} auf %{instance} zu verwalten." + confirm_your_email: "Sie sollten eine E-Mail mit einem Bestätigungslink erhalten haben oder in Kürze erhalten. Sie können nicht auf das Profil von %{enterprise} zugreifen, bis Sie Ihre E-Mail-Adresse bestätigt haben." set_a_password: "Sie werden dann aufgefordert, ein Kennwort festzulegen, bevor Sie das Unternehmen verwalten können." + mistakenly_sent: "Nicht sicher, warum Sie diese E-Mail erhalten haben? Bitte kontaktieren Sie %{owner_email} für weitere Informationen." producer_mail_greeting: "Liebe/r" producer_mail_text_before: "Wir haben jetzt alle Verbraucherbestellungen für den nächsten Essenstropfen." producer_mail_order_text: "Hier finden Sie eine Zusammenfassung der Bestellungen für Ihre Produkte:" @@ -1446,6 +1543,7 @@ de_DE: november: "November" december: "Dezember" email_not_found: "Emailadresse wurde nicht gefunden" + email_unconfirmed: "Sie müssen Ihre E-Mail-Adresse bestätigen, bevor Sie Ihr Passwort zurücksetzen können." email_required: "Sie müssen eine E-Mail-Adresse angeben" logging_in: "Moment, wir melden uns an" signup_email: "Deine E-Mail-Adresse" @@ -1697,6 +1795,8 @@ de_DE: calculator: "Rechner" calculator_values: "Rechnerwerte" flat_percent_per_item: "Flache Prozent (pro Artikel)" + flat_rate_per_item: "Pauschale (pro Stück)" + flat_rate_per_order: "Pauschalpreis pro Bestellung)" new_order_cycles: "Neue Bestellzyklen" new_order_cycle: "Neuer Bestellzyklus" select_a_coordinator_for_your_order_cycle: "Wählen Sie einen Koordinator für Ihren Bestellzyklus" @@ -1731,12 +1831,7 @@ de_DE: spree_admin_enterprises_fees: "Unternehmensgebühren" spree_admin_enterprises_none_create_a_new_enterprise: "ERSTELLEN SIE EIN NEUES UNTERNEHMEN" spree_admin_enterprises_none_text: "Sie haben noch keine Unternehmen" - spree_admin_enterprises_producers_name: "Name" - spree_admin_enterprises_producers_total_products: "Produkte insgesamt" - spree_admin_enterprises_producers_active_products: "Aktive Produkte" - spree_admin_enterprises_producers_order_cycles: "Produkte in OCs" spree_admin_enterprises_tabs_hubs: "HUBS" - spree_admin_enterprises_tabs_producers: "ERZEUGER" spree_admin_enterprises_producers_manage_products: "PRODUKTE VERWALTEN" spree_admin_enterprises_any_active_products_text: "Sie haben keine aktiven Produkte." spree_admin_enterprises_create_new_product: "NEUES PRODUKT" @@ -2001,7 +2096,6 @@ de_DE: content_configuration_pricing_table: "(TODO: Preistabelle)" content_configuration_case_studies: "(TODO: Fallstudien)" content_configuration_detail: "(Todo: Detail)" - enterprise_name_error: "wurde bereits genommen. Wenn dies Ihr Unternehmen ist und Sie die Eigentumsrechte beanspruchen möchten, wenden Sie sich bitte an den aktuellen Manager dieses Profils unter %{email}." enterprise_owner_error: "^ %{email} darf keine weiteren Unternehmen besitzen (Limit ist %{enterprise_limit})." enterprise_role_uniqueness_error: "^ Diese Rolle ist bereits vorhanden." inventory_item_visibility_error: muss wahr oder falsch sein @@ -2274,10 +2368,10 @@ de_DE: payment_methods: stripe_connect: enterprise_select_placeholder: Wählen... - loading_account_information_msg: Kontoinformationen von Stripe laden, bitte warten ... + loading_account_information_msg: Kontoinformationen von Stripe werden geladen, bitte warten ... stripe_disabled_msg: Streifenzahlungen wurden vom Systemadministrator deaktiviert. request_failed_msg: Es tut uns leid. Beim Versuch, Kontodaten mit Stripe zu überprüfen, ist ein Fehler aufgetreten. - account_missing_msg: Für dieses Unternehmen existiert kein Streifenkonto. + account_missing_msg: Für dieses Unternehmen existiert kein Stripe-Konto. connect_one: Verbinde eins access_revoked_msg: Der Zugriff auf dieses Stripe-Konto wurde widerrufen. Bitte verbinden Sie Ihr Konto erneut. status: Status @@ -2398,18 +2492,18 @@ de_DE: issue_text: | Falls die URL unten nicht funktionieren sollte, versuche die Adresse mit Hilfe von "copy and paste" in dein Browserfenster zu übertragen. confirmation_instructions: - subject: Bitte bestätigen Sie Ihren OFN-Account + subject: Bitte bestätigen Sie Ihr OFN-Konto weight: Gewicht (pro kg) zipcode: Postleitzahl users: form: - account_settings: Account Einstellungen + account_settings: Konto Einstellungen show: tabs: orders: Aufträge cards: Kreditkarten transactions: Transaktionen - settings: Account Einstellungen + settings: Konto Einstellungen unconfirmed_email: "Ausstehende E-Mail-Bestätigung für: %{unconfirmed_email}. Ihre E-Mail-Adresse wird aktualisiert, sobald die neue E-Mail bestätigt wurde." orders: open_orders: Offene Bestellungen From 8324b009996674291340a5632eeafcf72539a83a Mon Sep 17 00:00:00 2001 From: Frank West Date: Fri, 8 Jun 2018 07:55:39 -0700 Subject: [PATCH 187/206] Renames product bulk edit action to index When a user hit cancel while editing a product it took them to the spree products index page instead of the bulk edit page. The button was part of a shared view for all resources so changing it's actions were not readily available. It was suggested that instead of carrying our own separate controller action we could just override the index action of the products controller with the bulk edit functionality instead. This has the advantage of removing some overrides and allows us to not add additional overrides in the future. --- .../admin/modals/image_upload.html.haml | 2 +- .../admin/products_controller_decorator.rb | 22 ++---- app/models/spree/ability_decorator.rb | 2 +- ...turn_to_bulk_product_edit.html.haml.deface | 3 - .../edit/return_to_products.html.haml.deface | 3 + .../new/replace_form.html.haml.deface | 2 +- .../replace_products_tab.html.haml.deface | 3 - .../product_import/_save_results.html.haml | 2 +- app/views/admin/product_import/save.html.haml | 2 +- .../spree/admin/overview/_products.html.haml | 2 +- .../single_enterprise_dashboard.html.haml | 2 +- .../spree/admin/products/bulk_edit.html.haml | 10 --- .../spree/admin/products/index.html.haml | 10 +++ .../{bulk_edit => index}/_actions.html.haml | 0 .../{bulk_edit => index}/_data.html.haml | 0 .../{bulk_edit => index}/_filters.html.haml | 0 .../{bulk_edit => index}/_header.html.haml | 0 .../_indicators.html.haml | 0 .../{bulk_edit => index}/_products.html.haml | 8 +- .../_products_head.html.haml | 0 .../_products_product.html.haml | 0 .../_products_variant.html.haml | 2 +- .../_save_button_row.html.haml | 0 app/views/spree/admin/shared/_tabs.html.erb | 2 +- config/locales/de_DE.yml | 4 +- config/locales/en.yml | 4 +- config/locales/en_GB.yml | 4 +- config/locales/en_US.yml | 4 +- config/locales/es.yml | 4 +- config/locales/fr.yml | 4 +- config/locales/nb.yml | 4 +- config/locales/pt-BR.yml | 2 +- config/locales/pt.yml | 4 +- config/locales/sv.yml | 2 +- config/routes.rb | 1 - .../column_preference_defaults.rb | 4 +- .../spree/admin/products_controller_spec.rb | 4 +- .../admin/bulk_product_update_spec.rb | 74 +++++++++---------- spec/features/admin/enterprise_user_spec.rb | 20 ----- spec/features/admin/products_spec.rb | 4 +- spec/models/spree/ability_spec.rb | 6 +- 41 files changed, 96 insertions(+), 130 deletions(-) delete mode 100644 app/overrides/spree/admin/products/edit/return_to_bulk_product_edit.html.haml.deface create mode 100644 app/overrides/spree/admin/products/edit/return_to_products.html.haml.deface delete mode 100644 app/overrides/spree/admin/shared/_product_sub_menu/replace_products_tab.html.haml.deface delete mode 100644 app/views/spree/admin/products/bulk_edit.html.haml create mode 100644 app/views/spree/admin/products/index.html.haml rename app/views/spree/admin/products/{bulk_edit => index}/_actions.html.haml (100%) rename app/views/spree/admin/products/{bulk_edit => index}/_data.html.haml (100%) rename app/views/spree/admin/products/{bulk_edit => index}/_filters.html.haml (100%) rename app/views/spree/admin/products/{bulk_edit => index}/_header.html.haml (100%) rename app/views/spree/admin/products/{bulk_edit => index}/_indicators.html.haml (100%) rename app/views/spree/admin/products/{bulk_edit => index}/_products.html.haml (75%) rename app/views/spree/admin/products/{bulk_edit => index}/_products_head.html.haml (100%) rename app/views/spree/admin/products/{bulk_edit => index}/_products_product.html.haml (100%) rename app/views/spree/admin/products/{bulk_edit => index}/_products_variant.html.haml (95%) rename app/views/spree/admin/products/{bulk_edit => index}/_save_button_row.html.haml (100%) diff --git a/app/assets/javascripts/templates/admin/modals/image_upload.html.haml b/app/assets/javascripts/templates/admin/modals/image_upload.html.haml index e5c0f55541..f864bbee5f 100644 --- a/app/assets/javascripts/templates/admin/modals/image_upload.html.haml +++ b/app/assets/javascripts/templates/admin/modals/image_upload.html.haml @@ -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'} {{ 'admin.products.bulk_edit.upload_an_image' | t }} + %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"} diff --git a/app/controllers/spree/admin/products_controller_decorator.rb b/app/controllers/spree/admin/products_controller_decorator.rb index d2f715712e..3ee88fc18d 100644 --- a/app/controllers/spree/admin/products_controller_decorator.rb +++ b/app/controllers/spree/admin/products_controller_decorator.rb @@ -6,8 +6,9 @@ Spree::Admin::ProductsController.class_eval do include OrderCyclesHelper include EnterprisesHelper - before_filter :load_form_data, :only => [:bulk_edit, :new, :create, :edit, :update] - before_filter :load_spree_api_key, :only => [:bulk_edit, :variant_overrides] + 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: { @@ -15,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 { @@ -25,7 +26,7 @@ Spree::Admin::ProductsController.class_eval do def product_distributions end - def bulk_edit + def index @current_user = spree_current_user @show_latest_import = params[:latest_import] || false end @@ -56,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. @@ -93,7 +83,7 @@ Spree::Admin::ProductsController.class_eval do end def collection_actions - [:index, :bulk_edit, :bulk_update] + [:index, :bulk_update] end diff --git a/app/models/spree/ability_decorator.rb b/app/models/spree/ability_decorator.rb index 5834a51afe..385b9be032 100644 --- a/app/models/spree/ability_decorator.rb +++ b/app/models/spree/ability_decorator.rb @@ -134,7 +134,7 @@ class AbilityDecorator 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 diff --git a/app/overrides/spree/admin/products/edit/return_to_bulk_product_edit.html.haml.deface b/app/overrides/spree/admin/products/edit/return_to_bulk_product_edit.html.haml.deface deleted file mode 100644 index 8557143ae0..0000000000 --- a/app/overrides/spree/admin/products/edit/return_to_bulk_product_edit.html.haml.deface +++ /dev/null @@ -1,3 +0,0 @@ -/ replace "code[erb-loud]:contains('button_link_to t(:back_to_products_list)')" - -= button_link_to t('admin.products.back_to_products_list'), bulk_edit_admin_products_path, :icon => 'icon-arrow-left' \ No newline at end of file diff --git a/app/overrides/spree/admin/products/edit/return_to_products.html.haml.deface b/app/overrides/spree/admin/products/edit/return_to_products.html.haml.deface new file mode 100644 index 0000000000..1865179b4c --- /dev/null +++ b/app/overrides/spree/admin/products/edit/return_to_products.html.haml.deface @@ -0,0 +1,3 @@ +/ replace "code[erb-loud]:contains('button_link_to t(:back_to_products_list)')" + += button_link_to t('admin.products.back_to_products_list'), admin_products_path, :icon => 'icon-arrow-left' diff --git a/app/overrides/spree/admin/products/new/replace_form.html.haml.deface b/app/overrides/spree/admin/products/new/replace_form.html.haml.deface index efcd0f1d3d..538ca95fbf 100644 --- a/app/overrides/spree/admin/products/new/replace_form.html.haml.deface +++ b/app/overrides/spree/admin/products/new/replace_form.html.haml.deface @@ -90,7 +90,7 @@ = button t('actions.create_and_add_another'), 'icon-repeat', :submit, value: 'add_another' %span.or = t(:or) - = link_to_with_icon 'icon-remove', t('actions.cancel'), bulk_edit_admin_products_path, :class => 'button' + = link_to_with_icon 'icon-remove', t('actions.cancel'), admin_products_path, :class => 'button' :javascript diff --git a/app/overrides/spree/admin/shared/_product_sub_menu/replace_products_tab.html.haml.deface b/app/overrides/spree/admin/shared/_product_sub_menu/replace_products_tab.html.haml.deface deleted file mode 100644 index ec97ae63fe..0000000000 --- a/app/overrides/spree/admin/shared/_product_sub_menu/replace_products_tab.html.haml.deface +++ /dev/null @@ -1,3 +0,0 @@ -/ replace "code[erb-loud]:contains('tab :products')" - -= tab :products, url: bulk_edit_admin_products_path, :match_path => '/products' \ No newline at end of file diff --git a/app/views/admin/product_import/_save_results.html.haml b/app/views/admin/product_import/_save_results.html.haml index 82101b8a91..9ab9652ac1 100644 --- a/app/views/admin/product_import/_save_results.html.haml +++ b/app/views/admin/product_import/_save_results.html.haml @@ -56,7 +56,7 @@ %a.button.view{href: main_app.admin_inventory_path, ng: {show: 'updates.inventory_created > 0 || updates.inventory_updated > 0'}} = t('admin.product_import.save.view_inventory') - %a.button.view{href: bulk_edit_admin_products_path + '?latest_import=true', ng: {show: 'updates.products_created > 0 || updates.products_updated > 0'}} + %a.button.view{href: admin_products_path + '?latest_import=true', ng: {show: 'updates.products_created > 0 || updates.products_updated > 0'}} = t('admin.product_import.save.view_products') %a.button{href: main_app.admin_product_import_path} diff --git a/app/views/admin/product_import/save.html.haml b/app/views/admin/product_import/save.html.haml index 23a1d13d99..4d88bef979 100644 --- a/app/views/admin/product_import/save.html.haml +++ b/app/views/admin/product_import/save.html.haml @@ -58,6 +58,6 @@ - if @import_into == 'inventories' %a.button{href: main_app.admin_inventory_path}= t('admin.product_import.save.view_inventory') - else - %a.button{href: bulk_edit_admin_products_path + '?latest_import=true'}= t('admin.product_import.save.view_products') + %a.button{href: admin_products_path + '?latest_import=true'}= t('admin.product_import.save.view_products') %a.button{href: main_app.admin_product_import_path}= t('admin.back') diff --git a/app/views/spree/admin/overview/_products.html.haml b/app/views/spree/admin/overview/_products.html.haml index c017cd11f9..37de2f11d4 100644 --- a/app/views/spree/admin/overview/_products.html.haml +++ b/app/views/spree/admin/overview/_products.html.haml @@ -15,7 +15,7 @@ = "You have #{@product_count} active product#{@product_count > 1 ? "s" : ""}." %span.one.column.omega %span.icon-ok-sign - %a.seven.columns.alpha.button.bottom.blue{ href: "#{bulk_edit_admin_products_path}" } + %a.seven.columns.alpha.button.bottom.blue{ href: "#{admin_products_path}" } = t "spree_admin_enterprises_producers_manage_products" %span.icon-arrow-right - else diff --git a/app/views/spree/admin/overview/single_enterprise_dashboard.html.haml b/app/views/spree/admin/overview/single_enterprise_dashboard.html.haml index dd7322b5b9..dac3df1b8f 100644 --- a/app/views/spree/admin/overview/single_enterprise_dashboard.html.haml +++ b/app/views/spree/admin/overview/single_enterprise_dashboard.html.haml @@ -72,7 +72,7 @@ %span.icon-th-large = t "add_and_manage_products" .list - %a.button.bottom{href: bulk_edit_admin_products_path} + %a.button.bottom{href: admin_products_path} = t "manage_products" %span.icon-arrow-right diff --git a/app/views/spree/admin/products/bulk_edit.html.haml b/app/views/spree/admin/products/bulk_edit.html.haml deleted file mode 100644 index 4e6cf8c704..0000000000 --- a/app/views/spree/admin/products/bulk_edit.html.haml +++ /dev/null @@ -1,10 +0,0 @@ -= render 'spree/admin/products/bulk_edit/header' -= render 'spree/admin/products/bulk_edit/data' - -%div{ ng: { app: 'ofn.admin', controller: 'AdminProductEditCtrl', init: 'initialise()' } } - - = render 'spree/admin/products/bulk_edit/filters' - %hr.divider.sixteen.columns.alpha.omega - = render 'spree/admin/products/bulk_edit/actions' - = render 'spree/admin/products/bulk_edit/indicators' - = render 'spree/admin/products/bulk_edit/products' diff --git a/app/views/spree/admin/products/index.html.haml b/app/views/spree/admin/products/index.html.haml new file mode 100644 index 0000000000..50927a1ff9 --- /dev/null +++ b/app/views/spree/admin/products/index.html.haml @@ -0,0 +1,10 @@ += render 'spree/admin/products/index/header' += render 'spree/admin/products/index/data' + +%div{ ng: { app: 'ofn.admin', controller: 'AdminProductEditCtrl', init: 'initialise()' } } + + = render 'spree/admin/products/index/filters' + %hr.divider.sixteen.columns.alpha.omega + = render 'spree/admin/products/index/actions' + = render 'spree/admin/products/index/indicators' + = render 'spree/admin/products/index/products' diff --git a/app/views/spree/admin/products/bulk_edit/_actions.html.haml b/app/views/spree/admin/products/index/_actions.html.haml similarity index 100% rename from app/views/spree/admin/products/bulk_edit/_actions.html.haml rename to app/views/spree/admin/products/index/_actions.html.haml diff --git a/app/views/spree/admin/products/bulk_edit/_data.html.haml b/app/views/spree/admin/products/index/_data.html.haml similarity index 100% rename from app/views/spree/admin/products/bulk_edit/_data.html.haml rename to app/views/spree/admin/products/index/_data.html.haml diff --git a/app/views/spree/admin/products/bulk_edit/_filters.html.haml b/app/views/spree/admin/products/index/_filters.html.haml similarity index 100% rename from app/views/spree/admin/products/bulk_edit/_filters.html.haml rename to app/views/spree/admin/products/index/_filters.html.haml diff --git a/app/views/spree/admin/products/bulk_edit/_header.html.haml b/app/views/spree/admin/products/index/_header.html.haml similarity index 100% rename from app/views/spree/admin/products/bulk_edit/_header.html.haml rename to app/views/spree/admin/products/index/_header.html.haml diff --git a/app/views/spree/admin/products/bulk_edit/_indicators.html.haml b/app/views/spree/admin/products/index/_indicators.html.haml similarity index 100% rename from app/views/spree/admin/products/bulk_edit/_indicators.html.haml rename to app/views/spree/admin/products/index/_indicators.html.haml diff --git a/app/views/spree/admin/products/bulk_edit/_products.html.haml b/app/views/spree/admin/products/index/_products.html.haml similarity index 75% rename from app/views/spree/admin/products/bulk_edit/_products.html.haml rename to app/views/spree/admin/products/index/_products.html.haml index f4193ca883..c122896e48 100644 --- a/app/views/spree/admin/products/bulk_edit/_products.html.haml +++ b/app/views/spree/admin/products/index/_products.html.haml @@ -2,13 +2,13 @@ %form{ name: 'bulk_product_form' } %save-bar{ dirty: "bulk_product_form.$dirty", persist: "false" } %input.red{ type: "button", value: t(:save_changes), ng: { click: "submitProducts()", disabled: "!bulk_product_form.$dirty" } } - %input{ type: "button", value: t(:close), 'ng-click' => "cancel('#{bulk_edit_admin_products_path}')" } + %input{ type: "button", value: t(:close), 'ng-click' => "cancel('#{admin_products_path}')" } %table.index#listing_products.bulk{ "infinite-scroll" => "incrementLimit()", "infinite-scroll-distance" => "1" } - = render 'spree/admin/products/bulk_edit/products_head' + = render 'spree/admin/products/index/products_head' %tbody{ 'ng-repeat' => 'product in filteredProducts = ( products | filter:query | producer: producerFilter | category: categoryFilter | importDate: importDateFilter | limitTo:limit )', 'ng-class-even' => "'even'", 'ng-class-odd' => "'odd'" } - = render 'spree/admin/products/bulk_edit/products_product' - = render 'spree/admin/products/bulk_edit/products_variant' + = render 'spree/admin/products/index/products_product' + = render 'spree/admin/products/index/products_variant' diff --git a/app/views/spree/admin/products/bulk_edit/_products_head.html.haml b/app/views/spree/admin/products/index/_products_head.html.haml similarity index 100% rename from app/views/spree/admin/products/bulk_edit/_products_head.html.haml rename to app/views/spree/admin/products/index/_products_head.html.haml diff --git a/app/views/spree/admin/products/bulk_edit/_products_product.html.haml b/app/views/spree/admin/products/index/_products_product.html.haml similarity index 100% rename from app/views/spree/admin/products/bulk_edit/_products_product.html.haml rename to app/views/spree/admin/products/index/_products_product.html.haml diff --git a/app/views/spree/admin/products/bulk_edit/_products_variant.html.haml b/app/views/spree/admin/products/index/_products_variant.html.haml similarity index 95% rename from app/views/spree/admin/products/bulk_edit/_products_variant.html.haml rename to app/views/spree/admin/products/index/_products_variant.html.haml index 4cfcf7f8ae..c4c0412eef 100644 --- a/app/views/spree/admin/products/bulk_edit/_products_variant.html.haml +++ b/app/views/spree/admin/products/index/_products_variant.html.haml @@ -28,6 +28,6 @@ %td.actions %a{ 'ng-click' => 'editWarn(product,variant)', :class => "edit-variant icon-edit no-text", 'ng-show' => "variantSaved(variant)", 'ofn-with-tip' => t(:edit) } %td.actions - %span.icon-warning-sign{ 'ng-if' => 'variant.variant_overrides', 'ofn-with-tip' => "{{ 'spree.admin.products.bulk_edit.products_variant.variant_has_n_overrides' | t:{n: variant.variant_overrides.length} }}" } + %span.icon-warning-sign{ 'ng-if' => 'variant.variant_overrides', 'ofn-with-tip' => "{{ 'spree.admin.products.index.products_variant.variant_has_n_overrides' | t:{n: variant.variant_overrides.length} }}" } %td.actions %a{ 'ng-click' => 'deleteVariant(product,variant)', "ng-class" => '{disabled: product.variants.length < 2}', :class => "delete-variant icon-trash no-text", 'ofn-with-tip' => t(:remove) } diff --git a/app/views/spree/admin/products/bulk_edit/_save_button_row.html.haml b/app/views/spree/admin/products/index/_save_button_row.html.haml similarity index 100% rename from app/views/spree/admin/products/bulk_edit/_save_button_row.html.haml rename to app/views/spree/admin/products/index/_save_button_row.html.haml diff --git a/app/views/spree/admin/shared/_tabs.html.erb b/app/views/spree/admin/shared/_tabs.html.erb index 2228d4ac7b..eb76d8b98f 100644 --- a/app/views/spree/admin/shared/_tabs.html.erb +++ b/app/views/spree/admin/shared/_tabs.html.erb @@ -1,5 +1,5 @@ <%= tab :dashboard, :route => :admin, :icon => 'icon-dashboard' %> <%= tab :orders, :payments, :creditcard_payments, :shipments, :credit_cards, :return_authorizations, :url => admin_orders_path('q[s]' => 'completed_at desc'), :icon => 'icon-shopping-cart' %> -<%= tab :products , :option_types, :properties, :prototypes, :variants, :product_properties, :taxons, :url => bulk_edit_admin_products_path, :icon => 'icon-th-large' %> +<%= tab :products , :option_types, :properties, :prototypes, :variants, :product_properties, :taxons, :url => admin_products_path, :icon => 'icon-th-large' %> <%= tab :reports, :icon => 'icon-file' %> <%= tab :configurations, :general_settings, :mail_methods, :tax_categories, :zones, :states, :payment_methods, :inventory_settings, :taxonomies, :shipping_methods, :trackers, :label => 'configuration', :icon => 'icon-wrench', :url => edit_admin_general_settings_path %> diff --git a/config/locales/de_DE.yml b/config/locales/de_DE.yml index 299c4fe89a..e65492c1e3 100644 --- a/config/locales/de_DE.yml +++ b/config/locales/de_DE.yml @@ -358,7 +358,7 @@ de_DE: manages: verwaltet products: unit_name_placeholder: 'z.B. Trauben' - bulk_edit: + index: unit: Einheit display_as: Darstellen als category: Kategorie @@ -2293,7 +2293,7 @@ de_DE: new: title: 'Neues Produkt' unit_name_placeholder: 'z.B. Trauben' - bulk_edit: + index: header: title: Massenbearbeitung von Produkten indicators: diff --git a/config/locales/en.yml b/config/locales/en.yml index 707579765a..9920a34685 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -454,7 +454,7 @@ en: products: unit_name_placeholder: 'eg. bunches' - bulk_edit: + index: unit: Unit display_as: Display As category: Category @@ -2569,7 +2569,7 @@ See the %{link} to find out more about %{sitename}'s features and to start using new: title: 'New Product' unit_name_placeholder: 'eg. bunches' - bulk_edit: + index: header: title: Bulk Edit Products indicators: diff --git a/config/locales/en_GB.yml b/config/locales/en_GB.yml index 703a303253..eec1f844f6 100644 --- a/config/locales/en_GB.yml +++ b/config/locales/en_GB.yml @@ -267,7 +267,7 @@ en_GB: manages: manages products: unit_name_placeholder: 'eg. bunches' - bulk_edit: + index: unit: Unit display_as: Display As category: Category @@ -2106,7 +2106,7 @@ en_GB: new: title: 'New Product' unit_name_placeholder: 'eg. bunches' - bulk_edit: + index: header: title: Bulk Edit Products indicators: diff --git a/config/locales/en_US.yml b/config/locales/en_US.yml index a58d9a3ae0..0da03df1c6 100644 --- a/config/locales/en_US.yml +++ b/config/locales/en_US.yml @@ -364,7 +364,7 @@ en_US: manages: manages products: unit_name_placeholder: 'eg. bunches' - bulk_edit: + index: unit: Unit display_as: Display As category: Category @@ -2296,7 +2296,7 @@ en_US: new: title: 'New Product' unit_name_placeholder: 'eg. bunches' - bulk_edit: + index: header: title: Bulk Edit Products indicators: diff --git a/config/locales/es.yml b/config/locales/es.yml index d203a617ea..e990f79e55 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -364,7 +364,7 @@ es: manages: Gestionan products: unit_name_placeholder: 'ej. manojos' - bulk_edit: + index: unit: Unidad display_as: Mostrar como category: Categoría @@ -2309,7 +2309,7 @@ es: new: title: 'Nuevo producto' unit_name_placeholder: 'ej. manojos' - bulk_edit: + index: header: title: Editar varios Productos indicators: diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 50cdf5e37f..3f9b4d7548 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -371,7 +371,7 @@ fr: manages: gère products: unit_name_placeholder: 'ex: botte' - bulk_edit: + index: unit: Unité display_as: Unité affichéé category: Catégorie @@ -2389,7 +2389,7 @@ fr: new: title: 'Nouveau Produit' unit_name_placeholder: 'ex: botte' - bulk_edit: + index: header: title: Gestion du catalogue produits indicators: diff --git a/config/locales/nb.yml b/config/locales/nb.yml index 27ec19ef78..ae77ae6d18 100644 --- a/config/locales/nb.yml +++ b/config/locales/nb.yml @@ -371,7 +371,7 @@ nb: manages: administrerer products: unit_name_placeholder: 'f.eks. bunter' - bulk_edit: + index: unit: Enhet display_as: Vis som category: Kategori @@ -2376,7 +2376,7 @@ nb: new: title: 'Nytt produkt' unit_name_placeholder: 'f.eks. bunter' - bulk_edit: + index: header: title: Endre produkter i bulk indicators: diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml index 37a0c05dd4..54cb35e468 100644 --- a/config/locales/pt-BR.yml +++ b/config/locales/pt-BR.yml @@ -141,7 +141,7 @@ pt-BR: update_address: 'Atualizar Endereço' confirm_delete: 'Certeza que quer excluir?' products: - bulk_edit: + index: unit: Unidade display_as: Mostrar Como category: Categoria diff --git a/config/locales/pt.yml b/config/locales/pt.yml index f071628b51..9dea0d380d 100644 --- a/config/locales/pt.yml +++ b/config/locales/pt.yml @@ -371,7 +371,7 @@ pt: manages: gere products: unit_name_placeholder: 'ex: molho, ramo, etc.' - bulk_edit: + index: unit: Unidade display_as: Mostrar como category: Categoria @@ -2378,7 +2378,7 @@ pt: new: title: 'Novo Produto' unit_name_placeholder: 'ex: ramos' - bulk_edit: + index: header: title: Editar Produtos por Atacado indicators: diff --git a/config/locales/sv.yml b/config/locales/sv.yml index b5453da3b3..6b8ff19728 100644 --- a/config/locales/sv.yml +++ b/config/locales/sv.yml @@ -1855,7 +1855,7 @@ sv: other: "Du har %{count} aktiva beställningsomgångar." manage_order_cycles: "HANTERA BESTÄLLNINGSOMGÅNGAR" products: - bulk_edit: + index: header: title: 'Redigera Omfång av Produkter ' indicators: diff --git a/config/routes.rb b/config/routes.rb index 8e4b0e1495..53356e2f52 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -259,7 +259,6 @@ Spree::Core::Engine.routes.prepend do match '/admin/reports/orders_and_fulfillment' => 'admin/reports#orders_and_fulfillment', :as => "orders_and_fulfillment_admin_reports", :via => [:get, :post] match '/admin/reports/users_and_enterprises' => 'admin/reports#users_and_enterprises', :as => "users_and_enterprises_admin_reports", :via => [:get, :post] match '/admin/reports/sales_tax' => 'admin/reports#sales_tax', :as => "sales_tax_admin_reports", :via => [:get, :post] - match '/admin/products/bulk_edit' => 'admin/products#bulk_edit', :as => "bulk_edit_admin_products" match '/admin/orders/bulk_management' => 'admin/orders#bulk_management', :as => "admin_bulk_order_management" match '/admin/reports/products_and_inventory' => 'admin/reports#products_and_inventory', :as => "products_and_inventory_admin_reports", :via => [:get, :post] match '/admin/reports/customers' => 'admin/reports#customers', :as => "customers_admin_reports", :via => [:get, :post] diff --git a/lib/open_food_network/column_preference_defaults.rb b/lib/open_food_network/column_preference_defaults.rb index e45670869b..3b28d7e9ce 100644 --- a/lib/open_food_network/column_preference_defaults.rb +++ b/lib/open_food_network/column_preference_defaults.rb @@ -55,8 +55,8 @@ module OpenFoodNetwork } end - def products_bulk_edit_columns - node = "spree.admin.products.bulk_edit.products_head" + def products_index_columns + node = "spree.admin.products.index.products_head" { image: { name: I18n.t("admin.image"), visible: true }, producer: { name: I18n.t("admin.producer"), visible: true }, diff --git a/spec/controllers/spree/admin/products_controller_spec.rb b/spec/controllers/spree/admin/products_controller_spec.rb index 8632105b3e..d5b1f861b5 100644 --- a/spec/controllers/spree/admin/products_controller_spec.rb +++ b/spec/controllers/spree/admin/products_controller_spec.rb @@ -23,7 +23,7 @@ describe Spree::Admin::ProductsController, type: :controller do context "creating a new product" do before { login_as_admin } - it "redirects to bulk_edit when the user hits 'create'" do + it "redirects to products when the user hits 'create'" do s = create(:supplier_enterprise) t = create(:taxon) spree_post :create, { @@ -40,7 +40,7 @@ describe Spree::Admin::ProductsController, type: :controller do }, button: 'create' } - response.should redirect_to "/admin/products/bulk_edit" + response.should redirect_to spree.admin_products_path end it "redirects to new when the user hits 'add_another'" do diff --git a/spec/features/admin/bulk_product_update_spec.rb b/spec/features/admin/bulk_product_update_spec.rb index 61d563cb0c..26090d4330 100644 --- a/spec/features/admin/bulk_product_update_spec.rb +++ b/spec/features/admin/bulk_product_update_spec.rb @@ -16,14 +16,14 @@ feature %q{ p1 = FactoryBot.create(:product) p2 = FactoryBot.create(:product) - visit '/admin/products/bulk_edit' + visit spree.admin_products_path expect(page).to have_field "product_name", with: p1.name, :visible => true expect(page).to have_field "product_name", with: p2.name, :visible => true end it "displays a message when number of products is zero" do - visit '/admin/products/bulk_edit' + visit spree.admin_products_path expect(page).to have_text "No products yet. Why don't you add some?" end @@ -35,7 +35,7 @@ feature %q{ p1 = FactoryBot.create(:product, supplier: s2) p2 = FactoryBot.create(:product, supplier: s3) - visit '/admin/products/bulk_edit' + visit spree.admin_products_path expect(page).to have_select "producer_id", with_options: [s1.name,s2.name,s3.name], selected: s2.name expect(page).to have_select "producer_id", with_options: [s1.name,s2.name,s3.name], selected: s3.name @@ -45,7 +45,7 @@ feature %q{ p1 = FactoryBot.create(:product, available_on: Date.current) p2 = FactoryBot.create(:product, available_on: Date.current-1) - visit '/admin/products/bulk_edit' + visit spree.admin_products_path find("div#columns-dropdown", :text => "COLUMNS").click find("div#columns-dropdown div.menu div.menu_item", text: "Available On").click find("div#columns-dropdown", :text => "COLUMNS").click @@ -60,7 +60,7 @@ feature %q{ v1.on_hand = 4 v1.save! - visit '/admin/products/bulk_edit' + visit spree.admin_products_path within "#p_#{p1.id}" do expect(page).to have_no_field "on_hand", with: "15" @@ -73,7 +73,7 @@ feature %q{ v1 = FactoryBot.create(:variant, product: p1, is_master: false, on_hand: 4) v2 = FactoryBot.create(:variant, product: p1, is_master: false, on_hand: 0, on_demand: true) - visit '/admin/products/bulk_edit' + visit spree.admin_products_path expect(page).to have_selector "a.view-variants", count: 1 find("a.view-variants").trigger('click') @@ -86,7 +86,7 @@ feature %q{ it "displays a select box for the unit of measure for the product's variants" do p = FactoryBot.create(:product, variant_unit: 'weight', variant_unit_scale: 1, variant_unit_name: '') - visit '/admin/products/bulk_edit' + visit spree.admin_products_path expect(page).to have_select "variant_unit_with_scale", selected: "Weight (g)" end @@ -94,7 +94,7 @@ feature %q{ it "displays a text field for the item name when unit is set to 'Items'" do p = FactoryBot.create(:product, variant_unit: 'items', variant_unit_scale: nil, variant_unit_name: 'packet') - visit '/admin/products/bulk_edit' + visit spree.admin_products_path expect(page).to have_select "variant_unit_with_scale", selected: "Items" expect(page).to have_field "variant_unit_name", with: "packet" @@ -110,7 +110,7 @@ feature %q{ v1 = FactoryBot.create(:variant, display_name: "something1" ) v2 = FactoryBot.create(:variant, display_name: "something2" ) - visit '/admin/products/bulk_edit' + visit spree.admin_products_path expect(page).to have_selector "a.view-variants", count: 2 all("a.view-variants").each { |e| e.trigger('click') } @@ -125,7 +125,7 @@ feature %q{ v1 = FactoryBot.create(:variant, product: p1, is_master: false, on_hand: 15) v2 = FactoryBot.create(:variant, product: p1, is_master: false, on_hand: 6) - visit '/admin/products/bulk_edit' + visit spree.admin_products_path expect(page).to have_selector "a.view-variants", count: 1 all("a.view-variants").each { |e| e.trigger('click') } @@ -140,7 +140,7 @@ feature %q{ v1 = FactoryBot.create(:variant, product: p1, is_master: false, price: 12.75) v2 = FactoryBot.create(:variant, product: p1, is_master: false, price: 2.50) - visit '/admin/products/bulk_edit' + visit spree.admin_products_path expect(page).to have_selector "a.view-variants", count: 1 all("a.view-variants").each { |e| e.trigger('click') } @@ -154,7 +154,7 @@ feature %q{ v1 = FactoryBot.create(:variant, product: p1, is_master: false, price: 12.75, unit_value: 1200, unit_description: "(small bag)", display_as: "bag") v2 = FactoryBot.create(:variant, product: p1, is_master: false, price: 2.50, unit_value: 4800, unit_description: "(large bag)", display_as: "bin") - visit '/admin/products/bulk_edit' + visit spree.admin_products_path expect(page).to have_selector "a.view-variants", count: 1 all("a.view-variants").each { |e| e.trigger('click') } @@ -173,7 +173,7 @@ feature %q{ login_to_admin_section - visit '/admin/products/bulk_edit' + visit spree.admin_products_path find("a", text: "NEW PRODUCT").click expect(page).to have_content 'NEW PRODUCT' @@ -186,7 +186,7 @@ feature %q{ select taxon.name, from: 'product_primary_taxon_id' click_button 'Create' - expect(URI.parse(current_url).path).to eq '/admin/products/bulk_edit' + expect(URI.parse(current_url).path).to eq spree.admin_products_path expect(flash_message).to eq 'Product "Big Bag Of Apples" has been successfully created!' expect(page).to have_field "product_name", with: 'Big Bag Of Apples' end @@ -196,7 +196,7 @@ feature %q{ # Given a product without variants or a unit p = FactoryBot.create(:product, variant_unit: 'weight', variant_unit_scale: 1000) login_to_admin_section - visit '/admin/products/bulk_edit' + visit spree.admin_products_path # I should see an add variant button page.find('a.view-variants').trigger('click') @@ -246,7 +246,7 @@ feature %q{ login_to_admin_section - visit '/admin/products/bulk_edit' + visit spree.admin_products_path find("div#columns-dropdown", :text => "COLUMNS").click find("div#columns-dropdown div.menu div.menu_item", text: "Available On").click @@ -292,7 +292,7 @@ feature %q{ login_to_admin_section - visit '/admin/products/bulk_edit' + visit spree.admin_products_path expect(page).to have_select "variant_unit_with_scale", selected: "Weight (kg)" @@ -318,7 +318,7 @@ feature %q{ login_to_admin_section - visit '/admin/products/bulk_edit' + visit spree.admin_products_path expect(page).to have_selector "a.view-variants", count: 1 find("a.view-variants").trigger('click') @@ -357,7 +357,7 @@ feature %q{ login_to_admin_section - visit '/admin/products/bulk_edit' + visit spree.admin_products_path expect(page).to have_selector "a.view-variants", count: 1 find("a.view-variants").trigger('click') @@ -381,7 +381,7 @@ feature %q{ p = FactoryBot.create(:product, name: 'original name') login_to_admin_section - visit '/admin/products/bulk_edit' + visit spree.admin_products_path expect(page).to have_field "product_name", with: "original name" @@ -414,7 +414,7 @@ feature %q{ p = FactoryBot.create(:product, :name => "product 1") login_to_admin_section - visit '/admin/products/bulk_edit' + visit spree.admin_products_path expect(page).to have_selector "a.clone-product", count: 1 find("a.clone-product").click @@ -437,7 +437,7 @@ feature %q{ p2 = FactoryBot.create(:simple_product, :name => "product2", supplier: s2) login_to_admin_section - visit '/admin/products/bulk_edit' + visit spree.admin_products_path select2_select s1.name, from: "producer_filter" @@ -464,7 +464,7 @@ feature %q{ before do quick_login_as_admin - visit '/admin/products/bulk_edit' + visit spree.admin_products_path end it "shows a delete button for products, which deletes the appropriate product when clicked" do @@ -476,7 +476,7 @@ feature %q{ expect(page).to have_selector "a.delete-product", :count => 1 - visit '/admin/products/bulk_edit' + visit spree.admin_products_path expect(page).to have_selector "a.delete-product", :count => 1 end @@ -493,7 +493,7 @@ feature %q{ expect(page).to have_selector "a.delete-variant", :count => 2 - visit '/admin/products/bulk_edit' + visit spree.admin_products_path expect(page).to have_selector "a.view-variants" all("a.view-variants").select { |e| e.visible? }.each { |e| e.trigger('click') } @@ -509,7 +509,7 @@ feature %q{ before do quick_login_as_admin - visit '/admin/products/bulk_edit' + visit spree.admin_products_path end it "shows an edit button for products, which takes the user to the standard edit page for that product" do @@ -543,7 +543,7 @@ feature %q{ p3 = FactoryBot.create(:product, :name => "P3") login_to_admin_section - visit '/admin/products/bulk_edit' + visit spree.admin_products_path expect(page).to have_selector "a.clone-product", :count => 3 @@ -554,7 +554,7 @@ feature %q{ expect(page).to have_field "product_name", with: "COPY OF #{p1.name}" expect(page).to have_select "producer_id", selected: "#{p1.supplier.name}" - visit '/admin/products/bulk_edit' + visit spree.admin_products_path expect(page).to have_selector "a.clone-product", :count => 4 expect(page).to have_field "product_name", with: "COPY OF #{p1.name}" @@ -569,7 +569,7 @@ feature %q{ FactoryBot.create(:simple_product) login_to_admin_section - visit '/admin/products/bulk_edit' + visit spree.admin_products_path find("div#columns-dropdown", :text => "COLUMNS").click find("div#columns-dropdown div.menu div.menu_item", text: "Available On").click @@ -601,7 +601,7 @@ feature %q{ p2 = FactoryBot.create(:simple_product, :name => "product2", supplier: s2) login_to_admin_section - visit '/admin/products/bulk_edit' + visit spree.admin_products_path # Page shows the filter controls expect(page).to have_select "producer_filter", visible: false @@ -655,7 +655,7 @@ feature %q{ end it "shows only products that I supply" do - visit '/admin/products/bulk_edit' + visit spree.admin_products_path expect(page).to have_field 'product_name', with: product_supplied.name expect(page).to have_field 'product_name', with: product_supplied_permitted.name @@ -663,7 +663,7 @@ feature %q{ end it "shows only suppliers that I manage or have permission to" do - visit '/admin/products/bulk_edit' + visit spree.admin_products_path expect(page).to have_select 'producer_id', with_options: [supplier_managed1.name, supplier_managed2.name, supplier_permitted.name], selected: supplier_managed1.name expect(page).to have_no_select 'producer_id', with_options: [supplier_unmanaged.name] @@ -672,7 +672,7 @@ feature %q{ it "shows inactive products that I supply" do product_supplied_inactive - visit '/admin/products/bulk_edit' + visit spree.admin_products_path expect(page).to have_field 'product_name', with: product_supplied_inactive.name end @@ -680,7 +680,7 @@ feature %q{ it "allows me to create a product" do taxon = create(:taxon, name: 'Fruit') - visit '/admin/products/bulk_edit' + visit spree.admin_products_path find("a", text: "NEW PRODUCT").click expect(page).to have_content 'NEW PRODUCT' @@ -696,7 +696,7 @@ feature %q{ end click_button 'Create' - expect(URI.parse(current_url).path).to eq '/admin/products/bulk_edit' + expect(URI.parse(current_url).path).to eq spree.admin_products_path expect(flash_message).to eq 'Product "Big Bag Of Apples" has been successfully created!' expect(page).to have_field "product_name", with: 'Big Bag Of Apples' end @@ -705,7 +705,7 @@ feature %q{ p = product_supplied_permitted v = p.variants.first - visit '/admin/products/bulk_edit' + visit spree.admin_products_path find("div#columns-dropdown", :text => "COLUMNS").click find("div#columns-dropdown div.menu div.menu_item", text: "Available On").click find("div#columns-dropdown", :text => "COLUMNS").click @@ -750,7 +750,7 @@ feature %q{ it "displays product images and image upload modal" do quick_login_as_admin - visit '/admin/products/bulk_edit' + visit spree.admin_products_path within "table#listing_products tr#p_#{product.id}" do # Displays product images diff --git a/spec/features/admin/enterprise_user_spec.rb b/spec/features/admin/enterprise_user_spec.rb index dd24e465d8..0a070a80ea 100644 --- a/spec/features/admin/enterprise_user_spec.rb +++ b/spec/features/admin/enterprise_user_spec.rb @@ -33,26 +33,6 @@ feature %q{ end end - describe "product management" do - describe "managing supplied products" do - before do - user.enterprise_roles.create!(enterprise: supplier1) - product1 = create(:product, name: 'Green eggs', supplier: supplier1) - product2 = create(:product, name: 'Ham', supplier: supplier2) - login_to_admin_as user - end - - it "can manage products that I supply" do - visit spree.admin_products_path - - within '#listing_products' do - page.should have_content 'Green eggs' - page.should_not have_content 'Ham' - end - end - end - end - # This case no longer exists as anyone with an enterprise can supply into the system. # Or can they?? There is no producer profile anyway. # TODO discuss what parts of this are still necessary in which cases. diff --git a/spec/features/admin/products_spec.rb b/spec/features/admin/products_spec.rb index 18dd52ed73..bccc223953 100644 --- a/spec/features/admin/products_spec.rb +++ b/spec/features/admin/products_spec.rb @@ -39,7 +39,7 @@ feature %q{ click_button 'Create' - expect(current_path).to eq spree.bulk_edit_admin_products_path + expect(current_path).to eq spree.admin_products_path flash_message.should == 'Product "A new product !!!" has been successfully created!' product = Spree::Product.find_by_name('A new product !!!') product.supplier.should == @supplier @@ -80,7 +80,7 @@ feature %q{ click_button 'Create' - expect(current_path).to eq spree.bulk_edit_admin_products_path + expect(current_path).to eq spree.admin_products_path product = Spree::Product.find_by_name('Hot Cakes') product.variants.count.should == 1 variant = product.variants.first diff --git a/spec/models/spree/ability_spec.rb b/spec/models/spree/ability_spec.rb index 71e0c52eb1..7882665618 100644 --- a/spec/models/spree/ability_spec.rb +++ b/spec/models/spree/ability_spec.rb @@ -147,18 +147,18 @@ module Spree let(:order) { create(:order) } it "should be able to read/write their enterprises' products and variants" do - should have_ability([:admin, :read, :update, :product_distributions, :bulk_edit, :bulk_update, :clone, :destroy], for: p1) + should have_ability([:admin, :read, :update, :product_distributions, :bulk_update, :clone, :destroy], for: p1) should have_ability([:admin, :index, :read, :edit, :update, :search, :destroy, :delete], for: p1.master) end it "should be able to read/write related enterprises' products and variants with manage_products permission" do er_ps - should have_ability([:admin, :read, :update, :product_distributions, :bulk_edit, :bulk_update, :clone, :destroy], for: p_related) + should have_ability([:admin, :read, :update, :product_distributions, :bulk_update, :clone, :destroy], for: p_related) should have_ability([:admin, :index, :read, :edit, :update, :search, :destroy, :delete], for: p_related.master) end it "should not be able to read/write other enterprises' products and variants" do - should_not have_ability([:admin, :read, :update, :product_distributions, :bulk_edit, :bulk_update, :clone, :destroy], for: p2) + should_not have_ability([:admin, :read, :update, :product_distributions, :bulk_update, :clone, :destroy], for: p2) should_not have_ability([:admin, :index, :read, :edit, :update, :search, :destroy], for: p2.master) end From f7848b025fdb8015b2ac7992cb9a9d314f305e22 Mon Sep 17 00:00:00 2001 From: Frank West Date: Fri, 8 Jun 2018 08:38:39 -0700 Subject: [PATCH 188/206] Add rack-rewrite to handle redirects We are moving bulk edit to a different route and we want to be able to handle redirects on this route. Handling this at the rack level before the rails stack is the most performant way outside of rewrites on the web server itself. --- Gemfile | 1 + Gemfile.lock | 2 ++ config/initializers/rack_rewrite.rb | 7 +++++++ 3 files changed, 10 insertions(+) create mode 100644 config/initializers/rack_rewrite.rb diff --git a/Gemfile b/Gemfile index 1bafae9b50..962666d6b9 100644 --- a/Gemfile +++ b/Gemfile @@ -58,6 +58,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' diff --git a/Gemfile.lock b/Gemfile.lock index b96a2dad54..4c12c187b8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -543,6 +543,7 @@ GEM 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) @@ -775,6 +776,7 @@ DEPENDENCIES pry-byebug (>= 3.4.3) rabl rack-livereload + rack-rewrite rack-ssl rails (~> 3.2.22) rails-i18n (~> 3.0.0) diff --git a/config/initializers/rack_rewrite.rb b/config/initializers/rack_rewrite.rb new file mode 100644 index 0000000000..98c93a79f3 --- /dev/null +++ b/config/initializers/rack_rewrite.rb @@ -0,0 +1,7 @@ +module Openfoodnetwork + class Application < Rails::Application + config.middleware.insert_before(Rack::Lock, Rack::Rewrite) do + r301 '/admin/products/bulk_edit', '/admin/products' # TODO: Date added 15/06/2018 + end + end +end From 3821b9e0da39b99748d1ea569fb2c9eb18663fb7 Mon Sep 17 00:00:00 2001 From: Frank West Date: Fri, 15 Jun 2018 06:27:02 -0700 Subject: [PATCH 189/206] Fix ordering of Gemfile.lock When we run bundle the gems are being reordered to be alphabetical. Seems to have been committed on 26/05/2018 here: https://github.com/openfoodfoundation/openfoodnetwork/commit/7a64ad1cc1dc0df5a773f854e7316be122ce3ce4 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 4c12c187b8..0a1c11e147 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -784,8 +784,8 @@ DEPENDENCIES representative_view roadie-rails (~> 1.0.3) roo (~> 2.7.0) - rspec-rails (>= 3.5.2) roo-xls (~> 1.1.0) + rspec-rails (>= 3.5.2) rspec-retry rubocop (>= 0.49.1) sass (~> 3.3) From 86d7453d2637508a6b387f6bc22e24f27aa468f6 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Wed, 14 Mar 2018 15:23:36 +1100 Subject: [PATCH 190/206] Ask user to confirm oc date change for open order cycles with subsciptions --- .../directives/change-warning.js.coffee | 19 +++++++++++++++++++ .../services/order_cycle.js.coffee | 1 + .../api/admin/index_order_cycle_serializer.rb | 6 +++++- .../api/admin/order_cycle_serializer.rb | 6 +++++- .../_name_and_timing_form.html.haml | 4 ++-- app/views/admin/order_cycles/_row.html.haml | 4 ++-- config/locales/en.yml | 4 ++++ spec/features/admin/order_cycles_spec.rb | 5 +++++ 8 files changed, 43 insertions(+), 6 deletions(-) create mode 100644 app/assets/javascripts/admin/order_cycles/directives/change-warning.js.coffee diff --git a/app/assets/javascripts/admin/order_cycles/directives/change-warning.js.coffee b/app/assets/javascripts/admin/order_cycles/directives/change-warning.js.coffee new file mode 100644 index 0000000000..8c7b84fb62 --- /dev/null +++ b/app/assets/javascripts/admin/order_cycles/directives/change-warning.js.coffee @@ -0,0 +1,19 @@ +angular.module("admin.orderCycles").directive "changeWarning", (ConfirmDialog) -> + restrict: "A" + scope: + orderCycle: '=changeWarning' + link: (scope, element, attrs) -> + acknowledged = false + count = scope.orderCycle.subscriptions_count + cancel = 'admin.order_cycles.date_warning.cancel' + proceed = 'admin.order_cycles.date_warning.proceed' + msg = t('admin.order_cycles.date_warning.msg', n: count) + options = { cancel: t(cancel), confirm: t(proceed) } + + element.focus -> + return if acknowledged + return if moment(scope.orderCycle.orders_close_at).isBefore() + return if count < 1 + ConfirmDialog.open('info', msg, options).then -> + acknowledged = true + element.siblings('img').trigger('click') diff --git a/app/assets/javascripts/admin/order_cycles/services/order_cycle.js.coffee b/app/assets/javascripts/admin/order_cycles/services/order_cycle.js.coffee index f35db6e3b5..59d9b4f994 100644 --- a/app/assets/javascripts/admin/order_cycles/services/order_cycle.js.coffee +++ b/app/assets/javascripts/admin/order_cycles/services/order_cycle.js.coffee @@ -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) -> diff --git a/app/serializers/api/admin/index_order_cycle_serializer.rb b/app/serializers/api/admin/index_order_cycle_serializer.rb index 22d6d4445e..566d794c61 100644 --- a/app/serializers/api/admin/index_order_cycle_serializer.rb +++ b/app/serializers/api/admin/index_order_cycle_serializer.rb @@ -7,7 +7,7 @@ module Api attributes :id, :name, :orders_open_at, :orders_close_at, :status, :variant_count, :deletable attributes :coordinator, :producers, :shops, :viewing_as_coordinator - attributes :edit_path, :clone_path, :delete_path + attributes :edit_path, :clone_path, :delete_path, :subscriptions_count has_many :schedules, serializer: Api::Admin::IdNameSerializer @@ -61,6 +61,10 @@ module Api admin_order_cycle_path(object) end + def subscriptions_count + ProxyOrder.not_canceled.where(order_cycle_id: object.id).count + end + private def visible_enterprises diff --git a/app/serializers/api/admin/order_cycle_serializer.rb b/app/serializers/api/admin/order_cycle_serializer.rb index 9b5ff7a7cb..919ab9de1f 100644 --- a/app/serializers/api/admin/order_cycle_serializer.rb +++ b/app/serializers/api/admin/order_cycle_serializer.rb @@ -4,7 +4,7 @@ class Api::Admin::OrderCycleSerializer < ActiveModel::Serializer attributes :id, :name, :orders_open_at, :orders_close_at, :coordinator_id, :exchanges attributes :editable_variants_for_incoming_exchanges, :editable_variants_for_outgoing_exchanges attributes :visible_variants_for_outgoing_exchanges - attributes :viewing_as_coordinator, :schedule_ids + attributes :viewing_as_coordinator, :schedule_ids, :subscriptions_count has_many :coordinator_fees, serializer: Api::IdSerializer @@ -20,6 +20,10 @@ class Api::Admin::OrderCycleSerializer < ActiveModel::Serializer Enterprise.managed_by(options[:current_user]).include? object.coordinator end + def subscriptions_count + ProxyOrder.not_canceled.where(order_cycle_id: object.id).count + end + def exchanges scoped_exchanges = OpenFoodNetwork::OrderCyclePermissions.new(options[:current_user], object).visible_exchanges.by_enterprise_name ActiveModel::ArraySerializer.new(scoped_exchanges, {each_serializer: Api::Admin::ExchangeSerializer, current_user: options[:current_user] }) diff --git a/app/views/admin/order_cycles/_name_and_timing_form.html.haml b/app/views/admin/order_cycles/_name_and_timing_form.html.haml index 1b2ff05c21..842b12d5c3 100644 --- a/app/views/admin/order_cycles/_name_and_timing_form.html.haml +++ b/app/views/admin/order_cycles/_name_and_timing_form.html.haml @@ -10,7 +10,7 @@ = f.label :orders_open_at, t('.orders_open') .omega.six.columns - if viewing_as_coordinator_of?(@order_cycle) - = f.text_field :orders_open_at, 'datetimepicker' => 'order_cycle.orders_open_at', 'ng-model' => 'order_cycle.orders_open_at', 'ng-disabled' => '!loaded()' + = f.text_field :orders_open_at, 'datetimepicker' => 'order_cycle.orders_open_at', 'ng-model' => 'order_cycle.orders_open_at', 'ng-disabled' => '!loaded()', 'change-warning' => 'order_cycle' - else {{ order_cycle.orders_open_at }} @@ -23,7 +23,7 @@ = f.label :orders_close, t('.orders_close') .six.columns.omega - if viewing_as_coordinator_of?(@order_cycle) - = f.text_field :orders_close_at, 'datetimepicker' => 'order_cycle.orders_close_at', 'ng-model' => 'order_cycle.orders_close_at', 'ng-disabled' => '!loaded()' + = f.text_field :orders_close_at, 'datetimepicker' => 'order_cycle.orders_close_at', 'ng-model' => 'order_cycle.orders_close_at', 'ng-disabled' => '!loaded()', 'change-warning' => 'order_cycle' - else {{ order_cycle.orders_close_at }} diff --git a/app/views/admin/order_cycles/_row.html.haml b/app/views/admin/order_cycles/_row.html.haml index a2fb90df3b..40f40e5126 100644 --- a/app/views/admin/order_cycles/_row.html.haml +++ b/app/views/admin/order_cycles/_row.html.haml @@ -8,10 +8,10 @@ {{ schedule.name + ($last ? '' : ',') }} %span{ ng: { show: 'orderCycle.schedules.length == 0'}} None %td.orders_open_at{ ng: { show: 'columns.open.visible' } } - %input.datetimepicker{ id: 'oc{{::orderCycle.id}}_orders_open_at', name: 'oc{{::orderCycle.id}}[orders_open_at]', type: 'text', ng: { if: 'orderCycle.viewing_as_coordinator', model: 'orderCycle.orders_open_at' }, datetimepicker: 'orderCycle.orders_open_at' } + %input.datetimepicker{ id: 'oc{{::orderCycle.id}}_orders_open_at', name: 'oc{{::orderCycle.id}}[orders_open_at]', type: 'text', ng: { if: 'orderCycle.viewing_as_coordinator', model: 'orderCycle.orders_open_at' }, datetimepicker: 'orderCycle.orders_open_at', 'change-warning' => 'orderCycle' } %input{ id: 'oc{{::orderCycle.id}}_orders_open_at', name: 'oc{{::orderCycle.id}}[orders_open_at]', type: 'text', ng: { if: '!orderCycle.viewing_as_coordinator', model: 'orderCycle.orders_open_at'}, disabled: true } %td.orders_close_at{ ng: { show: 'columns.close.visible' } } - %input.datetimepicker{ id: 'oc{{::orderCycle.id}}_orders_close_at', name: 'oc{{::orderCycle.id}}[orders_close_at]', type: 'text', ng: { if: 'orderCycle.viewing_as_coordinator', model: 'orderCycle.orders_close_at' }, datetimepicker: 'orderCycle.orders_close_at' } + %input.datetimepicker{ id: 'oc{{::orderCycle.id}}_orders_close_at', name: 'oc{{::orderCycle.id}}[orders_close_at]', type: 'text', ng: { if: 'orderCycle.viewing_as_coordinator', model: 'orderCycle.orders_close_at' }, datetimepicker: 'orderCycle.orders_close_at', 'change-warning' => 'orderCycle' } %input{ id: 'oc{{::orderCycle.id}}_orders_close_at', name: 'oc{{::orderCycle.id}}[orders_close_at]', type: 'text', ng: { if: '!orderCycle.viewing_as_coordinator', model: 'orderCycle.orders_close_at'}, disabled: true } - unless simple_index diff --git a/config/locales/en.yml b/config/locales/en.yml index 707579765a..bc7399ef30 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -902,6 +902,10 @@ en: schedule_present: That order cycle is linked to a schedule and cannot be deleted. Please unlink or delete the schedule first. bulk_update: no_data: Hm, something went wrong. No order cycle data found. + date_warning: + msg: This order cycle is linked to %{n} open subscription orders. Changing this date now will not affect any orders which have already been placed, but should be avoided if possible. Are you sure you want to proceed? + cancel: Cancel + proceed: Proceed producer_properties: index: title: Producer Properties diff --git a/spec/features/admin/order_cycles_spec.rb b/spec/features/admin/order_cycles_spec.rb index eb6fe4bc73..30d49fd39f 100644 --- a/spec/features/admin/order_cycles_spec.rb +++ b/spec/features/admin/order_cycles_spec.rb @@ -24,6 +24,7 @@ feature %q{ oc7 = create(:simple_order_cycle, name: 'oc7', orders_open_at: 2.months.ago, orders_close_at: 5.weeks.ago) schedule1 = create(:schedule, name: 'Schedule1', order_cycles: [oc1, oc3]) + create(:proxy_order, subscription: create(:subscription, schedule: schedule1), order_cycle: oc1) # When I go to the admin order cycles page login_to_admin_section @@ -111,6 +112,10 @@ feature %q{ page.should have_selector "#listing_order_cycles tr.order-cycle-#{oc1.id}" page.should have_selector "#listing_order_cycles tr.order-cycle-#{oc2.id}" page.should have_selector "#listing_order_cycles tr.order-cycle-#{oc3.id}" + + # Attempting to edit dates of an open order cycle with active subscriptions + find("#oc#{oc1.id}_orders_open_at").click + expect(page).to have_selector "#confirm-dialog .message", text: I18n.t('admin.order_cycles.date_warning.msg', n: 1) end describe 'listing order cycles with other locales' do From 8770122eae4b76de7b8ffe468a28405f05156c84 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Wed, 11 Apr 2018 15:30:33 +1000 Subject: [PATCH 191/206] Request the subscription count for change warning each time, don't cache --- .../admin/order_cycles/directives/change-warning.js.coffee | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/admin/order_cycles/directives/change-warning.js.coffee b/app/assets/javascripts/admin/order_cycles/directives/change-warning.js.coffee index 8c7b84fb62..acd987069b 100644 --- a/app/assets/javascripts/admin/order_cycles/directives/change-warning.js.coffee +++ b/app/assets/javascripts/admin/order_cycles/directives/change-warning.js.coffee @@ -4,16 +4,16 @@ angular.module("admin.orderCycles").directive "changeWarning", (ConfirmDialog) - orderCycle: '=changeWarning' link: (scope, element, attrs) -> acknowledged = false - count = scope.orderCycle.subscriptions_count cancel = 'admin.order_cycles.date_warning.cancel' proceed = 'admin.order_cycles.date_warning.proceed' - msg = t('admin.order_cycles.date_warning.msg', n: count) + msg = 'admin.order_cycles.date_warning.msg' options = { cancel: t(cancel), confirm: t(proceed) } element.focus -> + count = scope.orderCycle.subscriptions_count return if acknowledged return if moment(scope.orderCycle.orders_close_at).isBefore() return if count < 1 - ConfirmDialog.open('info', msg, options).then -> + ConfirmDialog.open('info', t(msg, n: count), options).then -> acknowledged = true element.siblings('img').trigger('click') From d5b10414819c1ee80f77164bc6e57131d7df1329 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Fri, 8 Jun 2018 16:50:41 +1000 Subject: [PATCH 192/206] Preload subscription counts for serialization in order cycle collection actions --- .../admin/order_cycles_controller.rb | 9 +++++-- .../api/admin/index_order_cycle_serializer.rb | 2 +- .../admin/order_cycles_controller_spec.rb | 26 +++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/app/controllers/admin/order_cycles_controller.rb b/app/controllers/admin/order_cycles_controller.rb index 445e268085..1db8d22471 100644 --- a/app/controllers/admin/order_cycles_controller.rb +++ b/app/controllers/admin/order_cycles_controller.rb @@ -19,7 +19,7 @@ module Admin 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_counts: subscriptions_counts end end end @@ -81,7 +81,7 @@ module Admin 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 } + format.json { render_as_json @order_cycles, ams_prefix: 'index', current_user: spree_current_user, subscriptions_counts: subscriptions_counts } end else respond_to do |format| @@ -230,6 +230,11 @@ module Admin render json: { errors: t('admin.order_cycles.bulk_update.no_data') }, status: :unprocessable_entity end + def subscriptions_counts + return [] if @collection.blank? + ProxyOrder.not_canceled.group(:order_cycle_id).where(order_cycle_id: @collection).count + end + def ams_prefix_whitelist [:basic, :index] end diff --git a/app/serializers/api/admin/index_order_cycle_serializer.rb b/app/serializers/api/admin/index_order_cycle_serializer.rb index 566d794c61..9a05e1616d 100644 --- a/app/serializers/api/admin/index_order_cycle_serializer.rb +++ b/app/serializers/api/admin/index_order_cycle_serializer.rb @@ -62,7 +62,7 @@ module Api end def subscriptions_count - ProxyOrder.not_canceled.where(order_cycle_id: object.id).count + options[:subscriptions_counts][object.id] end private diff --git a/spec/controllers/admin/order_cycles_controller_spec.rb b/spec/controllers/admin/order_cycles_controller_spec.rb index fa290c3703..be835c6d98 100644 --- a/spec/controllers/admin/order_cycles_controller_spec.rb +++ b/spec/controllers/admin/order_cycles_controller_spec.rb @@ -337,5 +337,31 @@ module Admin end end end + + describe "#subscriptions_counts" do + let(:oc1) { create(:simple_order_cycle) } + let(:oc2) { create(:simple_order_cycle) } + let(:collection) { OrderCycle.where(id: [oc1, oc2]) } + + context "when the collection has not been set" do + it "returns and empty array" do + expect(controller.send(:subscriptions_counts)).to eq [] + end + end + + context "when the collection has been set" do + let!(:po1) { create(:proxy_order, order_cycle: oc1) } + let!(:po2) { create(:proxy_order, order_cycle: oc1) } + let!(:po3) { create(:proxy_order, order_cycle: oc2) } + + before { controller.instance_variable_set(:@collection, collection) } + + it "returns grouped count of all active proxy orders associated each order cycle in the collection" do + result = controller.send(:subscriptions_counts) + expect(result[oc1.id]).to eq 2 + expect(result[oc2.id]).to eq 1 + end + end + end end end From 7af11da90143e60d619ff47db2bded82ec8fbe2d Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Wed, 13 Jun 2018 17:00:46 +1000 Subject: [PATCH 193/206] Use a SubscriptionsCount query object to provide counts to IndexOrderCycleSerializer --- .../admin/order_cycles_controller.rb | 9 ++--- .../api/admin/index_order_cycle_serializer.rb | 2 +- app/services/subscriptions_count.rb | 19 +++++++++++ .../admin/order_cycles_controller_spec.rb | 25 -------------- spec/services/subscriptions_count_spec.rb | 34 +++++++++++++++++++ 5 files changed, 56 insertions(+), 33 deletions(-) create mode 100644 app/services/subscriptions_count.rb create mode 100644 spec/services/subscriptions_count_spec.rb diff --git a/app/controllers/admin/order_cycles_controller.rb b/app/controllers/admin/order_cycles_controller.rb index 1db8d22471..55b08de405 100644 --- a/app/controllers/admin/order_cycles_controller.rb +++ b/app/controllers/admin/order_cycles_controller.rb @@ -19,7 +19,7 @@ module Admin respond_to do |format| format.html format.json do - render_as_json @collection, ams_prefix: params[:ams_prefix], current_user: spree_current_user, subscriptions_counts: subscriptions_counts + render_as_json @collection, ams_prefix: params[:ams_prefix], current_user: spree_current_user, subscriptions_count: SubscriptionsCount.new(@collection) end end end @@ -81,7 +81,7 @@ module Admin 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, subscriptions_counts: subscriptions_counts } + format.json { render_as_json @order_cycles, ams_prefix: 'index', current_user: spree_current_user, subscriptions_count: SubscriptionsCount.new(@collection) } end else respond_to do |format| @@ -230,11 +230,6 @@ module Admin render json: { errors: t('admin.order_cycles.bulk_update.no_data') }, status: :unprocessable_entity end - def subscriptions_counts - return [] if @collection.blank? - ProxyOrder.not_canceled.group(:order_cycle_id).where(order_cycle_id: @collection).count - end - def ams_prefix_whitelist [:basic, :index] end diff --git a/app/serializers/api/admin/index_order_cycle_serializer.rb b/app/serializers/api/admin/index_order_cycle_serializer.rb index 9a05e1616d..144bc38a7c 100644 --- a/app/serializers/api/admin/index_order_cycle_serializer.rb +++ b/app/serializers/api/admin/index_order_cycle_serializer.rb @@ -62,7 +62,7 @@ module Api end def subscriptions_count - options[:subscriptions_counts][object.id] + options[:subscriptions_count].for(object.id) end private diff --git a/app/services/subscriptions_count.rb b/app/services/subscriptions_count.rb new file mode 100644 index 0000000000..6afbe15dae --- /dev/null +++ b/app/services/subscriptions_count.rb @@ -0,0 +1,19 @@ +class SubscriptionsCount + def initialize(order_cycles) + @order_cycles = order_cycles + end + + def for(order_cycle_id) + active[order_cycle_id] || 0 + end + + private + + attr_accessor :order_cycles + + def active + return @active unless @active.nil? + return @active = [] if order_cycles.blank? + @active ||= ProxyOrder.not_canceled.group(:order_cycle_id).where(order_cycle_id: order_cycles).count + end +end diff --git a/spec/controllers/admin/order_cycles_controller_spec.rb b/spec/controllers/admin/order_cycles_controller_spec.rb index be835c6d98..4bfe487353 100644 --- a/spec/controllers/admin/order_cycles_controller_spec.rb +++ b/spec/controllers/admin/order_cycles_controller_spec.rb @@ -338,30 +338,5 @@ module Admin end end - describe "#subscriptions_counts" do - let(:oc1) { create(:simple_order_cycle) } - let(:oc2) { create(:simple_order_cycle) } - let(:collection) { OrderCycle.where(id: [oc1, oc2]) } - - context "when the collection has not been set" do - it "returns and empty array" do - expect(controller.send(:subscriptions_counts)).to eq [] - end - end - - context "when the collection has been set" do - let!(:po1) { create(:proxy_order, order_cycle: oc1) } - let!(:po2) { create(:proxy_order, order_cycle: oc1) } - let!(:po3) { create(:proxy_order, order_cycle: oc2) } - - before { controller.instance_variable_set(:@collection, collection) } - - it "returns grouped count of all active proxy orders associated each order cycle in the collection" do - result = controller.send(:subscriptions_counts) - expect(result[oc1.id]).to eq 2 - expect(result[oc2.id]).to eq 1 - end - end - end end end diff --git a/spec/services/subscriptions_count_spec.rb b/spec/services/subscriptions_count_spec.rb new file mode 100644 index 0000000000..2446bcc8bf --- /dev/null +++ b/spec/services/subscriptions_count_spec.rb @@ -0,0 +1,34 @@ +describe SubscriptionsCount do + let(:oc1) { create(:simple_order_cycle) } + let(:oc2) { create(:simple_order_cycle) } + let(:subscriptions_count) { SubscriptionsCount.new(order_cycles) } + + describe "#for" do + context "when the collection has not been set" do + let(:order_cycles) { nil } + it "returns 0" do + expect(subscriptions_count.for(oc1.id)).to eq 0 + end + end + + context "when the collection has been set" do + let(:order_cycles) { OrderCycle.where(id: [oc1]) } + let!(:po1) { create(:proxy_order, order_cycle: oc1) } + let!(:po2) { create(:proxy_order, order_cycle: oc1) } + let!(:po3) { create(:proxy_order, order_cycle: oc2) } + + context "but the requested id is not present in the list of order cycles provided" do + it "returns 0" do + # Note that po3 applies to oc2, but oc2 in not in the collection + expect(subscriptions_count.for(oc2.id)).to eq 0 + end + end + + context "and the requested id is present in the list of order cycles provided" do + it "returns a count of active proxy orders associated with the requested order cycle" do + expect(subscriptions_count.for(oc1.id)).to eq 2 + end + end + end + end +end From a29a1bd047b8df8eb5a0ec95259af57d35400b42 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Wed, 20 Jun 2018 15:11:30 +1000 Subject: [PATCH 194/206] Only show change warning for open order cycles --- .../admin/order_cycles/directives/change-warning.js.coffee | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/admin/order_cycles/directives/change-warning.js.coffee b/app/assets/javascripts/admin/order_cycles/directives/change-warning.js.coffee index acd987069b..c1cd159807 100644 --- a/app/assets/javascripts/admin/order_cycles/directives/change-warning.js.coffee +++ b/app/assets/javascripts/admin/order_cycles/directives/change-warning.js.coffee @@ -9,10 +9,14 @@ angular.module("admin.orderCycles").directive "changeWarning", (ConfirmDialog) - 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 if moment(scope.orderCycle.orders_close_at).isBefore() + return unless isOpen(scope.orderCycle) return if count < 1 ConfirmDialog.open('info', t(msg, n: count), options).then -> acknowledged = true From fb02bdd25aad48a6ce75283b2d138068e2b24299 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Fri, 15 Jun 2018 09:51:43 +1000 Subject: [PATCH 195/206] Simplify Navigation.go, not preserving hash fragments I looked through the history and it looks like this function was a bit flawed (preserving hash fragments) from the beginning. It has been patched a few times without addressing the underlying issue that we want more than just replacing the pathname. We want to go somewhere else. --- .../javascripts/darkswarm/services/navigation.js.coffee | 7 +++---- .../unit/darkswarm/services/navigation.js.coffee | 4 +--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/darkswarm/services/navigation.js.coffee b/app/assets/javascripts/darkswarm/services/navigation.js.coffee index f445d20420..54a2e60174 100644 --- a/app/assets/javascripts/darkswarm/services/navigation.js.coffee +++ b/app/assets/javascripts/darkswarm/services/navigation.js.coffee @@ -21,10 +21,9 @@ Darkswarm.factory 'Navigation', ($location, $window) -> $window.location.href = $window.location.origin + 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() diff --git a/spec/javascripts/unit/darkswarm/services/navigation.js.coffee b/spec/javascripts/unit/darkswarm/services/navigation.js.coffee index 8b5912bff8..f8430655a9 100644 --- a/spec/javascripts/unit/darkswarm/services/navigation.js.coffee +++ b/spec/javascripts/unit/darkswarm/services/navigation.js.coffee @@ -3,7 +3,6 @@ describe 'Navigation service', -> window = location: href: null - pathname: null beforeEach -> module 'Darkswarm', ($provide) -> @@ -24,5 +23,4 @@ describe 'Navigation service', -> it "redirects to paths", -> Navigation.go "/woo/yeah" - expect(window.location.pathname).toEqual "/woo/yeah" - \ No newline at end of file + expect(window.location.href).toEqual "/woo/yeah" From d510df52360612b01557adf44712f6fffb994012 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Fri, 15 Jun 2018 10:49:53 +1000 Subject: [PATCH 196/206] Remove obsolete goWithoutHashFragments It was introduced, because `Navigation.go` perserved hash fragments. We actually don't need that behaviour and it has been corrected. `goWithoutHashFragments` also didn't deal with absolute URLs. And it used `location.origin` which is not supported by Internet Explorer. That is fixed by our use of Modernizr though. --- .../javascripts/darkswarm/services/checkout.js.coffee | 2 +- .../javascripts/darkswarm/services/navigation.js.coffee | 4 ---- .../unit/darkswarm/services/checkout_spec.js.coffee | 6 ++++++ 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/darkswarm/services/checkout.js.coffee b/app/assets/javascripts/darkswarm/services/checkout.js.coffee index 67306a7faf..204c35ac66 100644 --- a/app/assets/javascripts/darkswarm/services/checkout.js.coffee +++ b/app/assets/javascripts/darkswarm/services/checkout.js.coffee @@ -13,7 +13,7 @@ Darkswarm.factory 'Checkout', ($injector, CurrentOrder, ShippingMethods, StripeE submit: => Loading.message = t 'submitting_order' $http.put('/checkout.json', {order: @preprocess()}).success (data, status)=> - Navigation.goWithoutHashFragments data.path + Navigation.go data.path .error (response, status)=> if response.path Navigation.go response.path diff --git a/app/assets/javascripts/darkswarm/services/navigation.js.coffee b/app/assets/javascripts/darkswarm/services/navigation.js.coffee index 54a2e60174..b58786e930 100644 --- a/app/assets/javascripts/darkswarm/services/navigation.js.coffee +++ b/app/assets/javascripts/darkswarm/services/navigation.js.coffee @@ -16,10 +16,6 @@ Darkswarm.factory 'Navigation', ($location, $window) -> else @navigate(path) - goWithoutHashFragments: (path) -> - # Redirects to specified path, without Angular hash fragments such as '#/login' - $window.location.href = $window.location.origin + path - go: (path)-> # The browser treats this like clicking on a link. # It works for absolute paths, relative paths and URLs alike. diff --git a/spec/javascripts/unit/darkswarm/services/checkout_spec.js.coffee b/spec/javascripts/unit/darkswarm/services/checkout_spec.js.coffee index 5ca05a344f..4ff2f0d97a 100644 --- a/spec/javascripts/unit/darkswarm/services/checkout_spec.js.coffee +++ b/spec/javascripts/unit/darkswarm/services/checkout_spec.js.coffee @@ -95,6 +95,12 @@ describe 'Checkout service', -> Checkout.submit() $httpBackend.flush() + it "Redirects to the returned path", -> + $httpBackend.expectPUT("/checkout.json", {order: Checkout.preprocess()}).respond 200, {path: "/test"} + Checkout.submit() + $httpBackend.flush() + expect(Navigation.go).toHaveBeenCalledWith '/test' + describe "when there is an error", -> it "redirects when a redirect is given", -> $httpBackend.expectPUT("/checkout.json").respond 400, {path: 'path'} From d9623176fb1b1f223ce26a852850877f4b6ba917 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Thu, 14 Jun 2018 13:33:06 +0100 Subject: [PATCH 197/206] Include admin users as managers on new enterprises --- app/models/enterprise.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/enterprise.rb b/app/models/enterprise.rb index 52ffa73ad1..97855812ef 100644 --- a/app/models/enterprise.rb +++ b/app/models/enterprise.rb @@ -374,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 From 3f5b6be5b651242b241a79b8ef9d908c550c2004 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Thu, 8 Mar 2018 15:30:24 +1100 Subject: [PATCH 198/206] Remove unnecessary respond_to blocks from OrderCyclesController --- .../admin/order_cycles_controller.rb | 49 +++++++------------ 1 file changed, 19 insertions(+), 30 deletions(-) diff --git a/app/controllers/admin/order_cycles_controller.rb b/app/controllers/admin/order_cycles_controller.rb index 55b08de405..2139404dc2 100644 --- a/app/controllers/admin/order_cycles_controller.rb +++ b/app/controllers/admin/order_cycles_controller.rb @@ -45,49 +45,38 @@ module Admin def create @order_cycle = OrderCycle.new(params[:order_cycle]) - 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.save + OpenFoodNetwork::OrderCycleFormApplicator.new(@order_cycle, spree_current_user).go! + invoke_callbacks(:create, :after) + 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] - 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) - 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 } + 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) + flash[:notice] = I18n.t(:order_cycles_update_notice) if params[:reloading] == '1' + render json: { :success => true } + 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, subscriptions_count: SubscriptionsCount.new(@collection) } - 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 From ab9c06837b180de516cfeb1a17206f4da26ae750 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Thu, 8 Mar 2018 16:12:45 +1100 Subject: [PATCH 199/206] Add basic OrderCycleForm to handle create/update logic --- .../admin/order_cycles_controller.rb | 8 +-- app/services/order_cycle_form.rb | 13 +++++ spec/services/order_cycle_form_spec.rb | 55 +++++++++++++++++++ 3 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 app/services/order_cycle_form.rb create mode 100644 spec/services/order_cycle_form_spec.rb diff --git a/app/controllers/admin/order_cycles_controller.rb b/app/controllers/admin/order_cycles_controller.rb index 2139404dc2..3ddaaa485f 100644 --- a/app/controllers/admin/order_cycles_controller.rb +++ b/app/controllers/admin/order_cycles_controller.rb @@ -43,9 +43,9 @@ module Admin end def create - @order_cycle = OrderCycle.new(params[:order_cycle]) + @order_cycle_form = OrderCycleForm.new(@order_cycle, params) - if @order_cycle.save + if @order_cycle_form.save OpenFoodNetwork::OrderCycleFormApplicator.new(@order_cycle, spree_current_user).go! invoke_callbacks(:create, :after) flash[:notice] = I18n.t(:order_cycles_create_notice) @@ -56,9 +56,9 @@ module Admin end def update - @order_cycle = OrderCycle.find params[:id] + @order_cycle_form = OrderCycleForm.new(@order_cycle, params) - if @order_cycle.update_attributes(params[:order_cycle]) + if @order_cycle_form.save 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! diff --git a/app/services/order_cycle_form.rb b/app/services/order_cycle_form.rb new file mode 100644 index 0000000000..c60c21fc02 --- /dev/null +++ b/app/services/order_cycle_form.rb @@ -0,0 +1,13 @@ +class OrderCycleForm + attr_accessor :order_cycle, :params + + def initialize(order_cycle, params) + @order_cycle = order_cycle + @params = params + end + + def save + order_cycle.assign_attributes(params[:order_cycle]) + order_cycle.save + end +end diff --git a/spec/services/order_cycle_form_spec.rb b/spec/services/order_cycle_form_spec.rb new file mode 100644 index 0000000000..ae6014ecfb --- /dev/null +++ b/spec/services/order_cycle_form_spec.rb @@ -0,0 +1,55 @@ +describe OrderCycleForm do + describe "save" do + describe "creating a new order cycle from params" do + let(:shop) { create(:enterprise) } + let(:order_cycle) { OrderCycle.new } + let(:form) { OrderCycleForm.new(order_cycle, params) } + + context "when creation is successful" do + let(:params) { { order_cycle: { name: "Test Order Cycle", coordinator_id: shop.id } } } + + it "returns true" do + expect do + expect(form.save).to be true + end.to change(OrderCycle, :count).by(1) + end + end + + context "when creation fails" do + let(:params) { { order_cycle: { name: "Test Order Cycle" } } } + + it "returns false" do + expect do + expect(form.save).to be false + end.to_not change(OrderCycle, :count) + end + end + end + + describe "updating an existing order cycle from params" do + let(:shop) { create(:enterprise) } + let(:order_cycle) { create(:simple_order_cycle, name: "Old Name") } + let(:form) { OrderCycleForm.new(order_cycle, params) } + + context "when update is successful" do + let(:params) { { order_cycle: { name: "Test Order Cycle", coordinator_id: shop.id } } } + + it "returns true" do + expect do + expect(form.save).to be true + end.to change(order_cycle.reload, :name).to("Test Order Cycle") + end + end + + context "when updating fails" do + let(:params) { { order_cycle: { name: nil } } } + + it "returns false" do + expect do + expect(form.save).to be false + end.to_not change{ order_cycle.reload.name } + end + end + end + end +end From 21bd9d2e10e9c926800f5ffab1a83fc1f21753e6 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Fri, 9 Mar 2018 13:48:36 +1100 Subject: [PATCH 200/206] Add basic specs for OrderCyclesController#create --- .../admin/order_cycles_controller_spec.rb | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/spec/controllers/admin/order_cycles_controller_spec.rb b/spec/controllers/admin/order_cycles_controller_spec.rb index 4bfe487353..4c845239dc 100644 --- a/spec/controllers/admin/order_cycles_controller_spec.rb +++ b/spec/controllers/admin/order_cycles_controller_spec.rb @@ -96,6 +96,40 @@ module Admin end end + describe "create" do + let(:shop) { create(:distributor_enterprise) } + + context "as a manager of a shop" do + let(:form_mock) { instance_double(OrderCycleForm) } + let(:params) { { format: :json, order_cycle: {} } } + + before do + login_as_enterprise_user([shop]) + allow(OrderCycleForm).to receive(:new) { form_mock } + end + + context "when creation is successful" do + before { allow(form_mock).to receive(:save) { true } } + + it "returns success: true" do + spree_post :create, params + json_response = JSON.parse(response.body) + expect(json_response['success']).to be true + end + end + + context "when an error occurs" do + before { allow(form_mock).to receive(:save) { false } } + + it "returns an errors hash" do + spree_post :create, params + json_response = JSON.parse(response.body) + expect(json_response['errors']).to be + end + end + end + end + describe "update" do let(:order_cycle) { create(:simple_order_cycle) } let(:producer) { create(:supplier_enterprise) } From d9830749f1aa46103dd67fa6fff89deadb81d80b Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Thu, 8 Mar 2018 16:21:31 +1100 Subject: [PATCH 201/206] Extract schedule syncing logic into OrderCycleForm --- .../admin/order_cycles_controller.rb | 34 +---------- app/services/order_cycle_form.rb | 45 +++++++++++++-- .../admin/order_cycles_controller_spec.rb | 40 ------------- spec/services/order_cycle_form_spec.rb | 57 ++++++++++++++++++- 4 files changed, 98 insertions(+), 78 deletions(-) diff --git a/app/controllers/admin/order_cycles_controller.rb b/app/controllers/admin/order_cycles_controller.rb index 3ddaaa485f..ff19519d37 100644 --- a/app/controllers/admin/order_cycles_controller.rb +++ b/app/controllers/admin/order_cycles_controller.rb @@ -1,6 +1,4 @@ -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 @@ -9,11 +7,8 @@ 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| @@ -43,11 +38,10 @@ module Admin end def create - @order_cycle_form = OrderCycleForm.new(@order_cycle, params) + @order_cycle_form = OrderCycleForm.new(@order_cycle, params, spree_current_user) if @order_cycle_form.save OpenFoodNetwork::OrderCycleFormApplicator.new(@order_cycle, spree_current_user).go! - invoke_callbacks(:create, :after) flash[:notice] = I18n.t(:order_cycles_create_notice) render json: { success: true } else @@ -56,14 +50,13 @@ module Admin end def update - @order_cycle_form = OrderCycleForm.new(@order_cycle, params) + @order_cycle_form = OrderCycleForm.new(@order_cycle, params, spree_current_user) if @order_cycle_form.save 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) flash[:notice] = I18n.t(:order_cycles_update_notice) if params[:reloading] == '1' render json: { :success => true } else @@ -182,29 +175,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] }) diff --git a/app/services/order_cycle_form.rb b/app/services/order_cycle_form.rb index c60c21fc02..ca983280b5 100644 --- a/app/services/order_cycle_form.rb +++ b/app/services/order_cycle_form.rb @@ -1,13 +1,50 @@ -class OrderCycleForm - attr_accessor :order_cycle, :params +require 'open_food_network/permissions' +require 'open_food_network/proxy_order_syncer' - def initialize(order_cycle, params) +class OrderCycleForm + def initialize(order_cycle, params, user) @order_cycle = order_cycle @params = params + @permissions = OpenFoodNetwork::Permissions.new(user) end def save + check_editable_schedule_ids order_cycle.assign_attributes(params[:order_cycle]) - order_cycle.save + return false unless order_cycle.valid? + order_cycle.transaction do + order_cycle.save! + sync_subscriptions + true + end + rescue ActiveRecord::RecordInvalid + false + end + + private + + attr_accessor :order_cycle, :params, :permissions + + 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(permissions.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 end diff --git a/spec/controllers/admin/order_cycles_controller_spec.rb b/spec/controllers/admin/order_cycles_controller_spec.rb index 4c845239dc..ec2147587f 100644 --- a/spec/controllers/admin/order_cycles_controller_spec.rb +++ b/spec/controllers/admin/order_cycles_controller_spec.rb @@ -211,46 +211,6 @@ module Admin end end - describe "updating schedules" do - let(:user) { create(:user, enterprise_limit: 10) } - let!(:managed_coordinator) { create(:enterprise, owner: user) } - let!(:managed_enterprise) { create(:enterprise, owner: user) } - let!(:coordinated_order_cycle) { create(:simple_order_cycle, coordinator: managed_coordinator ) } - let!(:coordinated_order_cycle2) { create(:simple_order_cycle, coordinator: managed_enterprise ) } - let!(:uncoordinated_order_cycle) { create(:simple_order_cycle, coordinator: create(:enterprise) ) } - let!(:coordinated_schedule) { create(:schedule, order_cycles: [coordinated_order_cycle] ) } - let!(:coordinated_schedule2) { create(:schedule, order_cycles: [coordinated_order_cycle2] ) } - let!(:uncoordinated_schedule) { create(:schedule, order_cycles: [uncoordinated_order_cycle] ) } - - context "where I manage the order_cycle's coordinator" do - render_views - - before do - controller.stub spree_current_user: user - end - - it "allows me to assign only schedules that already I coordinate to the order cycle" do - schedule_ids = [coordinated_schedule2.id, uncoordinated_schedule.id] - spree_put :update, format: :json, id: coordinated_order_cycle.id, order_cycle: { schedule_ids: schedule_ids } - expect(assigns(:order_cycle)).to eq coordinated_order_cycle - # coordinated_order_cycle2 is added - expect(coordinated_order_cycle.reload.schedules).to include coordinated_schedule2 - # coordinated_order_cycle is removed, uncoordinated_order_cycle is NOT added - expect(coordinated_order_cycle.reload.schedules).to_not include coordinated_schedule, uncoordinated_schedule - end - - it "syncs proxy orders when schedule_ids change" do - syncer_mock = double(:syncer) - allow(OpenFoodNetwork::ProxyOrderSyncer).to receive(:new) { syncer_mock } - expect(syncer_mock).to receive(:sync!).exactly(2).times - - spree_put :update, format: :json, id: coordinated_order_cycle.id, order_cycle: { schedule_ids: [coordinated_schedule.id, coordinated_schedule2.id] } - spree_put :update, format: :json, id: coordinated_order_cycle.id, order_cycle: { schedule_ids: [coordinated_schedule.id] } - spree_put :update, format: :json, id: coordinated_order_cycle.id, order_cycle: { schedule_ids: [coordinated_schedule.id] } - end - end - end - describe "bulk_update" do let(:oc) { create(:simple_order_cycle) } let!(:coordinator) { oc.coordinator } diff --git a/spec/services/order_cycle_form_spec.rb b/spec/services/order_cycle_form_spec.rb index ae6014ecfb..4cd312f63a 100644 --- a/spec/services/order_cycle_form_spec.rb +++ b/spec/services/order_cycle_form_spec.rb @@ -3,7 +3,7 @@ describe OrderCycleForm do describe "creating a new order cycle from params" do let(:shop) { create(:enterprise) } let(:order_cycle) { OrderCycle.new } - let(:form) { OrderCycleForm.new(order_cycle, params) } + let(:form) { OrderCycleForm.new(order_cycle, params, shop.owner) } context "when creation is successful" do let(:params) { { order_cycle: { name: "Test Order Cycle", coordinator_id: shop.id } } } @@ -29,7 +29,7 @@ describe OrderCycleForm do describe "updating an existing order cycle from params" do let(:shop) { create(:enterprise) } let(:order_cycle) { create(:simple_order_cycle, name: "Old Name") } - let(:form) { OrderCycleForm.new(order_cycle, params) } + let(:form) { OrderCycleForm.new(order_cycle, params, shop.owner) } context "when update is successful" do let(:params) { { order_cycle: { name: "Test Order Cycle", coordinator_id: shop.id } } } @@ -52,4 +52,57 @@ describe OrderCycleForm do end end end + + describe "updating schedules" do + let(:user) { create(:user, enterprise_limit: 10) } + let!(:managed_coordinator) { create(:enterprise, owner: user) } + let!(:managed_enterprise) { create(:enterprise, owner: user) } + let!(:coordinated_order_cycle) { create(:simple_order_cycle, coordinator: managed_coordinator ) } + let!(:coordinated_order_cycle2) { create(:simple_order_cycle, coordinator: managed_enterprise ) } + let!(:uncoordinated_order_cycle) { create(:simple_order_cycle, coordinator: create(:enterprise) ) } + let!(:coordinated_schedule) { create(:schedule, order_cycles: [coordinated_order_cycle] ) } + let!(:coordinated_schedule2) { create(:schedule, order_cycles: [coordinated_order_cycle2] ) } + let!(:uncoordinated_schedule) { create(:schedule, order_cycles: [uncoordinated_order_cycle] ) } + + context "where I manage the order_cycle's coordinator" do + let(:form) { OrderCycleForm.new(coordinated_order_cycle, params, user) } + let(:syncer_mock) { instance_double(OpenFoodNetwork::ProxyOrderSyncer, sync!: true) } + + before do + allow(OpenFoodNetwork::ProxyOrderSyncer).to receive(:new) { syncer_mock } + end + + context "and I add an schedule that I own, and remove another that I own" do + let(:params) { { order_cycle: { schedule_ids: [coordinated_schedule2.id] } } } + + it "associates the order cycle to the schedule" do + expect(form.save).to be true + expect(coordinated_order_cycle.reload.schedules).to include coordinated_schedule2 + expect(coordinated_order_cycle.reload.schedules).to_not include coordinated_schedule + expect(syncer_mock).to have_received(:sync!) + end + end + + context "and I add a schedule that I don't own" do + let(:params) { { order_cycle: { schedule_ids: [coordinated_schedule.id, uncoordinated_schedule.id] } } } + + it "ignores the schedule that I don't own" do + expect(form.save).to be true + expect(coordinated_order_cycle.reload.schedules).to include coordinated_schedule + expect(coordinated_order_cycle.reload.schedules).to_not include uncoordinated_schedule + expect(syncer_mock).to_not have_received(:sync!) + end + end + + context "when I make no changes to the schedule ids" do + let(:params) { { order_cycle: { schedule_ids: [coordinated_schedule.id] } } } + + it "ignores the schedule that I don't own" do + expect(form.save).to be true + expect(coordinated_order_cycle.reload.schedules).to include coordinated_schedule + expect(syncer_mock).to_not have_received(:sync!) + end + end + end + end end From f88f4a5791861186877aff172b0f767971a50ec4 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Thu, 8 Mar 2018 17:23:17 +1100 Subject: [PATCH 202/206] Refactor OrderCycleForm to make logic clearer --- app/services/order_cycle_form.rb | 60 ++++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/app/services/order_cycle_form.rb b/app/services/order_cycle_form.rb index ca983280b5..8bf116869e 100644 --- a/app/services/order_cycle_form.rb +++ b/app/services/order_cycle_form.rb @@ -9,7 +9,7 @@ class OrderCycleForm end def save - check_editable_schedule_ids + build_schedule_ids order_cycle.assign_attributes(params[:order_cycle]) return false unless order_cycle.valid? order_cycle.transaction do @@ -25,26 +25,50 @@ class OrderCycleForm attr_accessor :order_cycle, :params, :permissions - 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(permissions.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 + def schedule_ids? + params[:order_cycle][:schedule_ids].present? + end + + def build_schedule_ids + return unless schedule_ids? + result = existing_schedule_ids + result |= (requested_schedule_ids & permitted_schedule_ids) # Add permitted and requested + result -= ((result & permitted_schedule_ids) - requested_schedule_ids) # Remove permitted but not 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 + return unless schedule_ids? + return unless schedule_sync_required? + OpenFoodNetwork::ProxyOrderSyncer.new(subscriptions_to_sync).sync! + end + + def schedule_sync_required? + removed_schedule_ids.any? || new_schedule_ids.any? + end + + def subscriptions_to_sync + Subscription.where(schedule_id: removed_schedule_ids + new_schedule_ids) + end + + def requested_schedule_ids + params[:order_cycle][:schedule_ids].map(&:to_i) + end + + def permitted_schedule_ids + Schedule.where(id: requested_schedule_ids | existing_schedule_ids) + .merge(permissions.editable_schedules).pluck(:id) + end + + def existing_schedule_ids + @existing_schedule_ids ||= order_cycle.persisted? ? order_cycle.schedule_ids : [] + end + + def removed_schedule_ids + existing_schedule_ids - order_cycle.schedule_ids + end + + def new_schedule_ids + @order_cycle.schedule_ids - existing_schedule_ids end end From 25525ae30b6d014d04cd37ba3775e587744e8b15 Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Fri, 9 Mar 2018 12:01:01 +1100 Subject: [PATCH 203/206] Move applicator calls to OrderCycleForm --- .../admin/order_cycles_controller.rb | 7 - app/services/order_cycle_form.rb | 16 ++- .../admin/order_cycles_controller_spec.rb | 123 +++++++++--------- spec/services/order_cycle_form_spec.rb | 32 +++++ 4 files changed, 109 insertions(+), 69 deletions(-) diff --git a/app/controllers/admin/order_cycles_controller.rb b/app/controllers/admin/order_cycles_controller.rb index ff19519d37..289dd1cf71 100644 --- a/app/controllers/admin/order_cycles_controller.rb +++ b/app/controllers/admin/order_cycles_controller.rb @@ -1,5 +1,3 @@ -require 'open_food_network/order_cycle_form_applicator' - module Admin class OrderCyclesController < ResourceController include OrderCyclesHelper @@ -41,7 +39,6 @@ module Admin @order_cycle_form = OrderCycleForm.new(@order_cycle, params, spree_current_user) if @order_cycle_form.save - OpenFoodNetwork::OrderCycleFormApplicator.new(@order_cycle, spree_current_user).go! flash[:notice] = I18n.t(:order_cycles_create_notice) render json: { success: true } else @@ -53,10 +50,6 @@ module Admin @order_cycle_form = OrderCycleForm.new(@order_cycle, params, spree_current_user) if @order_cycle_form.save - 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 flash[:notice] = I18n.t(:order_cycles_update_notice) if params[:reloading] == '1' render json: { :success => true } else diff --git a/app/services/order_cycle_form.rb b/app/services/order_cycle_form.rb index 8bf116869e..d463badfa4 100644 --- a/app/services/order_cycle_form.rb +++ b/app/services/order_cycle_form.rb @@ -1,10 +1,12 @@ require 'open_food_network/permissions' require 'open_food_network/proxy_order_syncer' +require 'open_food_network/order_cycle_form_applicator' class OrderCycleForm def initialize(order_cycle, params, user) @order_cycle = order_cycle @params = params + @user = user @permissions = OpenFoodNetwork::Permissions.new(user) end @@ -14,6 +16,7 @@ class OrderCycleForm return false unless order_cycle.valid? order_cycle.transaction do order_cycle.save! + apply_exchange_changes sync_subscriptions true end @@ -23,7 +26,18 @@ class OrderCycleForm private - attr_accessor :order_cycle, :params, :permissions + attr_accessor :order_cycle, :params, :user, :permissions + + def apply_exchange_changes + return if exchanges_unchanged? + OpenFoodNetwork::OrderCycleFormApplicator.new(order_cycle, user).go! + end + + def exchanges_unchanged? + [:incoming_exchanges, :outgoing_exchanges].all? do |direction| + params[:order_cycle][direction].nil? + end + end def schedule_ids? params[:order_cycle][:schedule_ids].present? diff --git a/spec/controllers/admin/order_cycles_controller_spec.rb b/spec/controllers/admin/order_cycles_controller_spec.rb index ec2147587f..853ae6a174 100644 --- a/spec/controllers/admin/order_cycles_controller_spec.rb +++ b/spec/controllers/admin/order_cycles_controller_spec.rb @@ -131,6 +131,51 @@ module Admin end describe "update" do + let(:order_cycle) { create(:simple_order_cycle) } + let(:coordinator) { order_cycle.coordinator } + let(:form_mock) { instance_double(OrderCycleForm) } + + before do + allow(OrderCycleForm).to receive(:new) { form_mock } + end + + context "as a manager of the coordinator" do + before { login_as_enterprise_user([coordinator]) } + let(:params) { { format: :json, id: order_cycle.id, order_cycle: {} } } + + context "when updating succeeds" do + before { allow(form_mock).to receive(:save) { true } } + + context "when the page is reloading" do + before { params[:reloading] = '1' } + + it "sets flash message" do + spree_put :update, params + flash[:notice].should == 'Your order cycle has been updated.' + end + end + + context "when the page is not reloading" do + it "does not set flash message" do + spree_put :update, params + flash[:notice].should be nil + end + end + end + + context "when a validation error occurs" do + before { allow(form_mock).to receive(:save) { false } } + + it "returns an error message" do + spree_put :update, params + json_response = JSON.parse(response.body) + expect(json_response['errors']).to be + end + end + end + end + + describe "limiting update scope" do let(:order_cycle) { create(:simple_order_cycle) } let(:producer) { create(:supplier_enterprise) } let(:coordinator) { order_cycle.coordinator } @@ -139,74 +184,30 @@ module Admin let!(:incoming_exchange) { create(:exchange, order_cycle: order_cycle, sender: producer, receiver: coordinator, incoming: true, variants: [v]) } let!(:outgoing_exchange) { create(:exchange, order_cycle: order_cycle, sender: coordinator, receiver: hub, incoming: false, variants: [v]) } + let(:allowed) { { incoming_exchanges: [], outgoing_exchanges: [] } } + let(:restricted) { { name: 'some name', orders_open_at: 1.day.from_now, orders_close_at: 1.day.ago } } + let(:params) { { format: :json, id: order_cycle.id, order_cycle: allowed.merge(restricted) } } + let(:form_mock) { instance_double(OrderCycleForm, save: true) } + + before { allow(controller).to receive(:spree_current_user) { user } } + context "as a manager of the coordinator" do - before { login_as_enterprise_user([coordinator]) } + let(:user) { coordinator.owner } + let(:expected) { [order_cycle, hash_including(order_cycle: allowed.merge(restricted)), user] } - it "sets flash message when page is reloading" do - spree_put :update, id: order_cycle.id, reloading: '1', order_cycle: {} - flash[:notice].should == 'Your order cycle has been updated.' - end - - it "does not set flash message otherwise" do - flash[:notice].should be_nil - end - - context "when updating without explicitly submitting exchanges" do - let(:form_applicator_mock) { double(:form_applicator) } - - before do - allow(OpenFoodNetwork::OrderCycleFormApplicator).to receive(:new) { form_applicator_mock } - allow(form_applicator_mock).to receive(:go!) { nil } - end - - it "does not run the OrderCycleFormApplicator" do - expect(order_cycle.exchanges.incoming).to eq [incoming_exchange] - expect(order_cycle.exchanges.outgoing).to eq [outgoing_exchange] - expect(order_cycle.prefers_product_selection_from_coordinator_inventory_only?).to be false - spree_put :update, id: order_cycle.id, order_cycle: { name: 'Some new name', preferred_product_selection_from_coordinator_inventory_only: true } - expect(form_applicator_mock).to_not have_received(:go!) - order_cycle.reload - expect(order_cycle.exchanges.incoming).to eq [incoming_exchange] - expect(order_cycle.exchanges.outgoing).to eq [outgoing_exchange] - expect(order_cycle.name).to eq 'Some new name' - expect(order_cycle.prefers_product_selection_from_coordinator_inventory_only?).to be true - end - end - - context "when a validation error occurs" do - let(:params) { - { - format: :json, - id: order_cycle.id, - order_cycle: { orders_open_at: order_cycle.orders_close_at + 1.day } - } - } - - it "returns an error message" do - spree_put :update, params - json_response = JSON.parse(response.body) - expect(json_response['errors']).to be_present - end + it "allows me to update exchange information for exchanges, name and dates" do + expect(OrderCycleForm).to receive(:new).with(*expected) { form_mock } + spree_put :update, params end end context "as a producer supplying to an order cycle" do - before do - login_as_enterprise_user [producer] - end + let(:user) { producer.owner } + let(:expected) { [order_cycle, hash_including(order_cycle: allowed), user] } - describe "removing a variant from incoming" do - let(:params) do - {order_cycle: { - incoming_exchanges: [{id: incoming_exchange.id, enterprise_id: producer.id, sender_id: producer.id, variants: {v.id => false}}], - outgoing_exchanges: [{id: outgoing_exchange.id, enterprise_id: hub.id, receiver_id: hub.id, variants: {v.id => false}}] } - } - end - - it "removes the variant from outgoing also" do - spree_put :update, {id: order_cycle.id}.merge(params) - Exchange.where(order_cycle_id: order_cycle).with_variant(v).should be_empty - end + it "allows me to update exchange information for exchanges, but not name or dates" do + expect(OrderCycleForm).to receive(:new).with(*expected) { form_mock } + spree_put :update, params end end end diff --git a/spec/services/order_cycle_form_spec.rb b/spec/services/order_cycle_form_spec.rb index 4cd312f63a..476adb3e39 100644 --- a/spec/services/order_cycle_form_spec.rb +++ b/spec/services/order_cycle_form_spec.rb @@ -105,4 +105,36 @@ describe OrderCycleForm do end end end + + describe "updating exchanges" do + let(:user) { instance_double(Spree::User) } + let(:order_cycle) { create(:simple_order_cycle) } + let(:form_applicator_mock) { instance_double(OpenFoodNetwork::OrderCycleFormApplicator) } + let(:form) { OrderCycleForm.new(order_cycle, params, user) } + let(:params) { { order_cycle: { name: 'Some new name' } } } + + before do + allow(OpenFoodNetwork::OrderCycleFormApplicator).to receive(:new) { form_applicator_mock } + allow(form_applicator_mock).to receive(:go!) + end + + context "when exchange params are provided" do + let(:exchange_params) { { incoming_exchanges: [], outgoing_exchanges: [] } } + before { params[:order_cycle].merge!(exchange_params) } + + it "runs the OrderCycleFormApplicator, and saves other changes" do + expect(form.save).to be true + expect(form_applicator_mock).to have_received(:go!) + expect(order_cycle.name).to eq 'Some new name' + end + end + + context "when no exchange params are provided" do + it "does not run the OrderCycleFormApplicator, but saves other changes" do + expect(form.save).to be true + expect(form_applicator_mock).to_not have_received(:go!) + expect(order_cycle.name).to eq 'Some new name' + end + end + end end From cf9f8edcce4b9cd650256f33067c2f299f6b712d Mon Sep 17 00:00:00 2001 From: Rob Harrington Date: Wed, 13 Jun 2018 14:56:35 +1000 Subject: [PATCH 204/206] Allow html requests for OrderCycleController#update This is still used from the Advanced Settings page, to update the order_cycle --- app/controllers/admin/order_cycles_controller.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/controllers/admin/order_cycles_controller.rb b/app/controllers/admin/order_cycles_controller.rb index 289dd1cf71..ad728c2185 100644 --- a/app/controllers/admin/order_cycles_controller.rb +++ b/app/controllers/admin/order_cycles_controller.rb @@ -50,8 +50,11 @@ module Admin @order_cycle_form = OrderCycleForm.new(@order_cycle, params, spree_current_user) if @order_cycle_form.save - flash[:notice] = I18n.t(:order_cycles_update_notice) if params[:reloading] == '1' - render json: { :success => true } + 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 } } + end else render json: { errors: @order_cycle.errors.full_messages }, status: :unprocessable_entity end From f626a21c5e86962ef72185d4b6f3b7c27e9e14f8 Mon Sep 17 00:00:00 2001 From: Keir Osborn Date: Fri, 22 Dec 2017 11:29:12 +0000 Subject: [PATCH 205/206] embedded groups initial test --- .../consumer/shopping/embedded_groups_spec.rb | 33 +++++++++++++++++++ spec/support/views/group_iframe_test.html | 8 +++++ 2 files changed, 41 insertions(+) create mode 100644 spec/features/consumer/shopping/embedded_groups_spec.rb create mode 100644 spec/support/views/group_iframe_test.html diff --git a/spec/features/consumer/shopping/embedded_groups_spec.rb b/spec/features/consumer/shopping/embedded_groups_spec.rb new file mode 100644 index 0000000000..789d28f007 --- /dev/null +++ b/spec/features/consumer/shopping/embedded_groups_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' + +feature "Using embedded shopfront functionality", js: true do + + Capybara.server_port = 9999 + + describe 'embedded groups' do + let(:enterprise) { create(:distributor_enterprise) } + let!(:group) { create(:enterprise_group, enterprises: [enterprise], permalink: 'group1', on_front_page: true) } + + + before do + Spree::Config[:enable_embedded_shopfronts] = true + Spree::Config[:embedded_shopfronts_whitelist] = 'localhost' + + page.driver.browser.js_errors = false + Capybara.current_session.driver.visit('spec/support/views/group_iframe_test.html') + + end + + it "displays in an iframe" do + expect(page).to have_selector 'iframe#group_test_iframe' + + within_frame 'group_test_iframe' do + within 'div#group-page' do + expect(page).to have_content 'About Us' + end + end + end + + end + +end \ No newline at end of file diff --git a/spec/support/views/group_iframe_test.html b/spec/support/views/group_iframe_test.html new file mode 100644 index 0000000000..f5ebb1a491 --- /dev/null +++ b/spec/support/views/group_iframe_test.html @@ -0,0 +1,8 @@ + + + + + + + + From bd7e0729385d275f8bb02cfec1e011c0c84ab7ca Mon Sep 17 00:00:00 2001 From: Keir Osborn Date: Tue, 13 Feb 2018 10:44:03 +0000 Subject: [PATCH 206/206] embedded groups layout changes --- .../group_page_controller.js.coffee | 1 + .../directives/target_blank.js.coffee | 6 ++ .../services/enterprise_modal.js.coffee | 1 + .../partials/enterprise_header.html.haml | 2 +- .../templates/partials/hub_details.html.haml | 2 +- .../darkswarm/embedded_shopfront.css.scss | 29 +++++++ app/views/groups/show.html.haml | 21 +++-- app/views/producers/_fat.html.haml | 2 +- app/views/producers/_skinny.html.haml | 2 +- app/views/shops/_skinny.html.haml | 6 +- .../consumer/shopping/embedded_groups_spec.rb | 85 ++++++++++++++----- spec/support/views/group_iframe_test.html | 2 +- 12 files changed, 123 insertions(+), 36 deletions(-) create mode 100644 app/assets/javascripts/darkswarm/directives/target_blank.js.coffee diff --git a/app/assets/javascripts/darkswarm/controllers/group_page_controller.js.coffee b/app/assets/javascripts/darkswarm/controllers/group_page_controller.js.coffee index 5d9bb0aa02..da67840752 100644 --- a/app/assets/javascripts/darkswarm/controllers/group_page_controller.js.coffee +++ b/app/assets/javascripts/darkswarm/controllers/group_page_controller.js.coffee @@ -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 \ No newline at end of file diff --git a/app/assets/javascripts/darkswarm/directives/target_blank.js.coffee b/app/assets/javascripts/darkswarm/directives/target_blank.js.coffee new file mode 100644 index 0000000000..f7d52f07bf --- /dev/null +++ b/app/assets/javascripts/darkswarm/directives/target_blank.js.coffee @@ -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") \ No newline at end of file diff --git a/app/assets/javascripts/darkswarm/services/enterprise_modal.js.coffee b/app/assets/javascripts/darkswarm/services/enterprise_modal.js.coffee index a0fb3ce5c5..ae43c85090 100644 --- a/app/assets/javascripts/darkswarm/services/enterprise_modal.js.coffee +++ b/app/assets/javascripts/darkswarm/services/enterprise_modal.js.coffee @@ -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) diff --git a/app/assets/javascripts/templates/partials/enterprise_header.html.haml b/app/assets/javascripts/templates/partials/enterprise_header.html.haml index 066ebdd957..bb07fdd019 100644 --- a/app/assets/javascripts/templates/partials/enterprise_header.html.haml +++ b/app/assets/javascripts/templates/partials/enterprise_header.html.haml @@ -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}"} diff --git a/app/assets/javascripts/templates/partials/hub_details.html.haml b/app/assets/javascripts/templates/partials/hub_details.html.haml index 5fb0c93696..baa9055305 100644 --- a/app/assets/javascripts/templates/partials/hub_details.html.haml +++ b/app/assets/javascripts/templates/partials/hub_details.html.haml @@ -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"} diff --git a/app/assets/stylesheets/darkswarm/embedded_shopfront.css.scss b/app/assets/stylesheets/darkswarm/embedded_shopfront.css.scss index b29acab34e..bc02417b55 100644 --- a/app/assets/stylesheets/darkswarm/embedded_shopfront.css.scss +++ b/app/assets/stylesheets/darkswarm/embedded_shopfront.css.scss @@ -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; diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 90b74c0b42..bd15ce9a76 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -31,7 +31,7 @@ .small-12.columns.pad-top .row - .small-12.medium-12.large-9.columns + .small-12.medium-12.large-9.embedded-fullwidth.columns %div{"ng-controller" => "GroupTabsCtrl"} %tabset %tab{heading: t(:label_map), @@ -48,9 +48,10 @@ %tab{heading: t(:groups_about), active: "tabs.about.active", select: "select(\'about\')"} - %h1 - = t :groups_about - %p!= @group.long_description + .about{ "embedded_target_blank" => true } + %h1 + = t :groups_about + %p!= @group.long_description %tab{heading: t(:groups_producers), active: "tabs.producers.active", @@ -103,7 +104,7 @@ = render 'shared/components/enterprise_no_results' - .small-12.medium-12.large-3.columns + .small-12.medium-12.large-3.columns.contact = render 'contact' .small-12.columns.pad-top @@ -119,7 +120,11 @@ %i.ofn-i_050-mail-circle =link_to_service "http://", @group.website, title: t(:groups_contact_website) do %i.ofn-i_049-web - %p -   + .powered-by-embedded + %img{src: '/favicon.ico'} + %span + = t 'powered_by' + %span + = t 'title' -= render "shared/footer" += render "shared/footer" \ No newline at end of file diff --git a/app/views/producers/_fat.html.haml b/app/views/producers/_fat.html.haml index 8ef6282cb7..190e244bb5 100644 --- a/app/views/producers/_fat.html.haml +++ b/app/views/producers/_fat.html.haml @@ -76,7 +76,7 @@ .row.cta-container .columns.small-12 %a.cta-hub{"ng-repeat" => "hub in producer.hubs | visible | orderBy:'-active'", - "ng-href" => "{{::hub.path}}", "ofn-change-hub" => "hub", + "ng-href" => "{{::hub.path}}", "ng-attr-target" => "{{ embedded_layout ? '_blank' : undefined }}", "ofn-change-hub" => "hub", "ng-class" => "::{primary: hub.active, secondary: !hub.active}"} %i.ofn-i_068-shop-reversed{"ng-if" => "::hub.active"} %i.ofn-i_068-shop-reversed{"ng-if" => "::!hub.active"} diff --git a/app/views/producers/_skinny.html.haml b/app/views/producers/_skinny.html.haml index 8c0a71e7e3..9771576842 100644 --- a/app/views/producers/_skinny.html.haml +++ b/app/views/producers/_skinny.html.haml @@ -1,7 +1,7 @@ .row.active_table_row{"ng-click" => "toggle($event)", "ng-class" => "{'closed' : !open(), 'is_distributor' : producer.is_distributor}"} .columns.small-12.medium-8.large-8.skinny-head %span{"ng-if" => "::producer.is_distributor" } - %a.is_distributor{"ng-href" => "{{::producer.path}}"} + %a.is_distributor{"ng-href" => "{{::producer.path}}", "ng-attr-target" => "{{ embedded_layout ? '_blank' : undefined}}"} .row.vertical-align-middle .columns.small-2.medium-2.large-2 %i{ng: {class: "::producer.producer_icon_font"}} diff --git a/app/views/shops/_skinny.html.haml b/app/views/shops/_skinny.html.haml index 1a5627bd26..6a67f4eed9 100644 --- a/app/views/shops/_skinny.html.haml +++ b/app/views/shops/_skinny.html.haml @@ -1,6 +1,6 @@ .row.active_table_row{"ng-if" => "hub.is_distributor", "ng-click" => "toggle($event)", "ng-class" => "{'closed' : !open(), 'is_distributor' : producer.is_distributor}"} .columns.small-12.medium-5.large-5.skinny-head - %a.hub{"ng-href" => "{{::hub.path}}", "ng-class" => "{primary: hub.active, secondary: !hub.active}", "ofn-change-hub" => "hub", "data-is-link" => "true"} + %a.hub{"ng-href" => "{{::hub.path}}", "ng-attr-target" => "{{ embedded_layout ? '_blank' : undefined}}", "ng-class" => "{primary: hub.active, secondary: !hub.active}", "ofn-change-hub" => "hub", "data-is-link" => "true"} %i{ng: {class: "::hub.icon_font"}} %span.margin-top.hub-name-listing{"ng-bind" => "::hub.name | truncate:40"} @@ -11,7 +11,7 @@ %span.margin-top{"ng-if" => "hub.distance != null && hub.distance > 0"} ({{ hub.distance / 1000 | number:0 }} km) .columns.small-4.medium-3.large-3.text-right{"ng-if" => "::hub.active"} - %a.hub.open_closed{"ng-href" => "{{::hub.path}}", "ng-class" => "{primary: hub.active, secondary: !hub.active}", "ofn-change-hub" => "hub"} + %a.hub.open_closed{"ng-href" => "{{::hub.path}}", "ng-attr-target" => "{{ embedded_layout ? '_blank' : undefined}}", "ng-class" => "{primary: hub.active, secondary: !hub.active}", "ofn-change-hub" => "hub"} %i.ofn-i_068-shop-reversed %span.margin-top{ ng: { if: "::current()" } } %em= t :hubs_shopping_here @@ -19,7 +19,7 @@ %span{"ng-bind" => "::hub.orders_close_at | sensible_timeframe"} .columns.small-4.medium-3.large-3.text-right{"ng-if" => "::!hub.active"} - %a.hub.open_closed{"ng-href" => "{{::hub.path}}", "ng-class" => "{primary: hub.active, secondary: !hub.active}", "ofn-change-hub" => "hub"} + %a.hub.open_closed{"ng-href" => "{{::hub.path}}", "ng-attr-target" => "{{ embedded_layout ? '_blank' : undefined}}", "ng-class" => "{primary: hub.active, secondary: !hub.active}", "ofn-change-hub" => "hub"} %span.margin-top{ ng: { if: "::current()" } } %em= t :hubs_shopping_here %span.margin-top{ ng: { if: "::!current()" } } diff --git a/spec/features/consumer/shopping/embedded_groups_spec.rb b/spec/features/consumer/shopping/embedded_groups_spec.rb index 789d28f007..4122fb30ce 100644 --- a/spec/features/consumer/shopping/embedded_groups_spec.rb +++ b/spec/features/consumer/shopping/embedded_groups_spec.rb @@ -5,29 +5,74 @@ feature "Using embedded shopfront functionality", js: true do Capybara.server_port = 9999 describe 'embedded groups' do - let(:enterprise) { create(:distributor_enterprise) } - let!(:group) { create(:enterprise_group, enterprises: [enterprise], permalink: 'group1', on_front_page: true) } - - - before do - Spree::Config[:enable_embedded_shopfronts] = true - Spree::Config[:embedded_shopfronts_whitelist] = 'localhost' + let(:enterprise) { create(:distributor_enterprise) } + let!(:group) { create(:enterprise_group, enterprises: [enterprise], permalink: 'group1', on_front_page: true) } + before do + Spree::Config[:enable_embedded_shopfronts] = true + Spree::Config[:embedded_shopfronts_whitelist] = 'test.com' page.driver.browser.js_errors = false - Capybara.current_session.driver.visit('spec/support/views/group_iframe_test.html') + allow_any_instance_of(ActionDispatch::Request).to receive(:referer).and_return('https://www.test.com') + Capybara.current_session.driver.visit('spec/support/views/group_iframe_test.html') + end - end + it "displays in an iframe" do + expect(page).to have_selector 'iframe#group_test_iframe' - it "displays in an iframe" do - expect(page).to have_selector 'iframe#group_test_iframe' + within_frame 'group_test_iframe' do + within 'div#group-page' do + expect(page).to have_content 'About Us' + end + end + end - within_frame 'group_test_iframe' do - within 'div#group-page' do - expect(page).to have_content 'About Us' - end - end - end - + it "displays powered by OFN text at bottom of page" do + expect(page).to have_selector 'iframe#group_test_iframe' + + within_frame 'group_test_iframe' do + within 'div#group-page' do + expect(page).to have_selector 'div.powered-by-embedded' + expect(page).to have_css "img[src*='favicon.ico']" + expect(page).to have_content 'Powered by' + expect(page).to have_content 'Open Food Network' + end + end + end + + it "doesn't display contact details when embedded" do + expect(page).to have_selector 'iframe#group_test_iframe' + + within_frame 'group_test_iframe' do + within 'div#group-page' do + + expect(page).to have_no_selector 'div.contact-container' + expect(page).to have_no_content '#{group.address.address1}' + end + end + end + + it "does not display the header when embedded" do + expect(page).to have_selector 'iframe#group_test_iframe' + + within_frame 'group_test_iframe' do + within 'div#group-page' do + expect(page).to have_no_selector 'header' + expect(page).to have_no_selector 'img.group-logo' + expect(page).to have_no_selector 'h2.group-name' + end + end + end + + it 'opens links to shops in a new window' do + expect(page).to have_selector 'iframe#group_test_iframe' + + within_frame 'group_test_iframe' do + within 'div#group-page' do + enterprise_links = page.all(:xpath, "//*[contains(@href, 'enterprise-5/shop')]", :visible => :false).count + enterprise_links_with_target_blank = page.all(:xpath, "//*[contains(@href, 'enterprise-5/shop') and @target = '_blank']", :visible => :false).count + expect(enterprise_links).to equal(enterprise_links_with_target_blank) + end + end + end end - -end \ No newline at end of file +end diff --git a/spec/support/views/group_iframe_test.html b/spec/support/views/group_iframe_test.html index f5ebb1a491..dcb4abbceb 100644 --- a/spec/support/views/group_iframe_test.html +++ b/spec/support/views/group_iframe_test.html @@ -2,7 +2,7 @@ - +