Compare commits

..

1 Commits

Author SHA1 Message Date
Rachel Arnould
b5020cc740 Update en.yml
Removing mentions about UFC Que choisir and changing the link
2024-11-25 13:31:36 +01:00
233 changed files with 2448 additions and 5559 deletions

View File

@@ -34,6 +34,12 @@ Lint/EmptyClass:
Exclude:
- 'spec/lib/reports/report_loader_spec.rb'
# Offense count: 1
# Configuration parameters: AllowComments.
Lint/EmptyFile:
Exclude:
- 'spec/lib/open_food_network/enterprise_injection_data_spec.rb'
# Offense count: 2
Lint/FloatComparison:
Exclude:
@@ -86,6 +92,7 @@ Metrics/AbcSize:
- 'app/controllers/admin/enterprises_controller.rb'
- 'app/controllers/payment_gateways/paypal_controller.rb'
- 'app/controllers/spree/admin/payments_controller.rb'
- 'app/controllers/spree/admin/taxons_controller.rb'
- 'app/controllers/spree/admin/variants_controller.rb'
- 'app/controllers/spree/orders_controller.rb'
- 'app/helpers/spree/admin/navigation_helper.rb'
@@ -120,7 +127,7 @@ Metrics/BlockNesting:
Exclude:
- 'app/models/spree/payment/processing.rb'
# Offense count: 47
# Offense count: 46
# Configuration parameters: CountComments, Max, CountAsOne.
Metrics/ClassLength:
Exclude:
@@ -130,7 +137,6 @@ Metrics/ClassLength:
- 'app/controllers/admin/resource_controller.rb'
- 'app/controllers/admin/subscriptions_controller.rb'
- 'app/controllers/application_controller.rb'
- 'app/controllers/checkout_controller.rb'
- 'app/controllers/payment_gateways/paypal_controller.rb'
- 'app/controllers/spree/admin/orders_controller.rb'
- 'app/controllers/spree/admin/payment_methods_controller.rb'
@@ -177,7 +183,7 @@ Metrics/ClassLength:
Metrics/CyclomaticComplexity:
Exclude:
- 'app/controllers/admin/enterprises_controller.rb'
- 'app/controllers/spree/admin/payments_controller.rb'
- 'app/controllers/spree/admin/taxons_controller.rb'
- 'app/controllers/spree/orders_controller.rb'
- 'app/helpers/checkout_helper.rb'
- 'app/helpers/order_cycles_helper.rb'
@@ -202,12 +208,13 @@ Metrics/CyclomaticComplexity:
- 'lib/spree/localized_number.rb'
- 'spec/models/product_importer_spec.rb'
# Offense count: 23
# Offense count: 24
# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns.
Metrics/MethodLength:
Exclude:
- 'app/controllers/admin/enterprises_controller.rb'
- 'app/controllers/payment_gateways/paypal_controller.rb'
- 'app/controllers/spree/admin/taxons_controller.rb'
- 'app/controllers/spree/orders_controller.rb'
- 'app/helpers/spree/admin/navigation_helper.rb'
- 'app/models/spree/ability.rb'
@@ -286,17 +293,19 @@ Metrics/ParameterLists:
- 'spec/support/controller_requests_helper.rb'
- 'spec/system/admin/reports_spec.rb'
# Offense count: 3
# Offense count: 4
# Configuration parameters: AllowedMethods, AllowedPatterns, Max.
Metrics/PerceivedComplexity:
Exclude:
- 'app/controllers/spree/admin/taxons_controller.rb'
- 'app/models/enterprise_relationship.rb'
- 'app/models/spree/ability.rb'
- 'app/models/spree/order/checkout.rb'
# Offense count: 7
# Offense count: 8
Naming/AccessorMethodName:
Exclude:
- 'app/controllers/spree/admin/taxonomies_controller.rb'
- 'app/mailers/producer_mailer.rb'
- 'app/models/spree/order.rb'
- 'app/services/checkout/post_checkout_actions.rb'
@@ -344,7 +353,7 @@ Naming/VariableNumber:
- 'spec/models/spree/tax_rate_spec.rb'
- 'spec/requests/api/orders_spec.rb'
# Offense count: 143
# Offense count: 142
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: ResponseMethods.
# ResponseMethods: response, last_response
@@ -548,7 +557,7 @@ RSpecRails/InferredSpecType:
- 'spec/requests/voucher_adjustments_spec.rb'
- 'spec/routing/stripe_spec.rb'
# Offense count: 21
# Offense count: 22
# Configuration parameters: IgnoreScopes, Include.
# Include: app/models/**/*.rb
Rails/InverseOf:
@@ -563,6 +572,7 @@ Rails/InverseOf:
- 'app/models/spree/price.rb'
- 'app/models/spree/product.rb'
- 'app/models/spree/stock_item.rb'
- 'app/models/spree/taxonomy.rb'
- 'app/models/spree/variant.rb'
- 'app/models/subscription_line_item.rb'
@@ -710,7 +720,7 @@ Style/GlobalStdStream:
- 'lib/tasks/subscriptions/debug.rake'
- 'lib/tasks/subscriptions/test.rake'
# Offense count: 10
# Offense count: 12
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: AllowSplatArgument.
Style/HashConversion:
@@ -718,7 +728,9 @@ Style/HashConversion:
- 'app/controllers/admin/column_preferences_controller.rb'
- 'app/controllers/admin/variant_overrides_controller.rb'
- 'app/controllers/spree/admin/products_controller.rb'
- 'app/models/order_cycle.rb'
- 'app/models/product_import/product_importer.rb'
- 'app/models/spree/shipping_method.rb'
- 'app/serializers/api/admin/exchange_serializer.rb'
- 'app/services/variants_stock_levels.rb'
- 'spec/controllers/admin/inventory_items_controller_spec.rb'

View File

@@ -89,4 +89,4 @@ RUN ./script/install-bundler
RUN yarn install
# Run bundler install in parallel with the amount of available CPUs
RUN bundle install --jobs="$(nproc)"
RUN bundle install --jobs="$(nproc)"

View File

@@ -86,7 +86,7 @@ gem "active_model_serializers", "0.8.4"
gem 'activerecord-session_store'
gem 'acts-as-taggable-on'
gem 'angularjs-file-upload-rails', '~> 2.4.1'
gem 'bigdecimal'
gem 'bigdecimal', '3.0.2'
gem 'bootsnap', require: false
gem 'geocoder'
gem 'gmaps4rails'

View File

@@ -180,7 +180,7 @@ GEM
base64 (0.2.0)
bcp47_spec (0.2.1)
bcrypt (3.1.20)
bigdecimal (3.1.8)
bigdecimal (3.0.2)
bindata (2.5.0)
bindex (0.8.1)
bootsnap (1.18.3)
@@ -191,7 +191,7 @@ GEM
bullet (7.1.6)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
cable_ready (5.0.6)
cable_ready (5.0.5)
actionpack (>= 5.2)
actionview (>= 5.2)
activesupport (>= 5.2)
@@ -755,16 +755,16 @@ GEM
state_machines-activerecord (0.9.0)
activerecord (>= 6.0)
state_machines-activemodel (>= 0.9.0)
stimulus_reflex (3.5.3)
actioncable (>= 5.2)
actionpack (>= 5.2)
actionview (>= 5.2)
activesupport (>= 5.2)
stimulus_reflex (3.5.1)
actioncable (>= 5.2, < 8)
actionpack (>= 5.2, < 8)
actionview (>= 5.2, < 8)
activesupport (>= 5.2, < 8)
cable_ready (~> 5.0)
nokogiri (~> 1.0)
nokogiri-html5-inference (~> 0.3)
rack (>= 2, < 4)
railties (>= 5.2)
railties (>= 5.2, < 8)
redis (>= 4.0, < 6.0)
stringex (2.8.6)
stringio (3.1.0)
@@ -865,7 +865,7 @@ DEPENDENCIES
angularjs-rails (= 1.8.0)
arel-helpers (~> 2.12)
aws-sdk-s3
bigdecimal
bigdecimal (= 3.0.2)
bootsnap
bugsnag
bullet

View File

@@ -1,32 +0,0 @@
FROM ruby:3.1.4-alpine3.19 AS base
ENV LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
TZ=Europe/London \
RAILS_ROOT=/usr/src/app
RUN apk --no-cache upgrade && \
apk add --no-cache tzdata postgresql-client imagemagick imagemagick-jpeg && \
apk add --no-cache --virtual wkhtmltopdf
WORKDIR $RAILS_ROOT
# Development dependencies
FROM base AS development-base
RUN apk add --no-cache --virtual .build-deps \
build-base postgresql-dev git nodejs yarn && \
apk add --no-cache --virtual .dev-utils \
bash curl less vim chromium-chromedriver zlib-dev openssl-dev \
readline-dev yaml-dev sqlite-dev libxml2-dev libxslt-dev libffi-dev vips-dev && \
curl -o /usr/local/bin/wait-for-it https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh && \
chmod +x /usr/local/bin/wait-for-it
# Install yarn dependencies separately for caching
FROM development-base AS yarn-dependencies
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
# Install Ruby gems
FROM development-base
COPY . $RAILS_ROOT
COPY Gemfile Gemfile.lock ./
RUN bundle install --jobs "$(nproc)"
COPY --from=yarn-dependencies $RAILS_ROOT/node_modules ./node_modules

View File

@@ -67,5 +67,8 @@
// foundation
//= require ../shared/mm-foundation-tpls-0.9.0-20180826174721.min.js
// LocalStorage
//= require ../shared/angular-local-storage.js
// requires the rest of the JS code in this folder
//= require_tree .

View File

@@ -307,6 +307,9 @@ filterSubmitProducts = (productsToFilter) ->
variantHasUpdatableProperty = result.hasUpdatableProperty
filteredVariants.push filteredVariant if variantHasUpdatableProperty
if product.hasOwnProperty("sku")
filteredProduct.sku = product.sku
hasUpdatableProperty = true
if product.hasOwnProperty("name")
filteredProduct.name = product.name
hasUpdatableProperty = true

View File

@@ -0,0 +1,29 @@
angular.module("admin.indexUtils").factory 'KeyValueMapStore', (localStorageService)->
new class KeyValueMapStore
localStorageKey: ''
storableKeys: []
constructor: ->
localStorageService.setStorageType("sessionStorage")
getStoredKeyValueMap: ->
localStorageService.get(@localStorageKey) || {}
setStoredValues: (source) ->
keyValueMap = {}
for key in @storableKeys
keyValueMap[key] = source[key]
localStorageService.set(@localStorageKey, keyValueMap)
restoreValues: (target) ->
storedKeyValueMap = @getStoredKeyValueMap()
return false if _.isEmpty(storedKeyValueMap)
for k,v of storedKeyValueMap
target[k] = v
return true
clearKeyValueMap: () ->
localStorageService.remove(@localStorageKey)

View File

@@ -17,6 +17,7 @@
#= require angular-google-maps.min.js
#= require ../shared/mm-foundation-tpls-0.9.0-20180826174721.min.js
#= require ../shared/ng-infinite-scroll.min.js
#= require ../shared/angular-local-storage.js
#= require ../shared/angular-slideables.js
#= require ../shared/shared
#= require_tree ../shared/directives

View File

@@ -1,6 +1,7 @@
angular.module("Darkswarm", [
'ngResource',
'mm.foundation',
'LocalStorageModule',
'infinite-scroll',
'angular-flash.service',
'templates',

View File

@@ -0,0 +1,10 @@
angular.module('Darkswarm').directive "ofnDisableScroll", ()->
# Stops scrolling from incrementing or decrementing input value
# Useful for number inputs
restrict: 'A'
link: (scope, element, attrs)->
element.bind 'focus', ->
element.bind 'mousewheel', (e)->
e.preventDefault()
element.bind 'blur', ->
element.unbind 'mousewheel'

View File

@@ -0,0 +1,5 @@
angular.module('Darkswarm').directive "integer", ->
restrict: 'A'
link: (scope, elem, attr) ->
elem.bind 'input', ->
elem.val Math.round(elem.val())

View File

@@ -20,13 +20,10 @@ angular.module('Darkswarm').directive 'mapSearch', ($timeout, Search) ->
$timeout =>
map = ctrl.getMap()
if !map
alert(t('gmap_load_failure'))
else
searchBox = scope.createSearchBox map
scope.bindSearchResponse map, searchBox
scope.biasResults map, searchBox
scope.performUrlSearch map
searchBox = scope.createSearchBox map
scope.bindSearchResponse map, searchBox
scope.biasResults map, searchBox
scope.performUrlSearch map
scope.createSearchBox = (map) ->
map.controls[google.maps.ControlPosition.TOP_LEFT].push scope.input

View File

@@ -0,0 +1,14 @@
angular.module('Darkswarm').directive "renderSvg", ()->
# Magical directive that'll render SVGs from URLs
# If only there were a neater way of doing this
restrict: 'E'
priority: 99
template: "<svg-wrapper></svg-wrapper>"
# Fetch SVG via ajax, inject into page using DOM
link: (scope, elem, attr)->
if /.svg/.test attr.path # Only do this if we've got an svg
$.ajax
url: attr.path
success: (html)->
elem.html($(html).find("svg"))

View File

@@ -0,0 +1,9 @@
angular.module('Darkswarm').directive "ofnScrollTo", ($location, $anchorScroll)->
# Onclick sets $location.hash to attrs.ofnScrollTo
# Then triggers anchorScroll
restrict: 'A'
link: (scope, element, attrs)->
element.bind 'click', (ev)->
ev.stopPropagation()
$location.hash attrs.ofnScrollTo
$anchorScroll()

View File

@@ -1,4 +1,4 @@
angular.module('Darkswarm').factory 'Cart', (CurrentOrder, Variants, $timeout, $http, $modal, $rootScope, $resource, Messages) ->
angular.module('Darkswarm').factory 'Cart', (CurrentOrder, Variants, $timeout, $http, $modal, $rootScope, $resource, localStorageService, Messages) ->
# Handles syncing of current cart/order state to server
new class Cart
dirty: false
@@ -113,6 +113,7 @@ angular.module('Darkswarm').factory 'Cart', (CurrentOrder, Variants, $timeout, $
clear: ->
@line_items = []
localStorageService.clearAll() # One day this will have to be moar GRANULAR
isOnlyItemInOrder: (id) =>
deletedItem = @line_items_finalised.find((item) -> item.id == id)

View File

@@ -0,0 +1,546 @@
/**
* An Angular module that gives you access to the browsers local storage
* @version v0.5.0 - 2016-08-29
* @link https://github.com/grevory/angular-local-storage
* @author grevory <greg@gregpike.ca>
* @license MIT License, http://www.opensource.org/licenses/MIT
*/
(function (window, angular) {
var isDefined = angular.isDefined,
isUndefined = angular.isUndefined,
isNumber = angular.isNumber,
isObject = angular.isObject,
isArray = angular.isArray,
extend = angular.extend,
toJson = angular.toJson;
angular
.module('LocalStorageModule', [])
.provider('localStorageService', function() {
// You should set a prefix to avoid overwriting any local storage variables from the rest of your app
// e.g. localStorageServiceProvider.setPrefix('yourAppName');
// With provider you can use config as this:
// myApp.config(function (localStorageServiceProvider) {
// localStorageServiceProvider.prefix = 'yourAppName';
// });
this.prefix = 'ls';
// You could change web storage type localstorage or sessionStorage
this.storageType = 'localStorage';
// Cookie options (usually in case of fallback)
// expiry = Number of days before cookies expire // 0 = Does not expire
// path = The web path the cookie represents
// secure = Wether the cookies should be secure (i.e only sent on HTTPS requests)
this.cookie = {
expiry: 30,
path: '/',
secure: false
};
// Decides wether we should default to cookies if localstorage is not supported.
this.defaultToCookie = true;
// Send signals for each of the following actions?
this.notify = {
setItem: true,
removeItem: false
};
// Setter for the prefix
this.setPrefix = function(prefix) {
this.prefix = prefix;
return this;
};
// Setter for the storageType
this.setStorageType = function(storageType) {
this.storageType = storageType;
return this;
};
// Setter for defaultToCookie value, default is true.
this.setDefaultToCookie = function (shouldDefault) {
this.defaultToCookie = !!shouldDefault; // Double-not to make sure it's a bool value.
return this;
};
// Setter for cookie config
this.setStorageCookie = function(exp, path, secure) {
this.cookie.expiry = exp;
this.cookie.path = path;
this.cookie.secure = secure;
return this;
};
// Setter for cookie domain
this.setStorageCookieDomain = function(domain) {
this.cookie.domain = domain;
return this;
};
// Setter for notification config
// itemSet & itemRemove should be booleans
this.setNotify = function(itemSet, itemRemove) {
this.notify = {
setItem: itemSet,
removeItem: itemRemove
};
return this;
};
this.$get = ['$rootScope', '$window', '$document', '$parse','$timeout', function($rootScope, $window, $document, $parse, $timeout) {
var self = this;
var prefix = self.prefix;
var cookie = self.cookie;
var notify = self.notify;
var storageType = self.storageType;
var webStorage;
// When Angular's $document is not available
if (!$document) {
$document = document;
} else if ($document[0]) {
$document = $document[0];
}
// If there is a prefix set in the config lets use that with an appended period for readability
if (prefix.substr(-1) !== '.') {
prefix = !!prefix ? prefix + '.' : '';
}
var deriveQualifiedKey = function(key) {
return prefix + key;
};
// Removes prefix from the key.
var underiveQualifiedKey = function (key) {
return key.replace(new RegExp('^' + prefix, 'g'), '');
};
// Check if the key is within our prefix namespace.
var isKeyPrefixOurs = function (key) {
return key.indexOf(prefix) === 0;
};
// Checks the browser to see if local storage is supported
var checkSupport = function () {
try {
var supported = (storageType in $window && $window[storageType] !== null);
// When Safari (OS X or iOS) is in private browsing mode, it appears as though localStorage
// is available, but trying to call .setItem throws an exception.
//
// "QUOTA_EXCEEDED_ERR: DOM Exception 22: An attempt was made to add something to storage
// that exceeded the quota."
var key = deriveQualifiedKey('__' + Math.round(Math.random() * 1e7));
if (supported) {
webStorage = $window[storageType];
webStorage.setItem(key, '');
webStorage.removeItem(key);
}
return supported;
} catch (e) {
// Only change storageType to cookies if defaulting is enabled.
if (self.defaultToCookie)
storageType = 'cookie';
$rootScope.$broadcast('LocalStorageModule.notification.error', e.message);
return false;
}
};
var browserSupportsLocalStorage = checkSupport();
// Directly adds a value to local storage
// If local storage is not available in the browser use cookies
// Example use: localStorageService.add('library','angular');
var addToLocalStorage = function (key, value, type) {
setStorageType(type);
// Let's convert undefined values to null to get the value consistent
if (isUndefined(value)) {
value = null;
} else {
value = toJson(value);
}
// If this browser does not support local storage use cookies
if (!browserSupportsLocalStorage && self.defaultToCookie || self.storageType === 'cookie') {
if (!browserSupportsLocalStorage) {
$rootScope.$broadcast('LocalStorageModule.notification.warning', 'LOCAL_STORAGE_NOT_SUPPORTED');
}
if (notify.setItem) {
$rootScope.$broadcast('LocalStorageModule.notification.setitem', {key: key, newvalue: value, storageType: 'cookie'});
}
return addToCookies(key, value);
}
try {
if (webStorage) {
webStorage.setItem(deriveQualifiedKey(key), value);
}
if (notify.setItem) {
$rootScope.$broadcast('LocalStorageModule.notification.setitem', {key: key, newvalue: value, storageType: self.storageType});
}
} catch (e) {
$rootScope.$broadcast('LocalStorageModule.notification.error', e.message);
return addToCookies(key, value);
}
return true;
};
// Directly get a value from local storage
// Example use: localStorageService.get('library'); // returns 'angular'
var getFromLocalStorage = function (key, type) {
setStorageType(type);
if (!browserSupportsLocalStorage && self.defaultToCookie || self.storageType === 'cookie') {
if (!browserSupportsLocalStorage) {
$rootScope.$broadcast('LocalStorageModule.notification.warning', 'LOCAL_STORAGE_NOT_SUPPORTED');
}
return getFromCookies(key);
}
var item = webStorage ? webStorage.getItem(deriveQualifiedKey(key)) : null;
// angular.toJson will convert null to 'null', so a proper conversion is needed
// FIXME not a perfect solution, since a valid 'null' string can't be stored
if (!item || item === 'null') {
return null;
}
try {
return JSON.parse(item);
} catch (e) {
return item;
}
};
// Remove an item from local storage
// Example use: localStorageService.remove('library'); // removes the key/value pair of library='angular'
//
// This is var-arg removal, check the last argument to see if it is a storageType
// and set type accordingly before removing.
//
var removeFromLocalStorage = function () {
// can't pop on arguments, so we do this
var consumed = 0;
if (arguments.length >= 1 &&
(arguments[arguments.length - 1] === 'localStorage' ||
arguments[arguments.length - 1] === 'sessionStorage')) {
consumed = 1;
setStorageType(arguments[arguments.length - 1]);
}
var i, key;
for (i = 0; i < arguments.length - consumed; i++) {
key = arguments[i];
if (!browserSupportsLocalStorage && self.defaultToCookie || self.storageType === 'cookie') {
if (!browserSupportsLocalStorage) {
$rootScope.$broadcast('LocalStorageModule.notification.warning', 'LOCAL_STORAGE_NOT_SUPPORTED');
}
if (notify.removeItem) {
$rootScope.$broadcast('LocalStorageModule.notification.removeitem', {key: key, storageType: 'cookie'});
}
removeFromCookies(key);
}
else {
try {
webStorage.removeItem(deriveQualifiedKey(key));
if (notify.removeItem) {
$rootScope.$broadcast('LocalStorageModule.notification.removeitem', {
key: key,
storageType: self.storageType
});
}
} catch (e) {
$rootScope.$broadcast('LocalStorageModule.notification.error', e.message);
removeFromCookies(key);
}
}
}
};
// Return array of keys for local storage
// Example use: var keys = localStorageService.keys()
var getKeysForLocalStorage = function (type) {
setStorageType(type);
if (!browserSupportsLocalStorage) {
$rootScope.$broadcast('LocalStorageModule.notification.warning', 'LOCAL_STORAGE_NOT_SUPPORTED');
return [];
}
var prefixLength = prefix.length;
var keys = [];
for (var key in webStorage) {
// Only return keys that are for this app
if (key.substr(0, prefixLength) === prefix) {
try {
keys.push(key.substr(prefixLength));
} catch (e) {
$rootScope.$broadcast('LocalStorageModule.notification.error', e.Description);
return [];
}
}
}
return keys;
};
// Remove all data for this app from local storage
// Also optionally takes a regular expression string and removes the matching key-value pairs
// Example use: localStorageService.clearAll();
// Should be used mostly for development purposes
var clearAllFromLocalStorage = function (regularExpression, type) {
setStorageType(type);
// Setting both regular expressions independently
// Empty strings result in catchall RegExp
var prefixRegex = !!prefix ? new RegExp('^' + prefix) : new RegExp();
var testRegex = !!regularExpression ? new RegExp(regularExpression) : new RegExp();
if (!browserSupportsLocalStorage && self.defaultToCookie || self.storageType === 'cookie') {
if (!browserSupportsLocalStorage) {
$rootScope.$broadcast('LocalStorageModule.notification.warning', 'LOCAL_STORAGE_NOT_SUPPORTED');
}
return clearAllFromCookies();
}
if (!browserSupportsLocalStorage && !self.defaultToCookie)
return false;
var prefixLength = prefix.length;
for (var key in webStorage) {
// Only remove items that are for this app and match the regular expression
if (prefixRegex.test(key) && testRegex.test(key.substr(prefixLength))) {
try {
removeFromLocalStorage(key.substr(prefixLength));
} catch (e) {
$rootScope.$broadcast('LocalStorageModule.notification.error', e.message);
return clearAllFromCookies();
}
}
}
return true;
};
// Checks the browser to see if cookies are supported
var browserSupportsCookies = (function() {
try {
return $window.navigator.cookieEnabled ||
("cookie" in $document && ($document.cookie.length > 0 ||
($document.cookie = "test").indexOf.call($document.cookie, "test") > -1));
} catch (e) {
$rootScope.$broadcast('LocalStorageModule.notification.error', e.message);
return false;
}
}());
// Directly adds a value to cookies
// Typically used as a fallback if local storage is not available in the browser
// Example use: localStorageService.cookie.add('library','angular');
var addToCookies = function (key, value, daysToExpiry, secure) {
if (isUndefined(value)) {
return false;
} else if(isArray(value) || isObject(value)) {
value = toJson(value);
}
if (!browserSupportsCookies) {
$rootScope.$broadcast('LocalStorageModule.notification.error', 'COOKIES_NOT_SUPPORTED');
return false;
}
try {
var expiry = '',
expiryDate = new Date(),
cookieDomain = '';
if (value === null) {
// Mark that the cookie has expired one day ago
expiryDate.setTime(expiryDate.getTime() + (-1 * 24 * 60 * 60 * 1000));
expiry = "; expires=" + expiryDate.toGMTString();
value = '';
} else if (isNumber(daysToExpiry) && daysToExpiry !== 0) {
expiryDate.setTime(expiryDate.getTime() + (daysToExpiry * 24 * 60 * 60 * 1000));
expiry = "; expires=" + expiryDate.toGMTString();
} else if (cookie.expiry !== 0) {
expiryDate.setTime(expiryDate.getTime() + (cookie.expiry * 24 * 60 * 60 * 1000));
expiry = "; expires=" + expiryDate.toGMTString();
}
if (!!key) {
var cookiePath = "; path=" + cookie.path;
if (cookie.domain) {
cookieDomain = "; domain=" + cookie.domain;
}
/* Providing the secure parameter always takes precedence over config
* (allows developer to mix and match secure + non-secure) */
if (typeof secure === 'boolean') {
if (secure === true) {
/* We've explicitly specified secure,
* add the secure attribute to the cookie (after domain) */
cookieDomain += "; secure";
}
// else - secure has been supplied but isn't true - so don't set secure flag, regardless of what config says
}
else if (cookie.secure === true) {
// secure parameter wasn't specified, get default from config
cookieDomain += "; secure";
}
$document.cookie = deriveQualifiedKey(key) + "=" + encodeURIComponent(value) + expiry + cookiePath + cookieDomain;
}
} catch (e) {
$rootScope.$broadcast('LocalStorageModule.notification.error', e.message);
return false;
}
return true;
};
// Directly get a value from a cookie
// Example use: localStorageService.cookie.get('library'); // returns 'angular'
var getFromCookies = function (key) {
if (!browserSupportsCookies) {
$rootScope.$broadcast('LocalStorageModule.notification.error', 'COOKIES_NOT_SUPPORTED');
return false;
}
var cookies = $document.cookie && $document.cookie.split(';') || [];
for(var i=0; i < cookies.length; i++) {
var thisCookie = cookies[i];
while (thisCookie.charAt(0) === ' ') {
thisCookie = thisCookie.substring(1,thisCookie.length);
}
if (thisCookie.indexOf(deriveQualifiedKey(key) + '=') === 0) {
var storedValues = decodeURIComponent(thisCookie.substring(prefix.length + key.length + 1, thisCookie.length));
try {
return JSON.parse(storedValues);
} catch(e) {
return storedValues;
}
}
}
return null;
};
var removeFromCookies = function (key) {
addToCookies(key,null);
};
var clearAllFromCookies = function () {
var thisCookie = null;
var prefixLength = prefix.length;
var cookies = $document.cookie.split(';');
for(var i = 0; i < cookies.length; i++) {
thisCookie = cookies[i];
while (thisCookie.charAt(0) === ' ') {
thisCookie = thisCookie.substring(1, thisCookie.length);
}
var key = thisCookie.substring(prefixLength, thisCookie.indexOf('='));
removeFromCookies(key);
}
};
var getStorageType = function() {
return storageType;
};
var setStorageType = function(type) {
if (type && storageType !== type) {
storageType = type;
browserSupportsLocalStorage = checkSupport();
}
return browserSupportsLocalStorage;
};
// Add a listener on scope variable to save its changes to local storage
// Return a function which when called cancels binding
var bindToScope = function(scope, key, def, lsKey, type) {
lsKey = lsKey || key;
var value = getFromLocalStorage(lsKey, type);
if (value === null && isDefined(def)) {
value = def;
} else if (isObject(value) && isObject(def)) {
value = extend(value, def);
}
$parse(key).assign(scope, value);
return scope.$watch(key, function(newVal) {
addToLocalStorage(lsKey, newVal, type);
}, isObject(scope[key]));
};
// Add listener to local storage, for update callbacks.
if (browserSupportsLocalStorage) {
if ($window.addEventListener) {
$window.addEventListener("storage", handleStorageChangeCallback, false);
$rootScope.$on('$destroy', function() {
$window.removeEventListener("storage", handleStorageChangeCallback);
});
} else if($window.attachEvent){
// attachEvent and detachEvent are proprietary to IE v6-10
$window.attachEvent("onstorage", handleStorageChangeCallback);
$rootScope.$on('$destroy', function() {
$window.detachEvent("onstorage", handleStorageChangeCallback);
});
}
}
// Callback handler for storage changed.
function handleStorageChangeCallback(e) {
if (!e) { e = $window.event; }
if (notify.setItem) {
if (isKeyPrefixOurs(e.key)) {
var key = underiveQualifiedKey(e.key);
// Use timeout, to avoid using $rootScope.$apply.
$timeout(function () {
$rootScope.$broadcast('LocalStorageModule.notification.changed', { key: key, newvalue: e.newValue, storageType: self.storageType });
});
}
}
}
// Return localStorageService.length
// ignore keys that not owned
var lengthOfLocalStorage = function(type) {
setStorageType(type);
var count = 0;
var storage = $window[storageType];
for(var i = 0; i < storage.length; i++) {
if(storage.key(i).indexOf(prefix) === 0 ) {
count++;
}
}
return count;
};
return {
isSupported: browserSupportsLocalStorage,
getStorageType: getStorageType,
setStorageType: setStorageType,
set: addToLocalStorage,
add: addToLocalStorage, //DEPRECATED
get: getFromLocalStorage,
keys: getKeysForLocalStorage,
remove: removeFromLocalStorage,
clearAll: clearAllFromLocalStorage,
bind: bindToScope,
deriveKey: deriveQualifiedKey,
underiveKey: underiveQualifiedKey,
length: lengthOfLocalStorage,
defaultToCookie: this.defaultToCookie,
cookie: {
isSupported: browserSupportsCookies,
set: addToCookies,
add: addToCookies, //DEPRECATED
get: getFromCookies,
remove: removeFromCookies,
clearAll: clearAllFromCookies
}
};
}];
});
})(window, window.angular);

View File

@@ -1,4 +1,5 @@
window.OFNShared = angular.module("OFNShared", [
"mm.foundation",
"LocalStorageModule"
]).config ($httpProvider) ->
$httpProvider.defaults.headers.common["Accept"] = "application/json, text/javascript, */*"

View File

@@ -44,9 +44,9 @@ module Admin
create_connected_app
jwt_service = Vine::JwtService.new(secret: connected_app_params[:vine_secret])
vine_api = Vine::ApiService.new(api_key: connected_app_params[:vine_api_key],
jwt_generator: jwt_service)
jwt_service = VineJwtService.new(secret: connected_app_params[:vine_secret])
vine_api = VineApiService.new(api_key: connected_app_params[:vine_api_key],
jwt_generator: jwt_service)
if !@app.connect(api_key: connected_app_params[:vine_api_key],
secret: connected_app_params[:vine_secret], vine_api:)
@@ -77,7 +77,7 @@ module Admin
def log_and_notify_exception(exception)
Rails.logger.error exception.inspect
Alert.raise(exception)
Bugsnag.notify(exception)
end
def vine_params_empty?

View File

@@ -19,12 +19,15 @@ module Admin
.find(params.require(:enterprise_id))
catalog_url = params.require(:catalog_url)
catalog = DfcCatalog.load(spree_current_user, catalog_url)
catalog.apply_wholesale_values!
json_catalog = fetch_catalog(catalog_url)
graph = DfcIo.import(json_catalog)
# * First step: import all products for given enterprise.
# * Second step: render table and let user decide which ones to import.
imported = catalog.products.map do |subject|
imported = graph.map do |subject|
next unless subject.is_a? DataFoodConsortium::Connector::SuppliedProduct
existing_variant = enterprise.supplied_variants.linked_to(subject.semanticId)
if existing_variant
@@ -41,5 +44,11 @@ module Admin
flash[:error] = e.message
redirect_to admin_product_import_path
end
private
def fetch_catalog(url)
DfcRequest.new(spree_current_user).call(url)
end
end
end

View File

@@ -30,13 +30,13 @@ module Admin
def validate_data
return unless process_data('validate')
render json: @importer.import_results
render json: @importer.import_results, response: 200
end
def save_data
return unless process_data('save')
render json: @importer.save_results
render json: @importer.save_results, response: 200
end
def reset_absent_products
@@ -76,7 +76,7 @@ module Admin
begin
@importer.public_send("#{method}_entries")
rescue StandardError => e
render plain: e.message, status: :internal_server_error
render json: e.message, response: 500
return false
end

View File

@@ -66,7 +66,7 @@ module Api
end
def error_during_processing(exception)
Alert.raise(exception)
Bugsnag.notify(exception)
render(json: { exception: exception.message },
status: :unprocessable_entity) && return

View File

@@ -24,7 +24,6 @@ module Api
Orders::WorkflowService.new(@order).advance_to_payment if @order.line_items.any?
@order.recreate_all_fees!
AmendBackorderJob.perform_later(@order) if @order.completed?
render json: @shipment, serializer: Api::ShipmentSerializer, status: :ok
end
@@ -74,7 +73,6 @@ module Api
@order.contents.add(variant, quantity, @shipment)
@order.recreate_all_fees!
AmendBackorderJob.perform_later(@order) if @order.completed?
render json: @shipment, serializer: Api::ShipmentSerializer, status: :ok
end
@@ -88,7 +86,6 @@ module Api
@shipment.reload if @shipment.persisted?
@order.recreate_all_fees!
AmendBackorderJob.perform_later(@order) if @order.completed?
render json: @shipment, serializer: Api::ShipmentSerializer, status: :ok
end

View File

@@ -52,7 +52,7 @@ module Api
end
def error_during_processing(exception)
Alert.raise(exception)
Bugsnag.notify(exception)
if Rails.env.development? || Rails.env.test?
render status: :unprocessable_entity,

View File

@@ -11,7 +11,7 @@ class CartController < BaseController
order.cap_quantity_at_stock!
order.recreate_all_fees!
StockSyncJob.sync_linked_catalogs_later(order)
StockSyncJob.sync_linked_catalogs(order)
render json: { error: false, stock_levels: stock_levels(order) }, status: :ok
else

View File

@@ -78,18 +78,6 @@ class CheckoutController < BaseController
return true if redirect_to_payment_gateway
# Redeem VINE voucher
vine_voucher_redeemer = Vine::VoucherRedeemerService.new(order: @order)
unless vine_voucher_redeemer.redeem
# rubocop:disable Rails/DeprecatedActiveModelErrorsMethods
flash[:error] = if vine_voucher_redeemer.errors.keys.include?(:redeeming_failed)
vine_voucher_redeemer.errors[:redeeming_failed]
else
I18n.t('checkout.errors.voucher_redeeming_error')
end
return false
# rubocop:enable Rails/DeprecatedActiveModelErrorsMethods
end
@order.process_payments!
@order.confirm!
order_completion_reset @order

View File

@@ -50,7 +50,9 @@ module OrderCompletion
end
def order_invalid!
Alert.raise_with_record("Notice: invalid order loaded during checkout", @order)
Bugsnag.notify("Notice: invalid order loaded during checkout") do |payload|
payload.add_metadata :order, :order, @order
end
flash[:error] = t('checkout.order_not_loaded')
redirect_to main_app.shop_path
@@ -90,7 +92,9 @@ module OrderCompletion
end
def notify_failure(error = RuntimeError.new(order_processing_error))
Alert.raise_with_record(error, @order)
Bugsnag.notify(error) do |payload|
payload.add_metadata :order, @order
end
flash[:error] = order_processing_error if flash.blank?
end

View File

@@ -20,7 +20,9 @@ module OrderStockCheck
def check_order_cycle_expiry
return unless current_order_cycle&.closed?
Alert.raise_with_record("Notice: order cycle closed during checkout completion", current_order)
Bugsnag.notify("Notice: order cycle closed during checkout completion") do |payload|
payload.add_metadata :order, :order, current_order
end
current_order.empty!
current_order.set_order_cycle! nil

View File

@@ -4,6 +4,11 @@ class ErrorsController < ApplicationController
layout "errors"
def not_found
Bugsnag.notify("404") do |event|
event.severity = "info"
event.add_metadata(:request, :env, request.env)
end
render status: :not_found, formats: :html
end

View File

@@ -70,7 +70,6 @@ module Spree
@order.restock_items = params.fetch(:restock_items, "true") == "true"
if @order.public_send(event.to_s)
AmendBackorderJob.perform_later(@order) if @order.completed?
flash[:success] = Spree.t(:order_updated)
else
flash[:error] = Spree.t(:cannot_perform_operation)

View File

@@ -24,12 +24,9 @@ module Spree
end
def create
# Try to redeem VINE voucher first as we don't want to create a payment and complete
# the order if it fails
return redirect_to spree.admin_order_payments_path(@order) unless redeem_vine_voucher
@payment = @order.payments.build(object_params)
load_payment_source
begin
unless @payment.save
redirect_to spree.admin_order_payments_path(@order)
@@ -54,10 +51,6 @@ module Spree
event = params[:e]
return unless event && @payment.payment_source
# capture_and_complete_order will complete the order, so we want to try to redeem VINE
# voucher first and exit if it fails
return if event == "capture_and_complete_order" && !redeem_vine_voucher
# Because we have a transition method also called void, we do this to avoid conflicts.
event = "void_transaction" if event == "void"
if allowed_events.include?(event) && @payment.public_send("#{event}!")
@@ -67,7 +60,7 @@ module Spree
end
rescue StandardError => e
logger.error e.message
Alert.raise(e)
Bugsnag.notify(e)
flash[:error] = e.message
ensure
redirect_to request.referer
@@ -189,22 +182,6 @@ module Spree
%w{capture void_transaction credit refund resend_authorization_email
capture_and_complete_order}
end
def redeem_vine_voucher
vine_voucher_redeemer = Vine::VoucherRedeemerService.new(order: @order)
if vine_voucher_redeemer.redeem == false
# rubocop:disable Rails/DeprecatedActiveModelErrorsMethods
flash[:error] = if vine_voucher_redeemer.errors.keys.include?(:redeeming_failed)
vine_voucher_redeemer.errors[:redeeming_failed]
else
I18n.t('checkout.errors.voucher_redeeming_error')
end
# rubocop:enable Rails/DeprecatedActiveModelErrorsMethods
return false
end
true
end
end
end
end

View File

@@ -11,7 +11,6 @@ module Spree
include OrderCyclesHelper
include EnterprisesHelper
helper ::Admin::ProductsHelper
helper Spree::Admin::TaxCategoriesHelper
before_action :load_data
before_action :load_producers, only: [:index, :new]
@@ -214,7 +213,7 @@ module Spree
end
def notify_bugsnag(error, product, variant)
Alert.raise(error) do |report|
Bugsnag.notify(error) do |report|
report.add_metadata(:product,
{ product: product.attributes, variant: variant.attributes })
report.add_metadata(:product, :product_error, product.errors.first) unless product.valid?

View File

@@ -70,15 +70,14 @@ module Spree
@order.recreate_all_fees! # Enterprise fees on line items and on the order itself
# Re apply the voucher
OrderManagement::Order::Updater.new(@order).update_voucher
VoucherAdjustmentsService.new(@order).update
@order.update_totals_and_states
if @order.complete?
@order.update_payment_fees!
@order.create_tax_charge!
end
AmendBackorderJob.perform_later(@order) if @order.completed?
respond_with(@order) do |format|
format.html do
if params.key?(:checkout)

View File

@@ -1,5 +1,6 @@
# frozen_string_literal: true
require 'open_food_network/error_logger'
require "spree/core/controller_helpers/auth"
require "spree/core/controller_helpers/common"
require "spree/core/controller_helpers/order"
@@ -36,7 +37,7 @@ class UserRegistrationsController < Devise::RegistrationsController
end
end
rescue StandardError => e
Alert.raise(e)
OpenFoodNetwork::ErrorLogger.notify(e)
render_error(message: I18n.t('unknown_error', scope: I18N_SCOPE))
end

View File

@@ -4,16 +4,7 @@ class VoucherAdjustmentsController < BaseController
before_action :set_order
def create
if voucher_params[:voucher_code].blank?
@order.errors.add(:voucher_code, I18n.t('checkout.errors.voucher_not_found'))
return render_error
end
voucher = load_voucher
return render_error unless valid_voucher?(voucher)
if add_voucher_to_order(voucher)
if add_voucher
update_payment_section
elsif @order.errors.present?
render_error
@@ -39,28 +30,19 @@ class VoucherAdjustmentsController < BaseController
@order = current_order
end
def valid_voucher?(voucher)
return false if @order.errors.present?
def add_voucher
if voucher_params[:voucher_code].blank?
@order.errors.add(:voucher_code, I18n.t('checkout.errors.voucher_not_found'))
return false
end
voucher = Voucher.find_by(code: voucher_params[:voucher_code], enterprise: @order.distributor)
if voucher.nil?
@order.errors.add(:voucher_code, I18n.t('checkout.errors.voucher_not_found'))
return false
end
if !voucher.valid?
@order.errors.add(
:voucher_code,
I18n.t(
'checkout.errors.create_voucher_error', error: voucher.errors.full_messages.to_sentence
)
)
return false
end
true
end
def add_voucher_to_order(voucher)
adjustment = voucher.create_adjustment(voucher.code, @order)
unless adjustment.persisted?
@@ -69,38 +51,14 @@ class VoucherAdjustmentsController < BaseController
return false
end
# calculate_voucher_adjustment
clear_payments
OrderManagement::Order::Updater.new(@order).update_voucher
VoucherAdjustmentsService.new(@order).update
@order.update_totals_and_states
true
end
def load_voucher
voucher = Voucher.find_by(code: voucher_params[:voucher_code],
enterprise: @order.distributor)
return voucher unless voucher.nil? || voucher.is_a?(Vouchers::Vine)
vine_voucher
end
def vine_voucher
vine_voucher_validator = Vine::VoucherValidatorService.new(
voucher_code: voucher_params[:voucher_code], enterprise: @order.distributor
)
voucher = vine_voucher_validator.validate
return nil if vine_voucher_validator.errors[:not_found_voucher].present?
if vine_voucher_validator.errors.present?
@order.errors.add(:voucher_code, I18n.t('checkout.errors.add_voucher_error'))
return nil
end
voucher
end
def update_payment_section
render cable_ready: cable_car.replace(
selector: "#checkout-payment-methods",

View File

@@ -1,20 +0,0 @@
# frozen_string_literal: true
module Spree
module Admin
module TaxCategoriesHelper
def tax_category_dropdown_options(require_tax_category)
if require_tax_category
{
include_blank: false,
selected: Spree::TaxCategory.find_by(is_default: true)&.id
}
else
{
include_blank: t(:none),
}
end
end
end
end
end

View File

@@ -1,30 +1,98 @@
# frozen_string_literal: true
# When orders are created, adjusted or cancelled, we need to amend
# When orders are cancelled, we need to amend
# an existing backorder as well.
# We're not dealing with line item changes just yet.
class AmendBackorderJob < ApplicationJob
sidekiq_options retry: 0
def self.schedule_bulk_update_for(orders)
# We can have one backorder per order cycle and distributor.
groups = orders.group_by { |order| [order.order_cycle, order.distributor] }
groups.each_value do |orders_with_same_backorder|
# We need to trigger only one update per backorder.
perform_later(orders_with_same_backorder.first)
end
end
def perform(order)
OrderLocker.lock_order_and_variants(order) do
amend_backorder(order)
end
end
# The following is a mix of the BackorderJob and the CompleteBackorderJob.
# TODO: Move the common code into a re-usable service class.
def amend_backorder(order)
backorder = BackorderUpdater.new.amend_backorder(order)
order_cycle = order.order_cycle
distributor = order.distributor
user = distributor.owner
items = backorderable_items(order)
user = order.distributor.owner
urls = nil # Not needed to send order. The backorder id is the URL.
FdcBackorderer.new(user, urls).send_order(backorder) if backorder
return if items.empty?
# We are assuming that all variants are linked to the same wholesale
# shop and its catalog:
reference_link = items[0].variant.semantic_links[0].semantic_id
urls = FdcUrlBuilder.new(reference_link)
orderer = FdcBackorderer.new(user, urls)
backorder = orderer.find_open_order(order)
variants = order_cycle.variants_distributed_by(distributor)
adjust_quantities(order_cycle, user, backorder, urls, variants)
FdcBackorderer.new(user, urls).send_order(backorder)
end
# Check if we have enough stock to reduce the backorder.
#
# Our local stock can increase when users cancel their orders.
# But stock levels could also have been adjusted manually. So we review all
# quantities before finalising the order.
def adjust_quantities(order_cycle, user, order, urls, variants)
broker = FdcOfferBroker.new(user, urls)
order.lines.each do |line|
line.quantity = line.quantity.to_i
wholesale_product_id = line.offer.offeredItem.semanticId
transformation = broker.wholesale_to_retail(wholesale_product_id)
linked_variant = variants.linked_to(transformation.retail_product_id)
# Assumption: If a transformation is present then we only sell the retail
# variant. If that can't be found, it was deleted and we'll ignore that
# for now.
next if linked_variant.nil?
# Find all line items for this order cycle
# Update quantity accordingly
if linked_variant.on_demand
release_superfluous_stock(line, linked_variant, transformation)
else
aggregate_final_quantities(order_cycle, line, linked_variant, transformation)
end
end
# Clean up empty lines:
order.lines.reject! { |line| line.quantity.zero? }
end
# We look at all linked variants.
def backorderable_items(order)
order.line_items.select do |item|
# TODO: scope variants to hub.
# We are only supporting producer stock at the moment.
item.variant.semantic_links.present?
end
end
def release_superfluous_stock(line, linked_variant, transformation)
# Note that a division of integers dismisses the remainder, like `floor`:
wholesale_items_contained_in_stock = linked_variant.on_hand / transformation.factor
# But maybe we didn't actually order that much:
deductable_quantity = [line.quantity, wholesale_items_contained_in_stock].min
line.quantity -= deductable_quantity
retail_stock_changes = deductable_quantity * transformation.factor
linked_variant.on_hand -= retail_stock_changes
end
def aggregate_final_quantities(order_cycle, line, variant, transformation)
orders = order_cycle.orders.invoiceable
quantity = Spree::LineItem.where(order: orders, variant:).sum(:quantity)
wholesale_quantity = (quantity.to_f / transformation.factor).ceil
line.quantity = wholesale_quantity
end
end

View File

@@ -19,7 +19,9 @@ class BackorderJob < ApplicationJob
rescue StandardError => e
# Errors here shouldn't affect the checkout. So let's report them
# separately:
Alert.raise_with_record(e, order)
Bugsnag.notify(e) do |payload|
payload.add_metadata(:order, :order, order)
end
end
def perform(order)
@@ -117,8 +119,7 @@ class BackorderJob < ApplicationJob
end
def load_broker(user, urls)
catalog = DfcCatalog.load(user, urls.catalog_url)
FdcOfferBroker.new(catalog)
FdcOfferBroker.new(user, urls)
end
def place_order(user, order, orderer, backorder)

View File

@@ -24,7 +24,8 @@ class CompleteBackorderJob < ApplicationJob
urls = FdcUrlBuilder.new(order.lines[0].offer.offeredItem.semanticId)
BackorderUpdater.new.update(order, user, distributor, order_cycle)
variants = order_cycle.variants_distributed_by(distributor)
adjust_quantities(order_cycle, user, order, urls, variants)
FdcBackorderer.new(user, urls).complete_order(order)
@@ -35,4 +36,55 @@ class CompleteBackorderJob < ApplicationJob
raise
end
# Check if we have enough stock to reduce the backorder.
#
# Our local stock can increase when users cancel their orders.
# But stock levels could also have been adjusted manually. So we review all
# quantities before finalising the order.
def adjust_quantities(order_cycle, user, order, urls, variants)
broker = FdcOfferBroker.new(user, urls)
order.lines.each do |line|
line.quantity = line.quantity.to_i
wholesale_product_id = line.offer.offeredItem.semanticId
transformation = broker.wholesale_to_retail(wholesale_product_id)
linked_variant = variants.linked_to(transformation.retail_product_id)
# Assumption: If a transformation is present then we only sell the retail
# variant. If that can't be found, it was deleted and we'll ignore that
# for now.
next if linked_variant.nil?
# Find all line items for this order cycle
# Update quantity accordingly
if linked_variant.on_demand
release_superfluous_stock(line, linked_variant, transformation)
else
aggregate_final_quantities(order_cycle, line, linked_variant, transformation)
end
end
# Clean up empty lines:
order.lines.reject! { |line| line.quantity.zero? }
end
def release_superfluous_stock(line, linked_variant, transformation)
# Note that a division of integers dismisses the remainder, like `floor`:
wholesale_items_contained_in_stock = linked_variant.on_hand / transformation.factor
# But maybe we didn't actually order that much:
deductable_quantity = [line.quantity, wholesale_items_contained_in_stock].min
line.quantity -= deductable_quantity
retail_stock_changes = deductable_quantity * transformation.factor
linked_variant.on_hand -= retail_stock_changes
end
def aggregate_final_quantities(order_cycle, line, variant, transformation)
orders = order_cycle.orders.invoiceable
quantity = Spree::LineItem.where(order: orders, variant:).sum(:quantity)
wholesale_quantity = (quantity.to_f / transformation.factor).ceil
line.quantity = wholesale_quantity
end
end

View File

@@ -22,7 +22,11 @@ class ReportJob < ApplicationJob
broadcast_result(channel, format, blob) if channel
rescue StandardError => e
Alert.raise(e, { report: { report_class:, user:, params:, format: } })
Bugsnag.notify(e) do |payload|
payload.add_metadata :report, {
report_class:, user:, params:, format:
}
end
broadcast_error(channel)
end

View File

@@ -8,12 +8,30 @@ class StockSyncJob < ApplicationJob
# product. These variants are rare though and we check first before we
# enqueue a new job. That should save some time loading the order with
# all the stock data to make this decision.
def self.sync_linked_catalogs_later(order)
sync_catalogs_by_perform_method(order, :perform_later)
def self.sync_linked_catalogs(order)
user = order.distributor.owner
catalog_ids(order).each do |catalog_id|
perform_later(user, catalog_id)
end
rescue StandardError => e
# Errors here shouldn't affect the shopping. So let's report them
# separately:
Bugsnag.notify(e) do |payload|
payload.add_metadata(:order, :order, order)
end
end
def self.sync_linked_catalogs_now(order)
sync_catalogs_by_perform_method(order, :perform_now)
user = order.distributor.owner
catalog_ids(order).each do |catalog_id|
perform_now(user, catalog_id)
end
rescue StandardError => e
# Errors here shouldn't affect the shopping. So let's report them
# separately:
Bugsnag.notify(e) do |payload|
payload.add_metadata(:order, :order, order)
end
end
def self.catalog_ids(order)
@@ -26,10 +44,7 @@ class StockSyncJob < ApplicationJob
end
def perform(user, catalog_id)
catalog = DfcCatalog.load(user, catalog_id)
catalog.apply_wholesale_values!
products = catalog.products
products = load_products(user, catalog_id)
products_by_id = products.index_by(&:semanticId)
product_ids = products_by_id.keys
variants = linked_variants(user.enterprises, product_ids)
@@ -47,23 +62,18 @@ class StockSyncJob < ApplicationJob
end
end
def load_products(user, catalog_id)
json_catalog = DfcRequest.new(user).call(catalog_id)
graph = DfcIo.import(json_catalog)
graph.select do |subject|
subject.is_a? DataFoodConsortium::Connector::SuppliedProduct
end
end
def linked_variants(enterprises, product_ids)
Spree::Variant.where(supplier: enterprises)
.includes(:semantic_links).references(:semantic_links)
.where(semantic_links: { semantic_id: product_ids })
end
def self.sync_catalogs_by_perform_method(order, perform_method)
distributor = order.distributor
return unless distributor
user = distributor.owner
catalog_ids(order).each do |catalog_id|
public_send(perform_method, user, catalog_id)
end
rescue StandardError => e
# Errors here shouldn't affect the shopping. So let's report them
# separately:
Alert.raise_with_record(e, order)
end
end

View File

@@ -55,7 +55,9 @@ class SubscriptionConfirmJob < ApplicationJob
if order.errors.any?
send_failed_payment_email(order)
else
Alert.raise_with_record(e, order)
Bugsnag.notify(e) do |payload|
payload.add_metadata :order, :order, order
end
send_failed_payment_email(order, e.message)
end
end
@@ -106,6 +108,8 @@ class SubscriptionConfirmJob < ApplicationJob
record_and_log_error(:failed_payment, order, error_message)
SubscriptionMailer.failed_payment_email(order).deliver_now
rescue StandardError => e
Alert.raise(e, { subscription_data: { order:, error_message: } })
Bugsnag.notify(e) do |payload|
payload.add_metadata :subscription_data, { order:, error_message: }
end
end
end

View File

@@ -79,19 +79,14 @@ class ProducerMailer < ApplicationMailer
def set_customer_data(line_items)
return unless @coordinator.show_customer_names_to_suppliers?
@display_business_name = false
line_items.map do |line_item|
customer_code = line_item.order.customer&.code
@display_business_name = true if customer_code.present?
{
sku: line_item.variant.sku,
supplier_name: line_item.variant.supplier.name,
product_and_full_name: line_item.product_and_full_name,
quantity: line_item.quantity,
first_name: line_item.order.billing_address.first_name,
last_name: line_item.order.billing_address.last_name,
business_name: customer_code,
last_name: line_item.order.billing_address.last_name
}
end.sort_by { |line_item| [line_item[:last_name].downcase, line_item[:first_name].downcase] }
end

View File

@@ -28,7 +28,7 @@ module Calculator
# In theory it should never be called any more after this has been deployed.
# If the message below doesn't show up in Bugsnag, we can safely delete this method and all
# the related methods below it.
Alert.raise("Calculator::DefaultTax was called with legacy tax calculations")
Bugsnag.notify("Calculator::DefaultTax was called with legacy tax calculations")
calculator = OpenFoodNetwork::EnterpriseFeeCalculator.new(order.distributor,
order.order_cycle)

View File

@@ -1,31 +0,0 @@
# frozen_string_literal: true
require "active_support/concern"
module Vouchers
module FlatRatable
extend ActiveSupport::Concern
included do
validates :amount,
presence: true,
numericality: { greater_than: 0 }
end
def display_value
Spree::Money.new(amount)
end
# We limit adjustment to the maximum amount needed to cover the order, ie if the voucher
# covers more than the order.total we only need to create an adjustment covering the order.total
def compute_amount(order)
-amount.clamp(0, order.pre_discount_total)
end
def rate(order)
amount = compute_amount(order)
amount / order.pre_discount_total
end
end
end

View File

@@ -481,7 +481,7 @@ class Enterprise < ApplicationRecord
image_variant_url_for(image.variant(name))
rescue StandardError => e
Alert.raise "Enterprise ##{id} #{image.try(:name)} error: #{e.message}"
Bugsnag.notify "Enterprise ##{id} #{image.try(:name)} error: #{e.message}"
Rails.logger.error(e.message)
nil

View File

@@ -79,9 +79,10 @@ module ProductImport
if entry.attributes['on_hand'].present?
new_variant.on_hand = entry.attributes['on_hand']
end
check_on_hand_nil(entry, new_variant)
end
check_on_hand_nil(entry, new_variant)
if new_variant.valid?
entry.product_object = new_variant
entry.validates_as = 'new_variant' unless entry.errors?
@@ -160,7 +161,7 @@ module ProductImport
end
def unit_fields_validation(entry)
unit_types = ['mg', 'g', 'kg', 'oz', 'lb', 't', 'ml', 'cl', 'dl', 'l', 'kl', 'gal', '']
unit_types = ['g', 'oz', 'lb', 'kg', 't', 'ml', 'l', 'kl', '']
if entry.units.blank?
mark_as_invalid(entry, attribute: 'units',
@@ -296,7 +297,7 @@ module ProductImport
unscaled_units = entry.unscaled_units.to_f || 0
entry.unit_value = unscaled_units * unit_scale unless unit_scale.nil?
if entry.match_variant?(existing_variant)
if entry.match_inventory_variant?(existing_variant)
variant_override = create_inventory_item(entry, existing_variant)
return validate_inventory_item(entry, variant_override)
end

View File

@@ -85,6 +85,10 @@ module ProductImport
end
def match_variant?(variant)
match_display_name?(variant) && variant.unit_value.to_d == unscaled_units.to_d
end
def match_inventory_variant?(variant)
match_display_name?(variant) && variant.unit_value.to_d == unit_value.to_d
end

View File

@@ -32,18 +32,14 @@ module ProductImport
def unit_scales
{
'mg' => { scale: 0.001, unit: 'weight' },
'g' => { scale: 1, unit: 'weight' },
'kg' => { scale: 1000, unit: 'weight' },
'oz' => { scale: 28.35, unit: 'weight' },
'lb' => { scale: 453.6, unit: 'weight' },
't' => { scale: 1_000_000, unit: 'weight' },
'ml' => { scale: 0.001, unit: 'volume' },
'cl' => { scale: 0.01, unit: 'volume' },
'dl' => { scale: 0.1, unit: 'volume' },
'l' => { scale: 1, unit: 'volume' },
'kl' => { scale: 1000, unit: 'volume' },
'gal' => { scale: 4.54609, unit: 'volume' },
'kl' => { scale: 1000, unit: 'volume' }
}
end

View File

@@ -34,7 +34,7 @@ module Spree
image_variant_url_for(variant(size))
rescue StandardError => e
Alert.raise "Product ##{viewable_id} Image ##{id} error: #{e.message}"
Bugsnag.notify "Product ##{viewable_id} Image ##{id} error: #{e.message}"
Rails.logger.error(e.message)
self.class.default_image_url(size)

View File

@@ -535,7 +535,7 @@ module Spree
# because an outdated shipping fee is not as bad as a lost payment.
# And the shipping fee is already up-to-date when this error occurs.
# https://github.com/openfoodfoundation/openfoodnetwork/issues/3924
Alert.raise(e) do |report|
Bugsnag.notify(e) do |report|
report.add_metadata(:order, attributes)
report.add_metadata(:shipment, shipment.attributes)
report.add_metadata(:shipment_in_db, Spree::Shipment.find_by(id: shipment.id).attributes)

View File

@@ -142,6 +142,8 @@ module Spree
OrderMailer.cancel_email(id).deliver_later if send_cancellation_email
update(payment_state: updater.update_payment_state)
AmendBackorderJob.perform_later(self)
end
def after_resume

View File

@@ -38,7 +38,6 @@ module Spree
line_item.price = variant.price
order.line_items << line_item
end
update_shipment
order.reload
line_item

View File

@@ -106,7 +106,7 @@ module Spree
calculator.compute(item)
else
# Tax refund should not be possible with the way our production server are configured
Alert.raise(
Bugsnag.notify(
"Notice: Tax refund should not be possible, please check the default zone and " \
"the tax rate zone configuration"
) do |payload|

View File

@@ -293,7 +293,7 @@ module Spree
end
def ensure_unit_value
Alert.raise("Trying to set unit_value to NaN") if unit_value&.nan?
Bugsnag.notify("Trying to set unit_value to NaN") if unit_value&.nan?
return unless (variant_unit == "items" && unit_value.nil?) || unit_value&.nan?
self.unit_value = 1.0

View File

@@ -15,7 +15,7 @@ class StripeAccount < ApplicationRecord
destroy && Stripe::OAuth.deauthorize(stripe_user_id:)
rescue Stripe::OAuth::OAuthError => e
Alert.raise(
Bugsnag.notify(
e,
stripe_account: stripe_user_id,
enterprise_id:

View File

@@ -48,8 +48,8 @@ class VariantOverride < ApplicationRecord
def move_stock!(quantity)
unless stock_overridden?
Alert.raise "Attempting to move stock of a VariantOverride " \
"without a count_on_hand specified."
Bugsnag.notify RuntimeError.new "Attempting to move stock of a VariantOverride " \
"without a count_on_hand specified."
return
end
@@ -73,8 +73,8 @@ class VariantOverride < ApplicationRecord
self.attributes = { on_demand: false, count_on_hand: default_stock }
save
else
Alert.raise "Attempting to reset stock level for a variant " \
"with no default stock level."
Bugsnag.notify RuntimeError.new "Attempting to reset stock level for a variant " \
"with no default stock level."
end
end
self

View File

@@ -14,7 +14,7 @@ class Voucher < ApplicationRecord
class_name: 'Spree::Adjustment',
dependent: nil
validates :code, presence: true
validates :code, presence: true, uniqueness: { scope: :enterprise_id }
TYPES = ["Vouchers::FlatRate", "Vouchers::PercentageRate"].freeze

View File

@@ -2,8 +2,24 @@
module Vouchers
class FlatRate < Voucher
include FlatRatable
validates :amount,
presence: true,
numericality: { greater_than: 0 }
validates_with ScopedUniquenessValidator
def display_value
Spree::Money.new(amount)
end
# We limit adjustment to the maximum amount needed to cover the order, ie if the voucher
# covers more than the order.total we only need to create an adjustment covering the order.total
def compute_amount(order)
-amount.clamp(0, order.pre_discount_total)
end
def rate(order)
amount = compute_amount(order)
amount / order.pre_discount_total
end
end
end

View File

@@ -5,7 +5,6 @@ module Vouchers
validates :amount,
presence: true,
numericality: { greater_than: 0, less_than_or_equal_to: 100 }
validates_with ScopedUniquenessValidator
def display_value
ActionController::Base.helpers.number_to_percentage(amount, precision: 2)

View File

@@ -1,13 +0,0 @@
# frozen_string_literal: false
module Vouchers
class Vine < Voucher
include FlatRatable
# a VINE voucher :
# - can potentially be associated with mutiple enterprise
# - code ( "short code" in VINE ) can be recycled, but they shouldn't be linked to the same
# voucher_id
validates :code, uniqueness: { scope: [:enterprise_id, :external_voucher_id] }
end
end

View File

@@ -1,38 +0,0 @@
# frozen_string_literal: true
# A handy wrapper around error reporting libraries like Bugsnag.
#
# Bugsnag's API is great for general purpose but overly complex for our use.
# It also changes over time and we often make mistakes using it. So this class
# aims at:
#
# * Abstracting from Bugsnag, open for other services.
# * Simpler interface to reduce user error.
# * Central place to update Bugsnag API usage when it changes.
#
class Alert
# Alert Bugsnag with additional metadata to appear in tabs.
#
# Alert.raise(
# "Invalid order during checkout",
# {
# order: { number: "ABC123", state: "awaiting_return" },
# env: { referer: "example.com" }
# }
# )
def self.raise(error, metadata = {}, &block)
Bugsnag.notify(error) do |payload|
metadata.each do |name, data|
payload.add_metadata(name, data)
end
block.call(payload)
end
end
def self.raise_with_record(error, record, &)
metadata = {
record.class.name => record&.attributes || { record_was_nil: true }
}
self.raise(error, metadata, &)
end
end

View File

@@ -1,148 +0,0 @@
# frozen_string_literal: true
require 'open_food_network/order_cycle_permissions'
# Update a backorder to reflect all local orders and stock levels
# connected to the associated order cycle.
class BackorderUpdater
# Given an OFN order was created, changed or cancelled,
# we re-calculate how much to order in for every variant.
def amend_backorder(order)
order_cycle = order.order_cycle
distributor = order.distributor
variants = distributed_linked_variants(order_cycle, distributor)
# Temporary code: once we don't need a variant link to look up the
# backorder, we don't need this check anymore.
# Then we can adjust the backorder even though there are no linked variants
# in the order cycle right now. Some variants may have been in the order
# cycle before and got ordered before being removed from the order cycle.
return unless variants.any?
# We are assuming that all variants are linked to the same wholesale
# shop and its catalog:
reference_link = variants[0].semantic_links[0].semantic_id
user = order.distributor.owner
urls = FdcUrlBuilder.new(reference_link)
orderer = FdcBackorderer.new(user, urls)
backorder = orderer.find_open_order(order)
update(backorder, user, distributor, order_cycle)
end
# Update a given backorder according to a distributor's order cycle.
def update(backorder, user, distributor, order_cycle)
variants = distributed_linked_variants(order_cycle, distributor)
# We are assuming that all variants are linked to the same wholesale
# shop and its catalog:
reference_link = variants[0].semantic_links[0].semantic_id
urls = FdcUrlBuilder.new(reference_link)
orderer = FdcBackorderer.new(user, urls)
catalog = DfcCatalog.load(user, urls.catalog_url)
broker = FdcOfferBroker.new(catalog)
updated_lines = update_order_lines(backorder, order_cycle, variants, broker, orderer)
unprocessed_lines = backorder.lines.to_set - updated_lines
managed_variants = managed_linked_variants(user, order_cycle, distributor)
cancel_stale_lines(unprocessed_lines, managed_variants, broker)
# Clean up empty lines:
backorder.lines.reject! { |line| line.quantity.zero? }
backorder
end
def update_order_lines(backorder, order_cycle, variants, broker, orderer)
variants.map do |variant|
link = variant.semantic_links[0].semantic_id
solution = broker.best_offer(link)
line = orderer.find_or_build_order_line(backorder, solution.offer)
if variant.on_demand
adjust_stock(variant, solution, line)
else
aggregate_final_quantities(order_cycle, line, variant, solution)
end
line
end
end
def cancel_stale_lines(unprocessed_lines, managed_variants, broker)
unprocessed_lines.each do |line|
wholesale_quantity = line.quantity.to_i
wholesale_product_id = line.offer.offeredItem.semanticId
transformation = broker.wholesale_to_retail(wholesale_product_id)
linked_variant = managed_variants.linked_to(transformation.retail_product_id)
if linked_variant.nil?
transformation.factor = 1
linked_variant = managed_variants.linked_to(wholesale_product_id)
end
# Adjust stock level back, we're not going to order this one.
if linked_variant&.on_demand
retail_quantity = wholesale_quantity * transformation.factor
linked_variant.on_hand -= retail_quantity
end
# We don't have any active orders for this
line.quantity = 0
end
end
def adjust_stock(variant, solution, line)
line.quantity = line.quantity.to_i
if variant.on_hand.negative?
needed_quantity = -1 * variant.on_hand # We need to replenish it.
# The number of wholesale packs we need to order to fulfill the
# needed quantity.
# For example, we order 2 packs of 12 cans if we need 15 cans.
wholesale_quantity = (needed_quantity.to_f / solution.factor).ceil
# The number of individual retail items we get with the wholesale order.
# For example, if we order 2 packs of 12 cans, we will get 24 cans
# and we'll account for that in our stock levels.
retail_quantity = wholesale_quantity * solution.factor
line.quantity += wholesale_quantity
variant.on_hand += retail_quantity
else
# Note that a division of integers dismisses the remainder, like `floor`:
wholesale_items_contained_in_stock = variant.on_hand / solution.factor
# But maybe we didn't actually order that much:
deductable_quantity = [line.quantity, wholesale_items_contained_in_stock].min
if deductable_quantity.positive?
line.quantity -= deductable_quantity
retail_stock_change = deductable_quantity * solution.factor
variant.on_hand -= retail_stock_change
end
end
end
def managed_linked_variants(user, order_cycle, distributor)
# These permissions may be too complex. Here may be scope to optimise.
permissions = OpenFoodNetwork::OrderCyclePermissions.new(user, order_cycle)
permissions.visible_variants_for_outgoing_exchanges_to(distributor)
.where.associated(:semantic_links)
end
def distributed_linked_variants(order_cycle, distributor)
order_cycle.variants_distributed_by(distributor)
.where.associated(:semantic_links)
end
def aggregate_final_quantities(order_cycle, line, variant, transformation)
# We may want to query all these quantities in one go instead of this n+1.
orders = order_cycle.orders.invoiceable
quantity = Spree::LineItem.where(order: orders, variant:).sum(:quantity)
wholesale_quantity = (quantity.to_f / transformation.factor).ceil
line.quantity = wholesale_quantity
end
end

View File

@@ -21,7 +21,7 @@ class FdcBackorderer
# Try the new method and fall back to old method.
def find_open_order(ofn_order)
lookup_open_order(ofn_order)
lookup_open_order(ofn_order) || find_last_open_order
end
def lookup_open_order(ofn_order)
@@ -36,13 +36,52 @@ class FdcBackorderer
.map { |id| find_order(id) }
.compact
# Just in case someone completed the order without updating our database:
.select { |o| o.orderStatus == order_status.HELD }
.select { |o| o.orderStatus[:path] == "Held" }
.first
# The DFC Connector doesn't recognise status values properly yet.
# So we are overriding the value with something that can be exported.
&.tap { |o| o.orderStatus = "dfc-v:Held" }
end
# DEPRECATED
#
# We now store links to orders we placed. So we don't need to search
# through all orders and pick a random open one.
# But for compatibility with currently open order cycles that don't have
# a stored link yet, we keep this method as well.
def find_last_open_order
graph = import(urls.orders_url)
open_orders = graph&.select do |o|
o.semanticType == "dfc-b:Order" && o.orderStatus[:path] == "Held"
end
return if open_orders.blank?
# If there are multiple open orders, we don't know which one to choose.
# We want the order we placed for the same distributor in the same order
# cycle before. So here are some assumptions for this to work:
#
# * We see only orders for our distributor. The endpoint URL contains the
# the distributor name and is currently hardcoded.
# * There's only one open order cycle at a time. Otherwise we may select
# an order of an old order cycle.
# * Orders are finalised when the order cycle closes. So _Held_ orders
# always belong to an open order cycle.
# * We see only our own orders. This assumption is wrong. The Shopify
# integration places held orders as well and they are visible to us.
#
# Unfortunately, the endpoint doesn't tell who placed the order.
# TODO: We need to remember the link to the order locally.
# Or the API is updated to include the orderer.
#
# For now, we just guess:
open_orders.last.tap do |order|
# The DFC Connector doesn't recognise status values properly yet.
# So we are overriding the value with something that can be exported.
order.orderStatus = "dfc-v:Held"
end
end
def find_order(semantic_id)
find_subject(import(semantic_id), "dfc-b:Order")
end
@@ -113,7 +152,7 @@ class FdcBackorderer
end
def new?(order)
order.semanticId == urls&.orders_url
order.semanticId == urls.orders_url
end
def build_sale_session(order)
@@ -121,10 +160,4 @@ class FdcBackorderer
session.semanticId = urls.sale_session_url
end
end
private
def order_status
DfcLoader.vocabulary("vocabulary").STATES.ORDERSTATE
end
end

View File

@@ -6,10 +6,19 @@ class FdcOfferBroker
Solution = Struct.new(:product, :factor, :offer)
RetailSolution = Struct.new(:retail_product_id, :factor)
attr_reader :catalog
def self.load_catalog(user, urls)
api = DfcRequest.new(user)
catalog_json = api.call(urls.catalog_url)
DfcIo.import(catalog_json)
end
def initialize(catalog)
@catalog = catalog
def initialize(user, urls)
@user = user
@urls = urls
end
def catalog
@catalog ||= self.class.load_catalog(@user, @urls)
end
def best_offer(product_id)
@@ -21,18 +30,19 @@ class FdcOfferBroker
end
def wholesale_product(product_id)
production_flow = catalog.item("#{product_id}/AsPlannedProductionFlow")
production_flow = catalog_item("#{product_id}/AsPlannedProductionFlow")
if production_flow
production_flow.product
wholesale_product_id = production_flow.product
catalog_item(wholesale_product_id)
else
# We didn't find a wholesale variant, falling back to the given product.
catalog.item(product_id)
catalog_item(product_id)
end
end
def contained_quantity(product_id)
consumption_flow = catalog.item("#{product_id}/AsPlannedConsumptionFlow")
consumption_flow = catalog_item("#{product_id}/AsPlannedConsumptionFlow")
# If we don't find a transformation, we return the original product,
# which contains exactly one of itself (identity).
@@ -44,10 +54,10 @@ class FdcOfferBroker
return RetailSolution.new(wholesale_product_id, 1) if production_flow.nil?
consumption_flow = catalog.item(
consumption_flow = catalog_item(
production_flow.semanticId.sub("AsPlannedProductionFlow", "AsPlannedConsumptionFlow")
)
retail_product_id = consumption_flow.product.semanticId
retail_product_id = consumption_flow.product
contained_quantity = consumption_flow.quantity.value.to_i
@@ -61,12 +71,19 @@ class FdcOfferBroker
end
end
def catalog_item(id)
@catalog_by_id ||= catalog.index_by(&:semanticId)
@catalog_by_id[id]
end
def flow_producing(wholesale_product_id)
@production_flows_by_product_id ||= production_flows.index_by { |flow| flow.product.semanticId }
@production_flows_by_product_id ||= production_flows.index_by(&:product)
@production_flows_by_product_id[wholesale_product_id]
end
def production_flows
@production_flows ||= catalog.select_type("dfc-b:AsPlannedProductionFlow")
@production_flows ||= catalog.select do |i|
i.semanticType == "dfc-b:AsPlannedProductionFlow"
end
end
end

View File

@@ -15,7 +15,7 @@ module Orders
order.send_cancellation_email = @send_cancellation_email
order.restock_items = @restock_items
order.cancel
end.tap { |orders| AmendBackorderJob.schedule_bulk_update_for(orders) }
end
# rubocop:enable Rails/FindEach
end

View File

@@ -10,7 +10,6 @@ module Orders
return unless order.cancel
Spree::OrderMailer.cancel_email_for_shop(order).deliver_later
AmendBackorderJob.perform_later(order)
end
private

View File

@@ -24,7 +24,9 @@ class PlaceProxyOrder
send_placement_email
rescue StandardError => e
summarizer.record_and_log_error(:processing, order, e.message)
Alert.raise_with_record(e, order)
Bugsnag.notify(e) do |payload|
payload.add_metadata :order, :order, order
end
end
private
@@ -54,7 +56,9 @@ class PlaceProxyOrder
true
rescue StandardError => e
Alert.raise(e, { proxy_order: { subscription:, proxy_order: } })
Bugsnag.notify(e) do |payload|
payload.add_metadata(:proxy_order, { subscription:, proxy_order: })
end
false
end

View File

@@ -145,7 +145,7 @@ module Sets
end
def notify_bugsnag(error, product, variant, variant_attributes)
Alert.raise(error) do |report|
Bugsnag.notify(error) do |report|
report.add_metadata( :product_set,
{ product: product.attributes, variant_attributes:,
variant: variant.attributes } )

View File

@@ -58,7 +58,7 @@ module VariantUnits
def option_value_value_unit_scaled
unit_scale, unit_name = scale_for_unit_value
value = (@nameable.unit_value.to_d / unit_scale).round(2)
value = (@nameable.unit_value / unit_scale).to_d.truncate(2)
[value, unit_name]
end

View File

@@ -65,7 +65,6 @@ module VariantUnits
def unit_value_attributes
units = { unit_presentation: option_value_name }
units.merge!(variant_unit:) if has_attribute?(:variant_unit)
units.merge!(variant_unit_name: '') if reset_variant_unit_name?
units
end
@@ -80,9 +79,5 @@ module VariantUnits
VariantUnits::OptionValueNamer.new(self).name
end
def reset_variant_unit_name?
has_attribute?(:variant_unit_name) && has_attribute?(:variant_unit) && variant_unit != 'items'
end
end
end

View File

@@ -1,81 +0,0 @@
# frozen_string_literal: true
require "faraday"
module Vine
class ApiService
attr_reader :api_key, :jwt_generator
def initialize(api_key:, jwt_generator:)
@vine_api_url = ENV.fetch("VINE_API_URL")
@api_key = api_key
@jwt_generator = jwt_generator
end
def my_team
my_team_url = "#{@vine_api_url}/my-team"
call_with_logging do
connection.get(my_team_url)
end
end
def voucher_validation(voucher_short_code)
voucher_validation_url = "#{@vine_api_url}/voucher-validation"
call_with_logging do
connection.post(
voucher_validation_url,
{ type: "voucher_code", value: voucher_short_code },
'Content-Type': "application/json"
)
end
end
def voucher_redemptions(voucher_id, voucher_set_id, amount)
voucher_redemptions_url = "#{@vine_api_url}/voucher-redemptions"
call_with_logging do
connection.post(
voucher_redemptions_url,
{ voucher_id:, voucher_set_id:, amount: amount.to_i },
'Content-Type': "application/json"
)
end
end
private
def connection
jwt = jwt_generator.generate_token
Faraday.new(
request: { timeout: 30 },
headers: {
'X-Authorization': "JWT #{jwt}",
Accept: "application/json"
}
) do |f|
f.request :json
f.response :json
f.request :authorization, 'Bearer', api_key
f.use Faraday::Response::RaiseError
end
end
def call_with_logging
yield
rescue Faraday::ClientError, Faraday::ServerError => e
# caller_location(2,1) gets us the second entry in the stacktrace,
# ie the method where `call_with_logging` is called from
log_error("#{self.class}##{caller_locations(2, 1)[0].label}", e.response)
# Re raise the same exception
raise
end
def log_error(prefix, response)
Rails.logger.error "#{prefix} -- response_status: #{response[:status]}"
Rails.logger.error "#{prefix} -- response: #{response[:body]}"
end
end
end

View File

@@ -1,23 +0,0 @@
# frozen_string_literal: true
module Vine
class JwtService
ALGORITHM = "HS256"
ISSUER = "openfoodnetwork"
def initialize(secret: )
@secret = secret
end
def generate_token
generation_time = Time.zone.now
payload = {
iss: ISSUER,
iat: generation_time.to_i,
exp: (generation_time + 1.minute).to_i,
}
JWT.encode(payload, @secret, ALGORITHM)
end
end
end

View File

@@ -1,62 +0,0 @@
# frozen_string_literal: true
module Vine
class VoucherRedeemerService
attr_reader :order, :errors
def initialize(order: )
@order = order
@errors = {}
end
def redeem
# Do nothing if we don't have a vine voucher added to the order
@voucher_adjustment = order.voucher_adjustments.first
@voucher = @voucher_adjustment&.originator
return true if @voucher_adjustment.nil? || !@voucher.is_a?(Vouchers::Vine)
return false if vine_settings.nil?
call_vine_api
@voucher_adjustment.close
true
rescue Faraday::ClientError => e
handle_errors(e.response)
false
rescue Faraday::Error => e
Rails.logger.error e.inspect
Bugsnag.notify(e)
errors[:vine_api] = I18n.t("vine_voucher_validator_service.errors.vine_api")
false
end
private
def vine_settings
@vine_settings ||= ConnectedApps::Vine.find_by(enterprise: order.distributor)&.data
end
def call_vine_api
jwt_service = Vine::JwtService.new(secret: vine_settings["secret"])
vine_api = Vine::ApiService.new(api_key: vine_settings["api_key"], jwt_generator: jwt_service)
# Voucher adjustment amount is stored in dollars and negative, VINE expect cents
amount = -1 * @voucher_adjustment.amount * 100
vine_api.voucher_redemptions(
@voucher.external_voucher_id, @voucher.external_voucher_set_id, amount
)
end
def handle_errors(response)
if response[:status] == 400
errors[:redeeming_failed] = I18n.t("vine_voucher_redeemer_service.errors.redeeming_failed")
else
errors[:vine_api] = I18n.t("vine_voucher_redeemer_service.errors.vine_api")
end
end
end
end

View File

@@ -1,71 +0,0 @@
# frozen_string_literal: true
module Vine
class VoucherValidatorService
attr_reader :voucher_code, :errors
def initialize(voucher_code:, enterprise:)
@voucher_code = voucher_code
@enterprise = enterprise
@errors = {}
end
def validate
return nil if vine_settings.nil?
response = call_vine_api
save_voucher(response)
rescue Faraday::ClientError => e
handle_errors(e.response)
nil
rescue Faraday::Error => e
Rails.logger.error e.inspect
Bugsnag.notify(e)
errors[:vine_api] = I18n.t("vine_voucher_validator_service.errors.vine_api")
nil
end
private
def vine_settings
@vine_settings ||= ConnectedApps::Vine.find_by(enterprise: @enterprise)&.data
end
def call_vine_api
# Check voucher is valid
jwt_service = Vine::JwtService.new(secret: vine_settings["secret"])
vine_api = Vine::ApiService.new(api_key: vine_settings["api_key"], jwt_generator: jwt_service)
vine_api.voucher_validation(voucher_code)
end
def handle_errors(response)
if response[:status] == 400
errors[:invalid_voucher] = I18n.t("vine_voucher_validator_service.errors.invalid_voucher")
elsif response[:status] == 404
errors[:not_found_voucher] =
I18n.t("vine_voucher_validator_service.errors.not_found_voucher")
else
errors[:vine_api] = I18n.t("vine_voucher_validator_service.errors.vine_api")
end
end
def save_voucher(response)
voucher_data = response.body["data"]
# Check if voucher already exist
voucher = Vouchers::Vine.find_or_initialize_by(
code: voucher_code,
enterprise: @enterprise,
external_voucher_id: voucher_data["id"],
external_voucher_set_id: voucher_data["voucher_set_id"]
)
voucher.amount = voucher_data["voucher_value_remaining"].to_f / 100
voucher.save
voucher
end
end
end

View File

@@ -0,0 +1,39 @@
# frozen_string_literal: true
require "faraday"
class VineApiService
attr_reader :api_key, :jwt_generator
def initialize(api_key:, jwt_generator:)
@vine_api_url = ENV.fetch("VINE_API_URL")
@api_key = api_key
@jwt_generator = jwt_generator
end
def my_team
my_team_url = "#{@vine_api_url}/my-team"
jwt = jwt_generator.generate_token
connection = Faraday.new(
request: { timeout: 30 },
headers: {
'X-Authorization': "JWT #{jwt}",
Accept: "application/json"
}
) do |f|
f.request :json
f.response :json
f.request :authorization, 'Bearer', api_key
end
response = connection.get(my_team_url)
if !response.success?
Rails.logger.error "VineApiService#my_team -- response_status: #{response.status}"
Rails.logger.error "VineApiService#my_team -- response: #{response.body}"
end
response
end
end

View File

@@ -0,0 +1,21 @@
# frozen_string_literal: true
class VineJwtService
ALGORITHM = "HS256"
ISSUER = "openfoodnetwork"
def initialize(secret: )
@secret = secret
end
def generate_token
generation_time = Time.zone.now
payload = {
iss: ISSUER,
iat: generation_time.to_i,
exp: (generation_time + 1.minute).to_i,
}
JWT.encode(payload, @secret, ALGORITHM)
end
end

View File

@@ -1,25 +0,0 @@
# frozen_string_literal: false
# paranoia doesn't support unique validation including deleted records:
# https://github.com/rubysherpas/paranoia/pull/333
# We use a custom validator to fix the issue, so we don't need to fork/patch the gem
module Vouchers
class ScopedUniquenessValidator < ActiveModel::Validator
def validate(record)
@record = record
return unless unique_voucher_code_per_enterprise?
record.errors.add :code, :taken, value: @record.code
end
private
def unique_voucher_code_per_enterprise?
query = Voucher.with_deleted.where(code: @record.code, enterprise_id: @record.enterprise_id)
query = query.where.not(id: @record.id) unless @record.id.nil?
query.present?
end
end
end

View File

@@ -3,7 +3,7 @@
= t('.add_new')
%br
- if @enterprise.vouchers.where.not(type: "Vouchers::Vine").with_deleted.present?
- if @enterprise.vouchers.with_deleted.present?
%table
%thead
%tr
@@ -17,7 +17,7 @@
/%th= t('.customers')
/%th= t('.net_value')
%tbody
- @enterprise.vouchers.where.not(type: "Vouchers::Vine").with_deleted.order(deleted_at: :desc, code: :asc).each do |voucher|
- @enterprise.vouchers.with_deleted.order(deleted_at: :desc, code: :asc).each do |voucher|
%tr
%td= voucher.code
%td= voucher.display_value

View File

@@ -10,8 +10,6 @@
%em= t(".need_to_be_manager") unless managed_by_user?(enterprise)
- else
= button_to t(".disable"), admin_enterprise_connected_app_path(connected_app.id, enterprise_id: enterprise.id), method: :delete
%hr
.connected-app__description
%p= t ".description"
%b= link_to(t(".link_label_html"), t(".link_url"), target: "_blank")
= t ".description_html"

View File

@@ -27,5 +27,4 @@
%hr
.connected-app__description
%p= t ".description"
%b= link_to(t(".link_label_html"), t(".link_url"), target: "_blank")
= t ".description_html"

View File

@@ -21,12 +21,10 @@
-# This is only seen by super-admins:
%em= t(".need_to_be_manager") unless managed_by_user?(enterprise)
%hr
.connected-app__description
= t ".description"
- else
.connected-app__vine-content
.vine-disable
= button_to t(".disable"), admin_enterprise_connected_app_path(connected_app.id, enterprise_id: enterprise.id), method: :delete
.connected-app__vine-link
%b= link_to(t(".link_label_html"), t(".link_url"), target: "_blank")
%hr
.connected-app__description
= t ".description_html"

View File

@@ -6,11 +6,11 @@
.container
.sixteen.columns
= render partial: 'admin/shared/flashes', locals: { flashes: } if defined? flashes
= render partial: 'filters', locals: { search_term:,
producer_id:,
producer_options:,
category_options:,
category_id: } if display_search_filter
= render partial: 'filters', locals: { search_term: search_term,
producer_id: producer_id,
producer_options: producer_options,
category_options: category_options,
category_id: category_id }
- if products.any?
.container.results
.sixteen.columns

View File

@@ -5,6 +5,8 @@
= f.text_field :name, 'aria-label': t('admin.products_page.columns.name')
= error_message_on product, :name
%td.col-sku.field.naked_inputs
= f.text_field :sku, 'aria-label': t('admin.products_page.columns.sku')
= error_message_on product, :sku
%td.col-unit_scale.align-right
-# empty
%td.col-unit.align-right

View File

@@ -12,7 +12,7 @@
= f.hidden_field :variant_unit_scale
= f.select :variant_unit_with_scale,
options_for_select(WeightsAndMeasures.variant_unit_options, variant.variant_unit_with_scale),
{ include_blank: t('.select_unit_scale') },
{ include_blank: true },
{ class: "fullwidth no-input", 'aria-label': t('admin.products_page.columns.unit_scale'), data: { "controller": "tom-select", "tom-select-options-value": '{ "plugins": [] }', action: "change->toggle-control#displayIfMatch" }, required: true }
= error_message_on variant, :variant_unit, 'data-toggle-control-target': 'control'
.field

View File

@@ -15,8 +15,7 @@
= render partial: "content", locals: { products: @products, pagy: @pagy, search_term: @search_term,
producer_options: producers, producer_id: @producer_id,
category_options: categories, category_id: @category_id,
tax_category_options:, flashes: flash,
display_search_filter: (@products.any? || @search_term.present? || @category_id.present?) }
tax_category_options:, flashes: flash }
- %w[product variant].each do |object_type|
= render partial: 'delete_modal', locals: { object_type: }
#modal-component

View File

@@ -1,5 +1,5 @@
.medium-6#checkout-payment-methods
- if @order.distributor.vouchers.present? || @order.distributor.connected_apps.vine.present?
- if @order.distributor.vouchers.present?
%div.checkout-substep
= render partial: "checkout/voucher_section", locals: { order: @order, voucher_adjustment: @order.voucher_adjustments.first }

View File

@@ -81,9 +81,6 @@
= t :first_name
%th.text-right
= t :last_name
- if @display_business_name
%th.text-right
= t :business_name
%tbody
- @customer_line_items.each do |line_item|
%tr
@@ -100,9 +97,6 @@
= line_item[:first_name]
%td
= line_item[:last_name]
- if @display_business_name
%td
= line_item[:business_name]
%p
= t :producer_mail_text_after
%p

View File

@@ -1,13 +1,13 @@
#{t :producer_mail_greeting} #{raw(@producer.name)},
#{t :producer_mail_greeting} #{@producer.name},
\
= t :producer_mail_text_before
\
- @distributors_pickup_times.each do |distributor_name, pickup_time|
\- #{raw(distributor_name)} (#{pickup_time})
\- #{distributor_name} (#{pickup_time})
\
- if @receival_instructions
= t :producer_mail_delivery_instructions
= raw(@receival_instructions)
= @receival_instructions
\
Orders summary
================
@@ -15,22 +15,22 @@ Orders summary
= t :producer_mail_order_text
\
- @grouped_line_items.each_pair do |product_and_full_name, line_items|
#{line_items.first.variant.sku} - #{raw(line_items.first.variant.supplier.name)} - #{raw(product_and_full_name)} (#{t(:producer_mail_qty)}: #{line_items.sum(&:quantity)}) @ #{line_items.first.single_money} = #{Spree::Money.new(line_items.sum(&:total), currency: line_items.first.currency)} (#{t(:with_tax_incl, amount: Spree::Money.new(line_items.sum(&:included_tax), currency: line_items.first.currency))})
#{line_items.first.variant.sku} - #{raw(line_items.first.variant.supplier.name)} - #{raw(product_and_full_name)} (QTY: #{line_items.sum(&:quantity)}) @ #{line_items.first.single_money} = #{Spree::Money.new(line_items.sum(&:total), currency: line_items.first.currency)}
\
\
#{t :total}: #{@total} (#{t(:with_tax_incl, amount: @tax_total)})
#{t :total}: #{@total}
\
- if @customer_line_items
- if @customer_grouped_line_items
= t :producer_mail_order_customer_text
\
- @customer_line_items.each do |line_item|
#{line_item[:sku]} - #{raw(line_item[:supplier_name])} - #{raw(line_item[:product_and_full_name])} (#{t(:producer_mail_qty)}: #{line_item[:quantity]}) - #{raw(line_item[:first_name])} #{raw(line_item[:last_name])} #{raw(line_item[:business_name])}
#{line_item[:sku]} - #{raw(line_item[:supplier_name])} - #{raw(line_item[:product_and_full_name])} (QTY: #{line_item[:quantity]}) - #{raw(line_item[:first_name])} #{raw(line_item[:last_name])}
\
\
= t :producer_mail_text_after
#{t :producer_mail_signoff},
#{raw(@coordinator.name)}
#{raw(@coordinator.address.address1)}, #{raw(@coordinator.address.city)}, #{raw(@coordinator.address.zipcode)}
#{@coordinator.name}
#{@coordinator.address.address1}, #{@coordinator.address.city}, #{@coordinator.address.zipcode}
#{@coordinator.phone}
#{@coordinator.contact.email}

View File

@@ -1,5 +1,5 @@
= f.field_container :tax_category_id do
= f.label :tax_category_id, t(:tax_category)
%br
= f.collection_select(:tax_category_id, Spree::TaxCategory.all, :id, :name, tax_category_dropdown_options(Spree::Config.products_require_tax_category), {:class => "select2 fullwidth"})
= f.collection_select(:tax_category_id, Spree::TaxCategory.all, :id, :name, {:include_blank => Spree::Config.products_require_tax_category ? false : t(:none)}, {:class => "select2 fullwidth"})
= f.error_message_on :tax_category_id

View File

@@ -43,10 +43,7 @@
- checkout_adjustments_for(@order, exclude: [:line_item]).reverse_each do |adjustment|
%tr
%td{align: "right", colspan: "3"}
- if adjustment.originator_type == "Voucher"
= t(:email_order_summary_voucher_label, code: adjustment.label)
- else
= "#{adjustment.label}:"
= "#{adjustment.label}:"
%td{align: "right"}
= adjustment.display_amount
%tr

View File

@@ -13,7 +13,6 @@ import { ru } from "flatpickr/dist/l10n/ru";
import { sv } from "flatpickr/dist/l10n/sv";
import { tr } from "flatpickr/dist/l10n/tr";
import { en } from "flatpickr/dist/l10n/default.js";
import { hu } from "flatpickr/dist/l10n/hu";
import ShortcutButtonsPlugin from "shortcut-buttons-flatpickr";
import labelPlugin from "flatpickr/dist/plugins/labelPlugin/labelPlugin";
@@ -37,7 +36,6 @@ export default class extends Flatpickr {
sv: sv,
tr: tr,
en: en,
hu: hu,
};
initialize() {

View File

@@ -12,8 +12,8 @@ export const useOpenAndCloseAsAModal = (controller) => {
}.bind(controller),
close: function (_event, remove = false) {
// Only execute close if the current modal is open
if (!this.modalTarget.classList.contains('in')) return;
// Only execute close if there is an open modal
if (!document.querySelector("body").classList.contains('modal-open')) return;
this.modalTarget.classList.remove("in");
this.backgroundTarget.classList.remove("in");

View File

@@ -10,8 +10,7 @@ export default class extends Controller {
changePage(event) {
const productsForm = document.querySelector("#products-form");
if (productsForm) productsForm.scrollIntoView({ behavior: "smooth" });
productsForm.scrollIntoView({ behavior: "smooth" });
this.page.value = event.target.dataset.page;
this.submitSearch();
this.page.value = 1;

View File

@@ -64,6 +64,14 @@ products .filter-box {
li.active a {
color: $clr-brick;
render-svg {
svg {
path {
fill: $clr-brick;
}
}
}
&:hover, &:focus {
border-color: $clr-brick-bright;
}
@@ -94,10 +102,43 @@ products .filter-box {
&:hover, &:focus {
color: $clr-brick-bright;
render-svg {
svg {
path {
fill: $clr-brick-bright;
}
}
}
}
&:active, &.active {
color: $clr-brick;
render-svg {
svg {
path {
fill: $clr-brick;
}
}
}
}
render-svg {
display: block;
width: 1.5rem;
height: 1.5rem;
margin: 0 0.2rem 0 0;
padding: 0;
svg {
width: 1.5rem;
height: 1.5rem;
path {
fill: #666;
}
}
}
}
}

View File

@@ -35,9 +35,32 @@
padding-left: 0.25rem;
}
render-svg {
&, & svg {
width: 1rem;
height: 1rem;
float: left;
padding-right: 0.25rem;
path {
@include csstrans;
fill: $base-clr;
}
}
}
&:hover, &:focus {
border-color: $hover-clr;
color: $hover-clr;
render-svg {
svg {
path {
fill: $hover-clr;
}
}
}
}
&.disabled {
@@ -46,6 +69,14 @@
&:hover, &:focus {
border-color: $border-clr;
color: $base-clr;
render-svg {
svg {
path {
fill: $base-clr;
}
}
}
}
}
@@ -53,6 +84,14 @@
border: 1px solid $base-clr;
background: $base-clr;
color: white;
render-svg {
svg {
path {
fill: white;
}
}
}
}
}
}

View File

@@ -13,8 +13,66 @@
padding: 0.25rem 0.5rem 0.35rem 0.35rem;
render-svg {
svg {
width: 16px;
height: 16px;
path {
fill: #555;
}
}
}
&, & * {
display: inline-block;
color: #555;
}
}
.inactive {
.fat-taxons {
render-svg {}
svg {
path {
fill: $disabled-dark;
}
}
}
}
.product-header {
render-svg {
svg {
width: 32px;
height: 32px;
path {
fill: black;
}
}
}
@include breakpoint(phablet) {
render-svg {
svg {
width: 24px;
height: 24px;
}
}
}
}
.summary-header {
render-svg {
svg {
width: 18px;
height: 18px;
path {
fill: $clr-brick;
}
}
}
}

View File

@@ -8,24 +8,10 @@ document.addEventListener("turbo:frame-missing", (event) => {
event.preventDefault();
// show error message instead
showError(event.detail.response?.status);
});
document.addEventListener("turbo:submit-end", (event) => {
if (!event.detail.success){
// show error message on failure
showError(event.detail.fetchResponse?.statusCode);
event.preventDefault();
}
});
function showError(status) {
// Note that other 4xx errors will be handled differently.
status = event.detail.response.status;
if(status == 401) {
alert(I18n.t("errors.unauthorized.message"));
} else if(status === undefined) {
alert(I18n.t("errors.network_error.message"));
} else if (status >= 500) {
} else {
alert(I18n.t("errors.general_error.message"));
}
}
});

View File

@@ -6,9 +6,5 @@ Bugsnag.configure do |config|
config.logger = Logger.new(STDOUT)
config.logger.level = Logger::ERROR
end
# If you want to notify Bugsnag in dev or test then set the env var:
# spring stop
# BUGSNAG=true ./bin/rails console
config.notify_release_stages = %w(production staging) unless ENV["BUGSNAG"]
config.notify_release_stages = %w(production staging)
end

View File

@@ -1138,6 +1138,14 @@ ar:
discover_regen:
tagline: "By clicking Allow data sharing, you are sharing data between Open Food Network and the Discover Regenerative Portal, and agreeing to make specific data public on the Portal, which will also be publicly available on the API.\nIMPORTANT: Before you stop sharing and remove your listing, please first contact hello@regenerative.org.au to request a copy of your Discover Regenerative data."
loading: "جار التحميل"
description_html: |
<p>
Discover Regenerative is a showcase of Australias Regenerative Farmers, their produce/products, outcomes and achievements. It simplifies how business-to-business (B2B) / wholesale buyers can find regenerative produce and directly connect with Producers.
</p>
<p>
<a href="https://regenerative.org.au/" target="_blank"><b>Visit Discover Regenerative</b>
<i class="icon-external-link"></i></a>
</p>
vine:
enable: "الاتصال"
actions:
@@ -2968,7 +2976,6 @@ ar:
ready: جاهز
pending: قيد الانتظار
shipped: تم الشحن
business_name: الاسم التجاري
js:
saving: 'حفظ...'
changes_saved: 'تم حفظ التغييرات.'

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