mv lib to app/lib due to upgrade
This commit is contained in:
parent
3d81dd6b57
commit
4ff44aed4c
26 changed files with 75 additions and 78 deletions
6
app/lib/api/errors.rb
Normal file
6
app/lib/api/errors.rb
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
module Api::Errors
|
||||
class Error < StandardError; end
|
||||
|
||||
# Authentication is handled by Doorkeeper, so no errors for that here
|
||||
class PermissionRequired < Error; end
|
||||
end
|
||||
43
app/lib/apple_bar.rb
Normal file
43
app/lib/apple_bar.rb
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
class AppleBar
|
||||
attr_reader :ordergroup
|
||||
|
||||
def initialize(ordergroup)
|
||||
@ordergroup = ordergroup
|
||||
@group_avg = ordergroup.avg_jobs_per_euro.to_f
|
||||
@global_avg = Ordergroup.avg_jobs_per_euro
|
||||
end
|
||||
|
||||
# Show group bar in following colors:
|
||||
# Green if higher than 100
|
||||
# Yellow if lower than 100 an higher than stop_ordering_under option value
|
||||
# Red if below stop_ordering_under, the ordergroup isn't allowed to participate in an order anymore
|
||||
def group_bar_state
|
||||
if apples >= 100
|
||||
'success'
|
||||
elsif FoodsoftConfig[:stop_ordering_under].present? &&
|
||||
(apples >= FoodsoftConfig[:stop_ordering_under])
|
||||
'warning'
|
||||
else
|
||||
'danger'
|
||||
end
|
||||
end
|
||||
|
||||
# Use apples as percentage, but show at least 10 percent
|
||||
def group_bar_width
|
||||
[@ordergroup.apples, 2].max
|
||||
end
|
||||
|
||||
def mean_order_amount_per_job
|
||||
(1 / @global_avg).round
|
||||
rescue
|
||||
0
|
||||
end
|
||||
|
||||
def apples
|
||||
@apples ||= @ordergroup.apples
|
||||
end
|
||||
|
||||
def with_restriction?
|
||||
FoodsoftConfig[:stop_ordering_under].present?
|
||||
end
|
||||
end
|
||||
43
app/lib/articles_csv.rb
Normal file
43
app/lib/articles_csv.rb
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
class ArticlesCsv < RenderCSV
|
||||
include ApplicationHelper
|
||||
|
||||
def header
|
||||
[
|
||||
Article.human_attribute_name(:availability_short),
|
||||
Article.human_attribute_name(:order_number),
|
||||
Article.human_attribute_name(:name),
|
||||
Article.human_attribute_name(:note),
|
||||
Article.human_attribute_name(:manufacturer),
|
||||
Article.human_attribute_name(:origin),
|
||||
Article.human_attribute_name(:unit),
|
||||
Article.human_attribute_name(:price),
|
||||
Article.human_attribute_name(:tax),
|
||||
Article.human_attribute_name(:deposit),
|
||||
Article.human_attribute_name(:unit_quantity),
|
||||
'',
|
||||
'',
|
||||
Article.human_attribute_name(:article_category)
|
||||
]
|
||||
end
|
||||
|
||||
def data
|
||||
@object.each do |o|
|
||||
yield [
|
||||
'',
|
||||
o.order_number,
|
||||
o.name,
|
||||
o.note,
|
||||
o.manufacturer,
|
||||
o.origin,
|
||||
o.unit,
|
||||
o.price,
|
||||
o.tax,
|
||||
o.deposit,
|
||||
o.unit_quantity,
|
||||
'',
|
||||
'',
|
||||
o.article_category.try(:name)
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
136
app/lib/bank_account_connector.rb
Normal file
136
app/lib/bank_account_connector.rb
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
class BankAccountConnector
|
||||
class TextItem
|
||||
def initialize(text)
|
||||
@text = text
|
||||
end
|
||||
|
||||
def name
|
||||
nil
|
||||
end
|
||||
|
||||
attr_reader :text
|
||||
end
|
||||
|
||||
class TextField
|
||||
def initialize(name, value, label)
|
||||
@name = name
|
||||
@value = value
|
||||
@label = label
|
||||
end
|
||||
|
||||
def type
|
||||
nil
|
||||
end
|
||||
|
||||
attr_reader :name, :value
|
||||
|
||||
def label
|
||||
@label || @name.to_s
|
||||
end
|
||||
end
|
||||
|
||||
class PasswordField < TextField
|
||||
def type
|
||||
:password
|
||||
end
|
||||
end
|
||||
|
||||
class HiddenField < TextField
|
||||
def type
|
||||
:hidden
|
||||
end
|
||||
end
|
||||
|
||||
@@registered_classes = Set.new
|
||||
|
||||
def self.register(klass)
|
||||
@@registered_classes.add klass
|
||||
end
|
||||
|
||||
def self.find(iban)
|
||||
@@registered_classes.each do |klass|
|
||||
return klass if klass.handles(iban)
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def initialize(bank_account)
|
||||
@bank_account = bank_account
|
||||
@auto_submit = nil
|
||||
@controls = []
|
||||
@count = 0
|
||||
end
|
||||
|
||||
def iban
|
||||
@bank_account.iban
|
||||
end
|
||||
|
||||
attr_reader :auto_submit, :controls, :count
|
||||
|
||||
def text(data)
|
||||
@controls += [TextItem.new(data)]
|
||||
end
|
||||
|
||||
def confirm_text(code)
|
||||
text t('.confirm', code: code)
|
||||
end
|
||||
|
||||
def wait_with_text(auto_submit, code)
|
||||
@auto_submit = auto_submit
|
||||
confirm_text code
|
||||
end
|
||||
|
||||
def wait_for_app(code)
|
||||
hidden_field :twofactor, code
|
||||
wait_with_text 3000, code
|
||||
nil
|
||||
end
|
||||
|
||||
def text_field(name, value = nil)
|
||||
@controls += [TextField.new(name, value, t(name))]
|
||||
end
|
||||
|
||||
def hidden_field(name, value)
|
||||
@controls += [HiddenField.new(name, value, 'HIDDEN')]
|
||||
end
|
||||
|
||||
def password_field(name, value = nil)
|
||||
@controls += [PasswordField.new(name, value, t(name))]
|
||||
end
|
||||
|
||||
def set_balance(amount)
|
||||
@bank_account.balance = amount
|
||||
end
|
||||
|
||||
def set_balance_as_sum
|
||||
@bank_account.balance = @bank_account.bank_transactions.sum(:amount)
|
||||
end
|
||||
|
||||
def continuation_point
|
||||
@bank_account.import_continuation_point
|
||||
end
|
||||
|
||||
def set_continuation_point(data)
|
||||
@bank_account.import_continuation_point = data
|
||||
end
|
||||
|
||||
def update_or_create_transaction(external_id, data = {})
|
||||
@bank_account.bank_transactions.where(external_id: external_id).first_or_create.update(data)
|
||||
@count += 1
|
||||
end
|
||||
|
||||
def finish
|
||||
@bank_account.last_import = Time.now
|
||||
@bank_account.save!
|
||||
end
|
||||
|
||||
def load(data); end
|
||||
|
||||
def dump; end
|
||||
|
||||
def t(key, args = {})
|
||||
return t(".fields.#{key}") unless key.is_a? String
|
||||
|
||||
I18n.t 'bank_account_connector' + key, args
|
||||
end
|
||||
end
|
||||
30
app/lib/bank_account_connector_external.rb
Normal file
30
app/lib/bank_account_connector_external.rb
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
class BankAccountConnectorExternal < BankAccountConnector
|
||||
def load(data)
|
||||
@connector = create_connector
|
||||
@connector.load data
|
||||
end
|
||||
|
||||
def dump
|
||||
@connector.dump
|
||||
end
|
||||
|
||||
def connector_import
|
||||
set_balance @connector.balance iban
|
||||
cp = @connector.transactions iban, continuation_point do |t|
|
||||
update_or_create_transaction t[:id], map_transaction(t)
|
||||
end
|
||||
set_continuation_point cp if cp
|
||||
end
|
||||
|
||||
def connector_logout
|
||||
@connector.logout
|
||||
end
|
||||
|
||||
def import(data)
|
||||
return false unless connector_login(data)
|
||||
|
||||
connector_import
|
||||
connector_logout
|
||||
true
|
||||
end
|
||||
end
|
||||
58
app/lib/bank_account_information_importer.rb
Normal file
58
app/lib/bank_account_information_importer.rb
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
class BankAccountInformationImporter
|
||||
def initialize(bank_account)
|
||||
@bank_account = bank_account
|
||||
end
|
||||
|
||||
def import!(content)
|
||||
return nil if content.empty?
|
||||
|
||||
import_data! JSON.parse(content, symbolize_names: true)
|
||||
end
|
||||
|
||||
def import_data!(data)
|
||||
return 0 if data.empty?
|
||||
|
||||
booked = data.fetch(:transactions, {}).fetch(:booked, [])
|
||||
|
||||
ret = 0
|
||||
booked.each do |t|
|
||||
amount = parse_account_information_amount t[:transactionAmount]
|
||||
entityName = amount < 0 ? t[:creditorName] : t[:debtorName]
|
||||
entityAccount = amount < 0 ? t[:creditorAccount] : t[:debtorAccount]
|
||||
reference = [t[:endToEndId], t[:remittanceInformationUnstructured]].join("\n").strip
|
||||
|
||||
@bank_account.bank_transactions.where(external_id: t[:transactionId]).first_or_create.update({
|
||||
date: t[:bookingDate],
|
||||
amount: amount,
|
||||
iban: entityAccount && entityAccount[:iban],
|
||||
reference: reference,
|
||||
text: entityName,
|
||||
receipt: t[:additionalInformation]
|
||||
})
|
||||
ret += 1
|
||||
end
|
||||
|
||||
balances = (data[:balances] ? data[:balances].map { |b| [b[:balanceType], b[:balanceAmount]] } : []).to_h
|
||||
balance = balances.values.first
|
||||
%w(closingBooked expected authorised openingBooked interimAvailable forwardAvailable nonInvoiced).each do |type|
|
||||
value = balances[type]
|
||||
if value
|
||||
balance = value
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
@bank_account.balance = parse_account_information_amount(balance) || @bank_account.bank_transactions.sum(:amount)
|
||||
@bank_account.import_continuation_point = booked.first&.fetch(:entryReference, nil) unless booked.empty?
|
||||
@bank_account.last_import = Time.now
|
||||
@bank_account.save!
|
||||
|
||||
ret
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_account_information_amount(value)
|
||||
value && value[:amount].to_f
|
||||
end
|
||||
end
|
||||
32
app/lib/bank_transaction_reference.rb
Normal file
32
app/lib/bank_transaction_reference.rb
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
class BankTransactionReference
|
||||
# parses a string from a bank transaction field
|
||||
def self.parse(data)
|
||||
m = /(^|[^\w.])FS(?<group>\d+)(\.(?<user>\d+))?(?<parts>([A-Za-z]+\d+(\.\d+)?)+)([^\w.]|$)/.match(data)
|
||||
return unless m
|
||||
|
||||
parts = {}
|
||||
m[:parts].scan(/([A-Za-z]+)(\d+(\.\d+)?)/) do |category, value|
|
||||
value = value.to_f
|
||||
value += parts[category] if parts[category]
|
||||
parts[category] = value
|
||||
end
|
||||
|
||||
ret = { group: m[:group].to_i, parts: parts }
|
||||
ret[:user] = m[:user].to_i if m[:user]
|
||||
ret
|
||||
end
|
||||
|
||||
def self.js_code_for_user(user)
|
||||
%{
|
||||
function(items) {
|
||||
var ret = "FS#{user.ordergroup.id}.#{user.id}";
|
||||
for (var key in items) {
|
||||
if (items.hasOwnProperty(key)) {
|
||||
ret += key + items[key];
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
29
app/lib/bank_transactions_csv.rb
Normal file
29
app/lib/bank_transactions_csv.rb
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
require 'csv'
|
||||
|
||||
class BankTransactionsCsv < RenderCSV
|
||||
include ApplicationHelper
|
||||
|
||||
def header
|
||||
[
|
||||
BankTransaction.human_attribute_name(:external_id),
|
||||
BankTransaction.human_attribute_name(:date),
|
||||
BankTransaction.human_attribute_name(:amount),
|
||||
BankTransaction.human_attribute_name(:iban),
|
||||
BankTransaction.human_attribute_name(:reference),
|
||||
BankTransaction.human_attribute_name(:text)
|
||||
]
|
||||
end
|
||||
|
||||
def data
|
||||
@object.each do |t|
|
||||
yield [
|
||||
t.external_id,
|
||||
t.date,
|
||||
t.amount,
|
||||
t.iban,
|
||||
t.reference,
|
||||
t.text
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
78
app/lib/date_time_attribute_validate.rb
Normal file
78
app/lib/date_time_attribute_validate.rb
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# workaround for https://github.com/einzige/date_time_attribute/issues/14
|
||||
require 'date_time_attribute'
|
||||
|
||||
module DateTimeAttributeValidate
|
||||
extend ActiveSupport::Concern
|
||||
include DateTimeAttribute
|
||||
|
||||
module ClassMethods
|
||||
def date_time_attribute(*attributes)
|
||||
super
|
||||
|
||||
attributes.each do |attribute|
|
||||
validate -> { self.send("#{attribute}_datetime_value_valid") }
|
||||
|
||||
# allow resetting the field to nil
|
||||
before_validation do
|
||||
if self.instance_variable_get("@#{attribute}_is_set")
|
||||
date = self.instance_variable_get("@#{attribute}_date_value")
|
||||
time = self.instance_variable_get("@#{attribute}_time_value")
|
||||
if date.blank? && time.blank?
|
||||
self.send("#{attribute}=", nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# remember old date and time values
|
||||
define_method("#{attribute}_date_value=") do |val|
|
||||
self.instance_variable_set("@#{attribute}_is_set", true)
|
||||
self.instance_variable_set("@#{attribute}_date_value", val)
|
||||
begin
|
||||
self.send("#{attribute}_date=", val)
|
||||
rescue
|
||||
nil
|
||||
end
|
||||
end
|
||||
define_method("#{attribute}_time_value=") do |val|
|
||||
self.instance_variable_set("@#{attribute}_is_set", true)
|
||||
self.instance_variable_set("@#{attribute}_time_value", val)
|
||||
begin
|
||||
self.send("#{attribute}_time=", val)
|
||||
rescue
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# fallback to field when values are not set
|
||||
define_method("#{attribute}_date_value") do
|
||||
self.instance_variable_get("@#{attribute}_date_value") || self.send("#{attribute}_date").try { |e| e.strftime('%Y-%m-%d') }
|
||||
end
|
||||
define_method("#{attribute}_time_value") do
|
||||
self.instance_variable_get("@#{attribute}_time_value") || self.send("#{attribute}_time").try { |e| e.strftime('%H:%M') }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# validate date and time
|
||||
define_method("#{attribute}_datetime_value_valid") do
|
||||
date = self.instance_variable_get("@#{attribute}_date_value")
|
||||
unless date.blank? || begin
|
||||
Date.parse(date)
|
||||
rescue
|
||||
nil
|
||||
end
|
||||
errors.add(attribute, "is not a valid date") # @todo I18n
|
||||
end
|
||||
time = self.instance_variable_get("@#{attribute}_time_value")
|
||||
unless time.blank? || begin
|
||||
Time.parse(time)
|
||||
rescue
|
||||
nil
|
||||
end
|
||||
errors.add(attribute, "is not a valid time") # @todo I18n
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
33
app/lib/financial_transactions_csv.rb
Normal file
33
app/lib/financial_transactions_csv.rb
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
require 'csv'
|
||||
|
||||
class FinancialTransactionsCsv < RenderCSV
|
||||
include ApplicationHelper
|
||||
|
||||
def header
|
||||
[
|
||||
FinancialTransaction.human_attribute_name(:created_on),
|
||||
FinancialTransaction.human_attribute_name(:ordergroup),
|
||||
FinancialTransaction.human_attribute_name(:ordergroup),
|
||||
FinancialTransaction.human_attribute_name(:user),
|
||||
FinancialTransaction.human_attribute_name(:financial_transaction_class),
|
||||
FinancialTransaction.human_attribute_name(:financial_transaction_type),
|
||||
FinancialTransaction.human_attribute_name(:note),
|
||||
FinancialTransaction.human_attribute_name(:amount)
|
||||
]
|
||||
end
|
||||
|
||||
def data
|
||||
@object.includes(:user, :ordergroup, :financial_transaction_type).each do |t|
|
||||
yield [
|
||||
t.created_on,
|
||||
t.ordergroup_id,
|
||||
t.ordergroup_name,
|
||||
show_user(t.user),
|
||||
t.financial_transaction_type.financial_transaction_class.name,
|
||||
t.financial_transaction_type.name,
|
||||
t.note,
|
||||
number_to_currency(t.amount)
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
84
app/lib/foodsoft/expansion_variables.rb
Normal file
84
app/lib/foodsoft/expansion_variables.rb
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
module Foodsoft
|
||||
# Variable expansions for user-specified texts.
|
||||
#
|
||||
# This is used in wiki-pages and the footer, for example, to allow foodcoops
|
||||
# to show dynamic information in the text.
|
||||
#
|
||||
# Plugins can modify the variables by means of the `#variables` accessor.
|
||||
# Please be thoughful when choosing names as to avoid collisions.
|
||||
# Do not put non-public info in variables.
|
||||
module ExpansionVariables
|
||||
ACTIVE_MONTHS = 3
|
||||
|
||||
# @return [Hash] Variables and their values
|
||||
cattr_accessor :variables
|
||||
|
||||
# Hash of variables. Note that keys are Strings.
|
||||
@@variables = {
|
||||
'scope' => -> { FoodsoftConfig.scope },
|
||||
'name' => -> { FoodsoftConfig[:name] },
|
||||
'contact.street' => -> { FoodsoftConfig[:contact][:street] },
|
||||
'contact.zip_code' => -> { FoodsoftConfig[:contact][:zip_code] },
|
||||
'contact.city' => -> { FoodsoftConfig[:contact][:city] },
|
||||
'contact.country' => -> { FoodsoftConfig[:contact][:country] },
|
||||
'contact.email' => -> { FoodsoftConfig[:contact][:email] },
|
||||
'contact.phone' => -> { FoodsoftConfig[:contact][:phone] },
|
||||
'price_markup' => -> { FoodsoftConfig[:price_markup] },
|
||||
'homepage' => -> { FoodsoftConfig[:homepage] },
|
||||
|
||||
'help_url' => -> { FoodsoftConfig[:help_url] },
|
||||
'applepear_url' => -> { FoodsoftConfig[:applepear_url] },
|
||||
|
||||
'foodsoft.url' => -> { FoodsoftConfig[:foodsoft_url] },
|
||||
'foodsoft.version' => Foodsoft::VERSION,
|
||||
'foodsoft.revision' => Foodsoft::REVISION,
|
||||
|
||||
'user_count' => -> { User.undeleted.count },
|
||||
'ordergroup_count' => -> { Ordergroup.undeleted.count },
|
||||
'active_ordergroup_count' => -> { active_ordergroup_count },
|
||||
'supplier_count' => -> { Supplier.undeleted.count },
|
||||
'active_supplier_count' => -> { active_supplier_count },
|
||||
'active_suppliers' => -> { active_suppliers },
|
||||
'first_order_date' => -> { I18n.l Order.first.try { |o| o.starts.to_date } }
|
||||
}
|
||||
|
||||
# Return expanded variable
|
||||
# @return [String] Expanded variable
|
||||
def self.get(var)
|
||||
s = @@variables[var.to_s]
|
||||
s.respond_to?(:call) ? s.call : s.to_s
|
||||
end
|
||||
|
||||
# Expand variables in a string
|
||||
# @param str [String] String to expand variables in
|
||||
# @param options [Hash<String, String>] Extra variables to expand
|
||||
# @return [String] Expanded string
|
||||
def self.expand(str, options = {})
|
||||
str.gsub(/{{([._a-zA-Z0-9]+)}}/) do
|
||||
options[::Regexp.last_match(1)] || self.get(::Regexp.last_match(1))
|
||||
end
|
||||
end
|
||||
|
||||
# @return [Number] Number of ordergroups that have been active in the past 3 months
|
||||
def self.active_ordergroup_count
|
||||
GroupOrder
|
||||
.where('updated_on > ?', ACTIVE_MONTHS.months.ago)
|
||||
.select(:ordergroup_id).distinct.count
|
||||
end
|
||||
|
||||
# @return [Number] Number of suppliers that has been ordered from in the past 3 months
|
||||
def self.active_supplier_count
|
||||
Order
|
||||
.where('starts > ?', ACTIVE_MONTHS.months.ago)
|
||||
.select(:supplier_id).distinct.count
|
||||
end
|
||||
|
||||
# @return [String] Comma-separated list of suppliers that has been ordered from in the past 3 months
|
||||
def self.active_suppliers
|
||||
Supplier.joins(:orders)
|
||||
.where('orders.starts > ?', ACTIVE_MONTHS.months.ago)
|
||||
.order(:name).select(:name).distinct
|
||||
.map(&:name).join(', ')
|
||||
end
|
||||
end
|
||||
end
|
||||
302
app/lib/foodsoft_config.rb
Normal file
302
app/lib/foodsoft_config.rb
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
# Foodcoop-specific configuration.
|
||||
#
|
||||
# This is loaded from +config/app_config.yml+, which contains a root
|
||||
# key for each environment (plus an optional +defaults+ key). When using
|
||||
# the multicoops feature (+multicoops+ is set to +true+ for the environment),
|
||||
# each foodcoop has its own key.
|
||||
#
|
||||
# In addition to the configuration file, values can be overridden in the database
|
||||
# using {RailsSettings::CachedSettings} as +foodcoop.<foodcoop_scope>.**+.
|
||||
#
|
||||
# Some values may not be set in the database (e.g. the database connection to
|
||||
# sharedlists, or +default_scope+), these are defined as children of the
|
||||
# +protected+ key. The default contains a sensible list, but you can modify
|
||||
# that. Here's an almost minimal example:
|
||||
#
|
||||
# default:
|
||||
# default_scope: f
|
||||
# host: order.foodstuff.test # hostname for urls in emails
|
||||
#
|
||||
# name: Fairy Foodstuff # the name of our foodcoop
|
||||
# contact:
|
||||
# # ...
|
||||
# email: fairy@foodstuff.test # general contact email address
|
||||
#
|
||||
# price_markup: 6 # foodcoop margin
|
||||
#
|
||||
# protected:
|
||||
# shared_lists: false # allow database connection override
|
||||
# use_messages: true # foodcoops can't disable the use of messages
|
||||
#
|
||||
# When you like to whitelist protected attributes, define an entry +all: true+,
|
||||
# then you can whitelist specific attributes setting them to +false+.
|
||||
#
|
||||
class FoodsoftConfig
|
||||
# @!attribute scope
|
||||
# Returns the current foodcoop scope for the multicoops feature, otherwise
|
||||
# the value of the foodcoop configuration key +default_scope+ is used.
|
||||
# @return [String] The current foodcoop scope.
|
||||
mattr_accessor :scope
|
||||
# @!attribute config
|
||||
# Returns a {ActiveSupport::HashWithIndifferentAccess Hash} with the current
|
||||
# scope's configuration from the configuration file. Note that this does not
|
||||
# include values that were changed in the database.
|
||||
# @return [ActiveSupport::HashWithIndifferentAccess] Current configuration from configuration file.
|
||||
mattr_accessor :config
|
||||
|
||||
mattr_accessor :default_config
|
||||
|
||||
# Configuration file location.
|
||||
# Taken from environment variable +FOODSOFT_APP_CONFIG+,
|
||||
# or else +config/app_config.yml+.
|
||||
APP_CONFIG_FILE = ENV.fetch('FOODSOFT_APP_CONFIG', 'config/app_config.yml')
|
||||
# Loaded configuration
|
||||
APP_CONFIG = ActiveSupport::HashWithIndifferentAccess.new
|
||||
|
||||
# distribution strategy config values enum
|
||||
module DistributionStrategy
|
||||
FIRST_ORDER_FIRST_SERVE = 'first_order_first_serve'
|
||||
NO_AUTOMATIC_DISTRIBUTION = 'no_automatic_distribution'
|
||||
end
|
||||
|
||||
class << self
|
||||
# Load and initialize foodcoop configuration file.
|
||||
# @param filename [String] Override configuration file
|
||||
def init(filename = APP_CONFIG_FILE)
|
||||
Rails.logger.info "Loading app configuration from #{APP_CONFIG_FILE}"
|
||||
APP_CONFIG.clear.merge! YAML.load(ERB.new(File.read(File.expand_path(filename, Rails.root))).result)
|
||||
# Gather program-default configuration
|
||||
self.default_config = get_default_config
|
||||
# Load initial config from development or production
|
||||
set_config Rails.env
|
||||
# Overwrite scope to have a better namescope than 'production'
|
||||
self.scope = config[:default_scope] or raise "No default_scope is set"
|
||||
# Set defaults for backward-compatibility
|
||||
set_missing
|
||||
# Make sure relevant configuration is applied, also in single coops mode,
|
||||
# where select_foodcoop is not called in every request.
|
||||
setup_mailing
|
||||
end
|
||||
|
||||
def init_mailing
|
||||
[:protocol, :host, :port, :script_name].each do |k|
|
||||
ActionMailer::Base.default_url_options[k] = self[k] if self[k]
|
||||
end
|
||||
end
|
||||
|
||||
# Set config and database connection for specific foodcoop.
|
||||
#
|
||||
# Only needed in multi coop mode.
|
||||
# @param foodcoop [String, Symbol] Foodcoop to select.
|
||||
def select_foodcoop(foodcoop)
|
||||
set_config foodcoop
|
||||
setup_database
|
||||
setup_mailing
|
||||
end
|
||||
|
||||
def select_default_foodcoop
|
||||
select_foodcoop config[:default_scope]
|
||||
end
|
||||
|
||||
def select_multifoodcoop(foodcoop)
|
||||
select_foodcoop foodcoop if config[:multi_coop_install]
|
||||
end
|
||||
|
||||
# Return configuration value for the currently selected foodcoop.
|
||||
#
|
||||
# First tries to read configuration from the database (cached),
|
||||
# then from the configuration files.
|
||||
#
|
||||
# FoodsoftConfig[:name] # => 'FC Test'
|
||||
#
|
||||
# To avoid errors when the database is not yet setup (when loading
|
||||
# the initial database schema), cached settings are only being read
|
||||
# when the settings table exists.
|
||||
#
|
||||
# @param key [String, Symbol]
|
||||
# @return [Object] Value of the key.
|
||||
def [](key)
|
||||
if RailsSettings::CachedSettings.table_exists? && allowed_key?(key)
|
||||
value = RailsSettings::CachedSettings["foodcoop.#{self.scope}.#{key}"]
|
||||
value = config[key] if value.nil?
|
||||
value
|
||||
else
|
||||
config[key]
|
||||
end
|
||||
end
|
||||
|
||||
# Store configuration in the database.
|
||||
#
|
||||
# If value is equal to what's defined in the configuration file, remove key from the database.
|
||||
# @param key [String, Symbol] Key
|
||||
# @param value [Object] Value
|
||||
# @return [Boolean] Whether storing succeeded (fails when key is not allowed to be set in database).
|
||||
def []=(key, value)
|
||||
return false unless allowed_key?(key)
|
||||
|
||||
value = normalize_value value
|
||||
# then update database
|
||||
if config[key] == value || (config[key].nil? && value == false)
|
||||
# delete (ok if it was already deleted)
|
||||
begin
|
||||
RailsSettings::CachedSettings.destroy "foodcoop.#{self.scope}.#{key}"
|
||||
rescue RailsSettings::Settings::SettingNotFound
|
||||
end
|
||||
else
|
||||
# or store
|
||||
RailsSettings::CachedSettings["foodcoop.#{self.scope}.#{key}"] = value
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
# @return [Array<String>] Configuration keys that are set (either in +app_config.yml+ or database).
|
||||
def keys
|
||||
keys = RailsSettings::CachedSettings.get_all("foodcoop.#{self.scope}.").try(:keys) || []
|
||||
keys.map! { |k| k.gsub(/^foodcoop\.#{self.scope}\./, '') }
|
||||
keys += config.keys
|
||||
keys.map(&:to_s).uniq
|
||||
end
|
||||
|
||||
# @return [Array<String>] Valid names of foodcoops.
|
||||
def foodcoops
|
||||
if config[:multi_coop_install]
|
||||
APP_CONFIG.keys.grep_v(/^(default|development|test|production)$/)
|
||||
else
|
||||
[config[:default_scope]]
|
||||
end
|
||||
end
|
||||
|
||||
# Loop through each foodcoop and executes the given block after setup config and database
|
||||
def each_coop
|
||||
foodcoops.each do |coop|
|
||||
select_multifoodcoop coop
|
||||
yield coop
|
||||
end
|
||||
end
|
||||
|
||||
def allowed_foodcoop?(foodcoop)
|
||||
foodcoops.include? foodcoop
|
||||
end
|
||||
|
||||
# @return [Boolean] Whether this key may be set in the database
|
||||
def allowed_key?(key)
|
||||
# fast check for keys without nesting
|
||||
if self.config[:protected].include? key
|
||||
!self.config[:protected][key]
|
||||
else
|
||||
!self.config[:protected][:all]
|
||||
end
|
||||
# @todo allow to check nested keys as well
|
||||
end
|
||||
|
||||
# @return [Hash] Full configuration.
|
||||
def to_hash
|
||||
keys.index_with { |k| self[k] }
|
||||
end
|
||||
|
||||
# for using active_model_serializer in the api/v1/configs controller
|
||||
alias read_attribute_for_serialization []
|
||||
|
||||
# @!attribute default_config
|
||||
# Returns the program-default foodcoop configuration.
|
||||
#
|
||||
# Plugins (engines in Rails terms) can easily add to the default
|
||||
# configuration by defining a method +default_foodsoft_config+ in
|
||||
# their engine and modify the {Hash} passed.
|
||||
#
|
||||
# When modifying this, please make sure to use default values that
|
||||
# match old behaviour. For example, when the wiki was made optional
|
||||
# and turned into a plugin, the configuration item +use_wiki+ was
|
||||
# introduced with a default value of +true+ (set in the wiki plugin):
|
||||
#
|
||||
# module FoodsoftWiki
|
||||
# class Engine < ::Rails::Engine
|
||||
# def default_foodsoft_config(cfg)
|
||||
# cfg[:use_wiki] = true # keep backward compatibility
|
||||
# end
|
||||
# end
|
||||
# end
|
||||
#
|
||||
# @return [Hash] Default configuration values
|
||||
|
||||
private
|
||||
|
||||
def set_config(foodcoop)
|
||||
raise "No config for this environment (#{foodcoop}) available!" if APP_CONFIG[foodcoop].nil?
|
||||
|
||||
self.config = APP_CONFIG[foodcoop]
|
||||
self.scope = foodcoop
|
||||
set_missing
|
||||
end
|
||||
|
||||
def setup_database
|
||||
database_config = ActiveRecord::Base.configurations[Rails.env]
|
||||
database_config = database_config.merge(config[:database]) if config[:database].present?
|
||||
ActiveRecord::Base.establish_connection(database_config)
|
||||
end
|
||||
|
||||
def setup_mailing
|
||||
ActionMailer::Base.default_url_options[:foodcoop] = scope
|
||||
end
|
||||
|
||||
# Completes foodcoop configuration with program defaults.
|
||||
# @see #foodsoft_config
|
||||
def set_missing
|
||||
config.replace(default_config.deep_merge(config))
|
||||
end
|
||||
|
||||
# Returns program-default configuration.
|
||||
# When new options are introduced, put backward-compatible defaults here, so that
|
||||
# configuration files that haven't been updated, still work as they did. This also
|
||||
# makes sure that the configuration editor picks up the defaults.
|
||||
# @return [Hash] Program-default foodcoop configuration.
|
||||
# @see #default_config
|
||||
# @see #set_missing
|
||||
def get_default_config
|
||||
cfg = {
|
||||
use_nick: true,
|
||||
use_apple_points: true,
|
||||
# English is the default language, and this makes it show up as default.
|
||||
default_locale: 'en',
|
||||
time_zone: 'Berlin',
|
||||
currency_unit: '€',
|
||||
currency_space: true,
|
||||
foodsoft_url: 'https://github.com/foodcoops/foodsoft',
|
||||
contact: {}, # avoid errors when undefined
|
||||
tasks_period_days: 7,
|
||||
tasks_upfront_days: 49,
|
||||
shared_supplier_article_sync_limit: 200,
|
||||
distribution_strategy: FoodsoftConfig::DistributionStrategy::FIRST_ORDER_FIRST_SERVE,
|
||||
# The following keys cannot, by default, be set by foodcoops themselves.
|
||||
protected: {
|
||||
multi_coop_install: true,
|
||||
default_scope: true,
|
||||
notification: true,
|
||||
shared_lists: true,
|
||||
protected: true,
|
||||
database: true
|
||||
}
|
||||
}
|
||||
# allow engines to easily add to this
|
||||
engines = Rails::Engine.subclasses.map(&:instance).select { |e| e.respond_to?(:default_foodsoft_config) }
|
||||
engines.each { |e| e.default_foodsoft_config(cfg) }
|
||||
cfg
|
||||
end
|
||||
|
||||
# Normalize value recursively (which can be entered as strings, but we want to store it properly)
|
||||
def normalize_value(value)
|
||||
value = value.map { |v| normalize_value(v) } if value.is_a? Array
|
||||
if value.is_a? Hash
|
||||
value = ActiveSupport::HashWithIndifferentAccess[value.to_a.map { |a| [a[0], normalize_value(a[1])] }]
|
||||
end
|
||||
case value
|
||||
when 'true' then true
|
||||
when 'false' then false
|
||||
when /^[-+0-9]+$/ then value.to_i
|
||||
when /^[-+0-9.]+([eE][-+0-9]+)?$/ then value.to_f
|
||||
when '' then nil
|
||||
else value
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
33
app/lib/foodsoft_date_util.rb
Normal file
33
app/lib/foodsoft_date_util.rb
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
module FoodsoftDateUtil
|
||||
# find next occurence given a recurring ical string and time
|
||||
def self.next_occurrence(start = Time.now, from = start, options = {})
|
||||
occ = nil
|
||||
if options && options[:recurr]
|
||||
schedule = IceCube::Schedule.new(start)
|
||||
schedule.add_recurrence_rule rule_from(options[:recurr])
|
||||
# @todo handle ical parse errors
|
||||
occ = begin
|
||||
schedule.next_occurrence(from).to_time
|
||||
rescue
|
||||
nil
|
||||
end
|
||||
end
|
||||
if options && options[:time] && occ
|
||||
occ = occ.beginning_of_day.advance(seconds: Time.parse(options[:time]).seconds_since_midnight)
|
||||
end
|
||||
occ
|
||||
end
|
||||
|
||||
# @param p [String, Symbol, Hash, IceCube::Rule] What to return a rule from.
|
||||
# @return [IceCube::Rule] Recurring rule
|
||||
def self.rule_from(p)
|
||||
case p
|
||||
when String
|
||||
IceCube::Rule.from_ical(p)
|
||||
when Hash
|
||||
IceCube::Rule.from_hash(p)
|
||||
else
|
||||
p
|
||||
end
|
||||
end
|
||||
end
|
||||
25
app/lib/foodsoft_file.rb
Normal file
25
app/lib/foodsoft_file.rb
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Foodsoft-file import
|
||||
class FoodsoftFile
|
||||
# parses a string from a foodsoft-file
|
||||
# returns two arrays with articles and outlisted_articles
|
||||
# the parsed article is a simple hash
|
||||
def self.parse(file, options = {})
|
||||
SpreadsheetFile.parse file, options do |row, row_index|
|
||||
next if row[2].blank?
|
||||
|
||||
article = { :order_number => row[1],
|
||||
:name => row[2],
|
||||
:note => row[3],
|
||||
:manufacturer => row[4],
|
||||
:origin => row[5],
|
||||
:unit => row[6],
|
||||
:price => row[7],
|
||||
:tax => row[8],
|
||||
:deposit => (row[9].nil? ? "0" : row[9]),
|
||||
:unit_quantity => row[10],
|
||||
:article_category => row[13] }
|
||||
status = row[0] && row[0].strip.downcase == 'x' ? :outlisted : nil
|
||||
yield status, article, row_index
|
||||
end
|
||||
end
|
||||
end
|
||||
58
app/lib/foodsoft_mail_receiver.rb
Normal file
58
app/lib/foodsoft_mail_receiver.rb
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
require 'mail'
|
||||
require 'midi-smtp-server'
|
||||
|
||||
class FoodsoftMailReceiver < MidiSmtpServer::Smtpd
|
||||
@@registered_classes = Set.new
|
||||
|
||||
def self.register(klass)
|
||||
@@registered_classes.add klass
|
||||
end
|
||||
|
||||
def self.received(recipient, data)
|
||||
find_handler(recipient).call(data)
|
||||
end
|
||||
|
||||
def start
|
||||
super
|
||||
@handlers = []
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def on_rcpt_to_event(_ctx, rcpt_to)
|
||||
recipient = rcpt_to.gsub(/^\s*<\s*(.*)\s*>\s*$/, '\1')
|
||||
@handlers << self.class.find_handler(recipient)
|
||||
rcpt_to
|
||||
rescue => error
|
||||
logger.info("Can not accept mail for '#{rcpt_to}': #{error}")
|
||||
raise MidiSmtpServer::Smtpd550Exception
|
||||
end
|
||||
|
||||
def on_message_data_event(ctx)
|
||||
@handlers.each do |handler|
|
||||
handler.call(ctx[:message][:data])
|
||||
end
|
||||
rescue => error
|
||||
ExceptionNotifier.notify_exception(error, data: ctx)
|
||||
raise error
|
||||
ensure
|
||||
@handlers.clear
|
||||
end
|
||||
|
||||
def self.find_handler(recipient)
|
||||
m = /(?<foodcoop>[^@.]+)\.(?<address>[^@]+)(@(?<hostname>[^@]+))?/.match recipient
|
||||
raise "recipient is missing or has an invalid format" if m.nil?
|
||||
raise "Foodcoop '#{m[:foodcoop]}' could not be found" unless FoodsoftConfig.allowed_foodcoop? m[:foodcoop]
|
||||
|
||||
FoodsoftConfig.select_multifoodcoop m[:foodcoop]
|
||||
|
||||
@@registered_classes.each do |klass|
|
||||
if match = klass.regexp.match(m[:address])
|
||||
handler = klass.new match
|
||||
return ->(data) { handler.received(data) }
|
||||
end
|
||||
end
|
||||
|
||||
raise "invalid format for recipient"
|
||||
end
|
||||
end
|
||||
39
app/lib/invoices_csv.rb
Normal file
39
app/lib/invoices_csv.rb
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
require 'csv'
|
||||
|
||||
class InvoicesCsv < RenderCSV
|
||||
include ApplicationHelper
|
||||
|
||||
def header
|
||||
[
|
||||
Invoice.human_attribute_name(:created_at),
|
||||
Invoice.human_attribute_name(:created_by),
|
||||
Invoice.human_attribute_name(:date),
|
||||
Invoice.human_attribute_name(:supplier),
|
||||
Invoice.human_attribute_name(:number),
|
||||
Invoice.human_attribute_name(:amount),
|
||||
Invoice.human_attribute_name(:total),
|
||||
Invoice.human_attribute_name(:deposit),
|
||||
Invoice.human_attribute_name(:deposit_credit),
|
||||
Invoice.human_attribute_name(:paid_on),
|
||||
Invoice.human_attribute_name(:note)
|
||||
]
|
||||
end
|
||||
|
||||
def data
|
||||
@object.each do |t|
|
||||
yield [
|
||||
t.created_at,
|
||||
show_user(t.created_by),
|
||||
t.date,
|
||||
t.supplier.name,
|
||||
t.number,
|
||||
t.amount,
|
||||
t.expected_amount,
|
||||
t.deposit,
|
||||
t.deposit_credit,
|
||||
t.paid_on,
|
||||
t.note
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
29
app/lib/order_csv.rb
Normal file
29
app/lib/order_csv.rb
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
require 'csv'
|
||||
|
||||
class OrderCsv < RenderCSV
|
||||
def header
|
||||
[
|
||||
OrderArticle.human_attribute_name(:units_to_order),
|
||||
Article.human_attribute_name(:order_number),
|
||||
Article.human_attribute_name(:name),
|
||||
Article.human_attribute_name(:unit),
|
||||
Article.human_attribute_name(:unit_quantity_short),
|
||||
ArticlePrice.human_attribute_name(:price),
|
||||
OrderArticle.human_attribute_name(:total_price)
|
||||
]
|
||||
end
|
||||
|
||||
def data
|
||||
@object.order_articles.ordered.includes([:article, :article_price]).all.map do |oa|
|
||||
yield [
|
||||
oa.units_to_order,
|
||||
oa.article.order_number,
|
||||
oa.article.name,
|
||||
oa.article.unit,
|
||||
oa.price.unit_quantity > 1 ? oa.price.unit_quantity : nil,
|
||||
number_to_currency(oa.price.price * oa.price.unit_quantity),
|
||||
number_to_currency(oa.total_price)
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
152
app/lib/order_pdf.rb
Normal file
152
app/lib/order_pdf.rb
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
class OrderPDF < RenderPDF
|
||||
attr_reader :order
|
||||
|
||||
def initialize(order, options = {})
|
||||
@order = order
|
||||
@orders = order
|
||||
super(options)
|
||||
end
|
||||
|
||||
def nice_table(name, data, dimrows = [])
|
||||
name_options = { size: 10, style: :bold }
|
||||
name_height = height_of name, name_options
|
||||
made_table = make_table data, width: bounds.width, cell_style: { size: 8, overflow: :shrink_to_fit } do |table|
|
||||
# borders
|
||||
table.cells.borders = [:bottom]
|
||||
table.cells.padding_top = 2
|
||||
table.cells.padding_bottom = 4
|
||||
table.cells.border_color = 'dddddd'
|
||||
table.rows(0).border_color = '666666'
|
||||
|
||||
# dim rows which were ordered but not received
|
||||
dimrows.each do |ri|
|
||||
table.row(ri).text_color = '999999'
|
||||
table.row(ri).columns(0..-1).font_style = nil
|
||||
end
|
||||
|
||||
yield table if block_given?
|
||||
end
|
||||
|
||||
if name_height + made_table.height < cursor
|
||||
down_or_page 15
|
||||
else
|
||||
start_new_page
|
||||
end
|
||||
|
||||
text name, name_options
|
||||
made_table.draw
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
# Return price for order_article.
|
||||
#
|
||||
# This is a separate method so that plugins can override it.
|
||||
#
|
||||
# @param article [OrderArticle]
|
||||
# @return [Number] Price to show
|
||||
# @see https://github.com/foodcoops/foodsoft/issues/445
|
||||
def order_article_price(order_article)
|
||||
order_article.price.fc_price
|
||||
end
|
||||
|
||||
def order_article_price_per_unit(order_article)
|
||||
"#{number_to_currency(order_article_price(order_article))} / #{order_article.article.unit}"
|
||||
end
|
||||
|
||||
def group_order_article_quantity_with_tolerance(goa)
|
||||
goa.tolerance > 0 ? "#{goa.quantity} + #{goa.tolerance}" : goa.quantity.to_s
|
||||
end
|
||||
|
||||
def group_order_article_result(goa)
|
||||
number_with_precision goa.result, strip_insignificant_zeros: true
|
||||
end
|
||||
|
||||
def group_order_articles(ordergroup)
|
||||
GroupOrderArticle
|
||||
.includes(:group_order)
|
||||
.where(group_orders: { order_id: @orders, ordergroup_id: ordergroup })
|
||||
end
|
||||
|
||||
def order_articles
|
||||
OrderArticle
|
||||
.ordered
|
||||
.includes(article: :supplier)
|
||||
.includes(group_order_articles: { group_order: :ordergroup })
|
||||
.where(order: @orders)
|
||||
.order('suppliers.name, articles.name, groups.name')
|
||||
.preload(:article_price)
|
||||
end
|
||||
|
||||
def ordergroups(offset = nil, limit = nil)
|
||||
result = GroupOrder
|
||||
.ordered
|
||||
.where(order: @orders)
|
||||
.group('groups.id')
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.pluck('groups.name', 'SUM(group_orders.price)', 'ordergroup_id', 'SUM(group_orders.transport)')
|
||||
|
||||
result.map do |item|
|
||||
[item.first || stock_ordergroup_name] + item[1..]
|
||||
end
|
||||
end
|
||||
|
||||
def each_order_article(&block)
|
||||
order_articles.each(&block)
|
||||
end
|
||||
|
||||
def each_ordergroup(&block)
|
||||
ordergroups.each(&block)
|
||||
end
|
||||
|
||||
def each_ordergroup_batch(batch_size)
|
||||
offset = 0
|
||||
|
||||
loop do
|
||||
go_records = ordergroups(offset, batch_size)
|
||||
|
||||
break unless go_records.any?
|
||||
|
||||
group_ids = go_records.map(&:third)
|
||||
|
||||
# get quantity for each article and ordergroup
|
||||
goa_records = group_order_articles(group_ids)
|
||||
.group('group_order_articles.order_article_id, group_orders.ordergroup_id')
|
||||
.pluck('group_order_articles.order_article_id', 'group_orders.ordergroup_id', 'SUM(COALESCE(group_order_articles.result, group_order_articles.quantity))')
|
||||
|
||||
# transform the flat list of results in a hash (with the article as key), which contains an array for all ordergroups
|
||||
results = goa_records.group_by(&:first).transform_values do |value|
|
||||
grouped_value = value.group_by(&:second)
|
||||
group_ids.map do |group_id|
|
||||
number_with_precision grouped_value[group_id].try(:first).try(:third), strip_insignificant_zeros: true
|
||||
end
|
||||
end
|
||||
|
||||
yield go_records, results
|
||||
offset += batch_size
|
||||
end
|
||||
end
|
||||
|
||||
def each_group_order_article_for_order_article(order_article, &block)
|
||||
order_article.group_order_articles.each(&block)
|
||||
end
|
||||
|
||||
def each_group_order_article_for_ordergroup(ordergroup, &block)
|
||||
group_order_articles(ordergroup)
|
||||
.includes(order_article: { article: [:supplier] })
|
||||
.order('suppliers.name, articles.name')
|
||||
.preload(order_article: [:article_price, :order])
|
||||
.each(&block)
|
||||
end
|
||||
|
||||
def stock_ordergroup_name
|
||||
users = GroupOrder.stock
|
||||
.eager_load(:updated_by)
|
||||
.where(order: @orders)
|
||||
.map(&:updated_by)
|
||||
.map { |u| u.try(&:name) || '?' }
|
||||
|
||||
I18n.t('model.group_order.stock_ordergroup_name', user: users.uniq.sort.join(', '))
|
||||
end
|
||||
end
|
||||
30
app/lib/order_txt.rb
Normal file
30
app/lib/order_txt.rb
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
class OrderTxt
|
||||
def initialize(order, _options = {})
|
||||
@order = order
|
||||
end
|
||||
|
||||
# Renders the fax-text-file
|
||||
# e.g. for easier use with online-fax-software, which don't accept pdf-files
|
||||
def to_txt
|
||||
supplier = @order.supplier
|
||||
contact = FoodsoftConfig[:contact].symbolize_keys
|
||||
text = I18n.t('orders.fax.heading', :name => FoodsoftConfig[:name])
|
||||
text += "\n#{Supplier.human_attribute_name(:customer_number)}: #{supplier.customer_number}" unless supplier.customer_number.blank?
|
||||
text += "\n" + I18n.t('orders.fax.delivery_day')
|
||||
text += "\n\n#{supplier.name}\n#{supplier.address}\n#{Supplier.human_attribute_name(:fax)}: #{supplier.fax}\n\n"
|
||||
text += "****** " + I18n.t('orders.fax.to_address') + "\n\n"
|
||||
text += "#{FoodsoftConfig[:name]}\n#{contact[:street]}\n#{contact[:zip_code]} #{contact[:city]}\n\n"
|
||||
text += "****** " + I18n.t('orders.fax.articles') + "\n\n"
|
||||
text += format("%8s %8s %s\n", I18n.t('orders.fax.number'), I18n.t('orders.fax.amount'), I18n.t('orders.fax.name'))
|
||||
# now display all ordered articles
|
||||
@order.order_articles.ordered.includes([:article, :article_price]).each do |oa|
|
||||
text += format("%8s %8d %s\n", oa.article.order_number, oa.units_to_order.to_i, oa.article.name)
|
||||
end
|
||||
text
|
||||
end
|
||||
|
||||
# Helper method to test pdf via rails console: OrderTxt.new(order).save_tmp
|
||||
def save_tmp
|
||||
File.write("#{Rails.root}/tmp/#{self.class.to_s.underscore}.txt", to_csv.force_encoding("UTF-8"))
|
||||
end
|
||||
end
|
||||
41
app/lib/ordergroups_csv.rb
Normal file
41
app/lib/ordergroups_csv.rb
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
class OrdergroupsCsv < RenderCSV
|
||||
include ApplicationHelper
|
||||
|
||||
def header
|
||||
row = [
|
||||
Ordergroup.human_attribute_name(:id),
|
||||
Ordergroup.human_attribute_name(:name),
|
||||
Ordergroup.human_attribute_name(:description),
|
||||
Ordergroup.human_attribute_name(:account_balance),
|
||||
Ordergroup.human_attribute_name(:created_on),
|
||||
Ordergroup.human_attribute_name(:contact_person),
|
||||
Ordergroup.human_attribute_name(:contact_phone),
|
||||
Ordergroup.human_attribute_name(:contact_address),
|
||||
Ordergroup.human_attribute_name(:break_start),
|
||||
Ordergroup.human_attribute_name(:break_end),
|
||||
Ordergroup.human_attribute_name(:last_user_activity),
|
||||
Ordergroup.human_attribute_name(:last_order)
|
||||
]
|
||||
row + Ordergroup.custom_fields.pluck(:label)
|
||||
end
|
||||
|
||||
def data
|
||||
@object.each do |o|
|
||||
row = [
|
||||
o.id,
|
||||
o.name,
|
||||
o.description,
|
||||
o.account_balance,
|
||||
o.created_on,
|
||||
o.contact_person,
|
||||
o.contact_phone,
|
||||
o.contact_address,
|
||||
o.break_start,
|
||||
o.break_end,
|
||||
o.last_user_activity,
|
||||
o.last_order.try(:starts)
|
||||
]
|
||||
yield row + Ordergroup.custom_fields.map { |f| o.settings.custom_fields[f[:name]] }
|
||||
end
|
||||
end
|
||||
end
|
||||
44
app/lib/render_csv.rb
Normal file
44
app/lib/render_csv.rb
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
require 'csv'
|
||||
|
||||
class RenderCSV
|
||||
include ActionView::Helpers::NumberHelper
|
||||
|
||||
def initialize(object, options = {})
|
||||
@object = object
|
||||
@options = options
|
||||
# defaults to please Microsoft Excel ...
|
||||
@options[:col_sep] ||= FoodsoftConfig[:csv_col_sep] || ';'
|
||||
@options[:row_sep] ||= FoodsoftConfig[:csv_row_sep] if FoodsoftConfig[:csv_row_sep]
|
||||
@options[:encoding] ||= FoodsoftConfig[:csv_encoding] || 'ISO-8859-15'
|
||||
end
|
||||
|
||||
def to_csv
|
||||
options = @options.select { |k| %w(col_sep row_sep).include? k.to_s }
|
||||
ret = CSV.generate options do |csv|
|
||||
if h = header
|
||||
csv << h
|
||||
end
|
||||
data { |d| csv << d }
|
||||
end
|
||||
ret.encode(@options[:encoding], invalid: :replace, undef: :replace)
|
||||
end
|
||||
|
||||
def header
|
||||
nil
|
||||
end
|
||||
|
||||
def data
|
||||
yield []
|
||||
end
|
||||
|
||||
# Helper method to test pdf via rails console: OrderCsv.new(order).save_tmp
|
||||
def save_tmp
|
||||
encoding = @options[:encoding] || 'UTF-8'
|
||||
File.write("#{Rails.root}/tmp/#{self.class.to_s.underscore}.csv", to_csv.force_encoding(encoding))
|
||||
end
|
||||
|
||||
# XXX disable unit to avoid encoding problems, both in unit and whitespace. Also allows computations in spreadsheet.
|
||||
def number_to_currency(number, options = {})
|
||||
super(number, options.merge({ unit: '' }))
|
||||
end
|
||||
end
|
||||
172
app/lib/render_pdf.rb
Normal file
172
app/lib/render_pdf.rb
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
require 'prawn/measurement_extensions'
|
||||
|
||||
class RotatedCell < Prawn::Table::Cell::Text
|
||||
def initialize(pdf, text, options = {})
|
||||
options[:content] = text
|
||||
options[:valign] = :center
|
||||
options[:align] = :center
|
||||
options[:rotate_around] = :center
|
||||
@rotation = -options[:rotate] || 0
|
||||
super(pdf, [0, pdf.cursor], options)
|
||||
end
|
||||
|
||||
def tan_rotation
|
||||
Math.tan(Math::PI * @rotation / 180)
|
||||
end
|
||||
|
||||
def skew
|
||||
(height + (border_top_width / 2.0) + (border_bottom_width / 2.0)) / tan_rotation
|
||||
end
|
||||
|
||||
def styled_width_of(_text)
|
||||
options = @text_options.reject { |k| k == :style }
|
||||
with_font { (@pdf.height_of(@content, options) + padding_top + padding_bottom) / tan_rotation }
|
||||
end
|
||||
|
||||
def natural_content_height
|
||||
options = @text_options.reject { |k| k == :style }
|
||||
with_font { (@pdf.width_of(@content, options) + padding_top + padding_bottom) * tan_rotation }
|
||||
end
|
||||
|
||||
def draw_borders(pt)
|
||||
@pdf.mask(:line_width, :stroke_color) do
|
||||
x, y = pt
|
||||
from = [[x - skew, y + (border_top_width / 2.0)],
|
||||
to = [x, y - height - (border_bottom_width / 2.0)]]
|
||||
|
||||
@pdf.line_width = @border_widths[3]
|
||||
@pdf.stroke_color = @border_colors[3]
|
||||
@pdf.stroke_line(from, to)
|
||||
@pdf.undash
|
||||
end
|
||||
end
|
||||
|
||||
def draw_content
|
||||
with_font do
|
||||
with_text_color do
|
||||
text_box(width: spanned_content_width + FPTolerance + skew,
|
||||
height: spanned_content_height + FPTolerance,
|
||||
at: [1 - skew, @pdf.cursor]).render
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class RenderPDF < Prawn::Document
|
||||
include ActionView::Helpers::NumberHelper
|
||||
include ApplicationHelper
|
||||
|
||||
TOP_MARGIN = 36
|
||||
BOTTOM_MARGIN = 23
|
||||
HEADER_SPACE = 9
|
||||
FOOTER_SPACE = 3
|
||||
HEADER_FONT_SIZE = 16
|
||||
FOOTER_FONT_SIZE = 8
|
||||
DEFAULT_FONT = 'OpenSans'
|
||||
|
||||
def initialize(options = {})
|
||||
options[:font_size] ||= FoodsoftConfig[:pdf_font_size].try(:to_f) || 12
|
||||
options[:page_size] ||= FoodsoftConfig[:pdf_page_size] || 'A4'
|
||||
options[:skip_page_creation] = true
|
||||
@options = options
|
||||
@first_page = true
|
||||
|
||||
super(options)
|
||||
|
||||
# Use ttf for better utf-8 compability
|
||||
font_families.update(
|
||||
'OpenSans' => {
|
||||
bold: font_path('OpenSans-Bold.ttf'),
|
||||
italic: font_path('OpenSans-Italic.ttf'),
|
||||
bold_italic: font_path('OpenSans-BoldItalic.ttf'),
|
||||
normal: font_path('OpenSans-Regular.ttf')
|
||||
}
|
||||
)
|
||||
|
||||
header = options[:title] || title
|
||||
footer = I18n.l(Time.now, format: :long)
|
||||
|
||||
header_size = 0
|
||||
header_size = height_of(header, size: HEADER_FONT_SIZE, font: DEFAULT_FONT) + HEADER_SPACE if header
|
||||
footer_size = height_of(footer, size: FOOTER_FONT_SIZE, font: DEFAULT_FONT) + FOOTER_SPACE
|
||||
|
||||
start_new_page(top_margin: TOP_MARGIN + header_size, bottom_margin: BOTTOM_MARGIN + footer_size)
|
||||
|
||||
font DEFAULT_FONT
|
||||
|
||||
repeat :all, dynamic: true do
|
||||
bounding_box [bounds.left, bounds.top + header_size], width: bounds.width, height: header_size do
|
||||
text header, size: HEADER_FONT_SIZE, align: :center, overflow: :shrink_to_fit if header
|
||||
end
|
||||
font_size FOOTER_FONT_SIZE do
|
||||
bounding_box [bounds.left, bounds.bottom - FOOTER_SPACE], width: bounds.width, height: footer_size do
|
||||
text footer, align: :left, valign: :bottom
|
||||
end
|
||||
bounding_box [bounds.left, bounds.bottom - FOOTER_SPACE], width: bounds.width, height: footer_size do
|
||||
text I18n.t('lib.render_pdf.page', number: page_number, count: page_count), align: :right, valign: :bottom
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def title
|
||||
nil
|
||||
end
|
||||
|
||||
def to_pdf
|
||||
body # Add content, which is defined in subclasses
|
||||
render # Render pdf
|
||||
end
|
||||
|
||||
# Helper method to test pdf via rails console: OrderByGroups.new(order).save_tmp
|
||||
def save_tmp
|
||||
File.write("#{Rails.root}/tmp/#{self.class.to_s.underscore}.pdf", to_pdf.force_encoding("UTF-8"))
|
||||
end
|
||||
|
||||
# @todo avoid underscore instead of unicode whitespace in pdf :/
|
||||
def number_to_currency(number, options = {})
|
||||
super(number, options).gsub("\u202f", ' ') if number
|
||||
end
|
||||
|
||||
def font_size(points = nil, &block)
|
||||
points *= @options[:font_size] / 12 if points
|
||||
super(points, &block)
|
||||
end
|
||||
|
||||
# add pagebreak or vertical whitespace, depending on configuration
|
||||
def down_or_page(space = 10)
|
||||
if @first_page
|
||||
@first_page = false
|
||||
return
|
||||
end
|
||||
if pdf_add_page_breaks?
|
||||
start_new_page
|
||||
else
|
||||
move_down space
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def fontsize(n)
|
||||
n
|
||||
end
|
||||
|
||||
# return whether pagebreak or vertical whitespace is used for breaks
|
||||
def pdf_add_page_breaks?(docid = nil)
|
||||
docid ||= self.class.name.underscore
|
||||
cfg = FoodsoftConfig[:pdf_add_page_breaks]
|
||||
case cfg
|
||||
when Array
|
||||
cfg.index(docid.to_s).any?
|
||||
when Hash
|
||||
cfg[docid.to_s]
|
||||
else
|
||||
cfg
|
||||
end
|
||||
end
|
||||
|
||||
def font_path(name)
|
||||
Rails.root.join('vendor', 'assets', 'fonts', name)
|
||||
end
|
||||
end
|
||||
22
app/lib/spreadsheet_file.rb
Normal file
22
app/lib/spreadsheet_file.rb
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
require 'roo'
|
||||
|
||||
class SpreadsheetFile
|
||||
def self.parse(file, options = {})
|
||||
filepath = file.is_a?(String) ? file : file.to_path
|
||||
filename = options.delete(:filename) || filepath
|
||||
fileext = File.extname(filename)
|
||||
options[:csv_options] = { col_sep: ';', encoding: 'utf-8' }.merge(options[:csv_options] || {})
|
||||
s = Roo::Spreadsheet.open(filepath, options.merge({ extension: fileext }))
|
||||
|
||||
row_index = 1
|
||||
s.each do |row|
|
||||
if row_index == 1
|
||||
# @todo try to detect headers; for now using the index is ok
|
||||
else
|
||||
yield row, row_index
|
||||
end
|
||||
row_index += 1
|
||||
end
|
||||
row_index
|
||||
end
|
||||
end
|
||||
10
app/lib/templates/haml/scaffold/_form.html.haml
Normal file
10
app/lib/templates/haml/scaffold/_form.html.haml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
= simple_form_for(@<%= singular_table_name %>) do |f|
|
||||
= f.error_notification
|
||||
|
||||
.form-inputs
|
||||
<%- attributes.each do |attribute| -%>
|
||||
= f.<%= attribute.reference? ? :association : :input %> :<%= attribute.name %>
|
||||
<%- end -%>
|
||||
|
||||
.form-actions
|
||||
= f.button :submit
|
||||
37
app/lib/token_verifier.rb
Normal file
37
app/lib/token_verifier.rb
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Generic token verifier with foodcoop scope
|
||||
# @todo use +Rails.application.message_verifier+ when possible
|
||||
class TokenVerifier < ActiveSupport::MessageVerifier
|
||||
def initialize(prefix)
|
||||
super(self.class.secret)
|
||||
@_prefix = prefix.is_a?(Array) ? prefix.join(':') : prefix.to_s
|
||||
end
|
||||
|
||||
def generate(message = nil)
|
||||
fullmessage = [FoodsoftConfig.scope, @_prefix]
|
||||
fullmessage.append(message) unless message.nil?
|
||||
super(fullmessage)
|
||||
end
|
||||
|
||||
def verify(message)
|
||||
r = super(message)
|
||||
raise InvalidMessage unless r.is_a?(Array) && r.length >= 2 && r.length <= 3
|
||||
raise InvalidScope unless r[0] == FoodsoftConfig.scope
|
||||
raise InvalidPrefix unless r[1] == @_prefix
|
||||
|
||||
# return original message
|
||||
if r.length > 2
|
||||
r[2]
|
||||
end
|
||||
end
|
||||
|
||||
class InvalidMessage < ActiveSupport::MessageVerifier::InvalidSignature; end
|
||||
|
||||
class InvalidScope < ActiveSupport::MessageVerifier::InvalidSignature; end
|
||||
|
||||
class InvalidPrefix < ActiveSupport::MessageVerifier::InvalidSignature; end
|
||||
|
||||
def self.secret
|
||||
# secret_key_base for Rails 4, but Rails 3 initializer may still be used
|
||||
Foodsoft::Application.config.secret_key_base || Foodsoft::Application.config.secret_token
|
||||
end
|
||||
end
|
||||
39
app/lib/users_csv.rb
Normal file
39
app/lib/users_csv.rb
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
class UsersCsv < RenderCSV
|
||||
include ApplicationHelper
|
||||
|
||||
def header
|
||||
row = [
|
||||
User.human_attribute_name(:id),
|
||||
User.human_attribute_name(:name),
|
||||
User.human_attribute_name(:nick),
|
||||
User.human_attribute_name(:first_name),
|
||||
User.human_attribute_name(:last_name),
|
||||
User.human_attribute_name(:email),
|
||||
User.human_attribute_name(:phone),
|
||||
User.human_attribute_name(:last_login),
|
||||
User.human_attribute_name(:last_activity),
|
||||
User.human_attribute_name(:iban),
|
||||
User.human_attribute_name(:ordergroup)
|
||||
]
|
||||
row + User.custom_fields.pluck(:label)
|
||||
end
|
||||
|
||||
def data
|
||||
@object.each do |o|
|
||||
row = [
|
||||
o.id,
|
||||
o.name,
|
||||
o.nick,
|
||||
o.first_name,
|
||||
o.last_name,
|
||||
o.email,
|
||||
o.phone,
|
||||
o.last_login,
|
||||
o.last_activity,
|
||||
o.iban,
|
||||
o.ordergroup&.name
|
||||
]
|
||||
yield row + User.custom_fields.map { |f| o.settings.custom_fields[f[:name]] }
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue