Merge pull request #298 from wvengen/feature-config_db

Allow foodcoops to configure their settings
This commit is contained in:
wvengen 2014-09-02 15:19:48 +02:00
commit 1dad9c034c
33 changed files with 905 additions and 39 deletions

View file

@ -140,6 +140,23 @@ table {
text-align: center; text-align: center;
} }
// Navigation with embedded tabs
.nav-tabs-with-heading {
> li {
line-height: 40px;
margin-top: 16.5px;
a { padding-bottom: 9px; }
}
> li.heading {
> h1, > h2, > h3 {
font-size: 31.5px;
line-height: 40px;
margin: 0;
}
margin: 10px 1em 5px 0;
}
}
// Tasks .. // Tasks ..
.accepted { .accepted {
color: #468847; color: #468847;
@ -324,6 +341,15 @@ table.table {
} }
} }
// inline (boolean) position after/before heading
label {
h1, h2, h3, h4 {
input[type=checkbox] {
margin: auto 0.3em auto 0.3em;
}
}
}
// it's a bit distracting // it's a bit distracting
.icon-asterisk { .icon-asterisk {
font-size: 80%; font-size: 80%;

View file

@ -0,0 +1,39 @@
class Admin::ConfigsController < Admin::BaseController
before_action :get_tabs, only: [:show, :list]
def show
@current_tab = @tabs.include?(params[:tab]) ? params[:tab] : @tabs.first
@cfg = FoodsoftConfig
end
def list
@current_tab = 'list'
@cfg = FoodsoftConfig
@dfl = FoodsoftConfig.config
@keys = FoodsoftConfig.keys.select {|k| FoodsoftConfig.allowed_key?(k)}.sort
end
def update
ActiveRecord::Base.transaction do
# TODO support nested configuration keys
params[:config].each do |key, val|
FoodsoftConfig[key] = val
end
end
flash[:notice] = I18n.t('admin.configs.update.notice')
redirect_to action: 'show'
end
protected
# Set configuration tab names as `@tabs`
def get_tabs
@tabs = %w(foodcoop payment tasks messages layout language others)
# allow engines to modify this list
engines = Rails::Engine.subclasses.map(&:instance).select { |e| e.respond_to?(:configuration) }
engines.each { |e| e.configuration(@tabs, self) }
@tabs.uniq!
end
end

View file

@ -6,6 +6,7 @@ class ApplicationController < ActionController::Base
protect_from_forgery protect_from_forgery
before_filter :select_foodcoop, :authenticate, :store_controller, :items_per_page before_filter :select_foodcoop, :authenticate, :store_controller, :items_per_page
after_filter :remove_controller after_filter :remove_controller
around_filter :set_time_zone, :set_currency
# Returns the controller handling the current request. # Returns the controller handling the current request.
@ -168,4 +169,27 @@ class ApplicationController < ActionController::Base
{foodcoop: FoodsoftConfig.scope} {foodcoop: FoodsoftConfig.scope}
end end
# Set timezone according to foodcoop preference.
# @see http://stackoverflow.com/questions/4362663/timezone-with-rails-3
# @see http://archives.ryandaigle.com/articles/2008/1/25/what-s-new-in-edge-rails-easier-timezones
def set_time_zone
old_time_zone = Time.zone
Time.zone = FoodsoftConfig[:time_zone] if FoodsoftConfig[:time_zone]
yield
ensure
Time.zone = old_time_zone
end
# Set currency according to foodcoop preference.
# @see #set_time_zone
def set_currency
old_currency = ::I18n.t('number.currency.format.unit')
new_currency = FoodsoftConfig[:currency_unit] || ''
new_currency += "\u202f" if FoodsoftConfig[:currency_space]
::I18n.backend.store_translations(::I18n.locale, number: {currency: {format: {unit: new_currency}}})
yield
ensure
::I18n.backend.store_translations(::I18n.locale, number: {currency: {format: {unit: old_currency}}})
end
end end

View file

@ -0,0 +1,131 @@
module Admin::ConfigsHelper
# Returns form input for configuration key.
# For configuration keys that contain a {Hash}, {ActiveView::Helpers::FormBuilder#fields_for fields_for} can be used.
# When the key is not {FoodsoftConfig#allowed_key? allowed}, +nil+ is returned.
# @param form [ActionView::Helpers::FormBuilder] Form object.
# @param key [Symbol, String] Configuration key.
# @param options [Hash] Options passed to the form builder.
# @option options [Boolean] :required Wether field is shown as required (default not).
# @return [String] Form input for configuration key.
# @todo find way to pass current value to time_zone input without using default
def config_input(form, key, options = {}, &block)
return unless @cfg.allowed_key? key
options[:label] = config_input_label(form, key)
options[:required] ||= false
options[:input_html] ||= {}
config_input_field_options form, key, options[:input_html]
config_input_tooltip_options form, key, options[:input_html]
if options[:as] == :boolean
options[:input_html][:checked] = 'checked' if v=options[:input_html].delete(:value) and v!='false'
options[:checked_value] = 'true' if options[:checked_value].nil?
options[:unchecked_value] = 'false' if options[:unchecked_value].nil?
elsif options[:collection] or options[:as] == :select
options[:selected] = options[:input_html].delete(:value)
return form.input key, options, &block
elsif options[:as] == :time_zone
options[:default] = options[:input_html].delete(:value)
return form.input key, options, &block
end
form.input key, options, &block
end
# @return [String] Label name in form for configuration key.
# @param form [ActionView::Helpers::FormBuilder] Form object.
# @param key [Symbol, String] Configuration key.
# @see #config_input
def config_input_label(form, key)
cfg_path = form.lookup_model_names[1..-1] + [key]
I18n.t("config.keys.#{cfg_path.map(&:to_s).join('.')}")
end
# @return [String] Form input field for configuration key.
# @see config_input
# @option options [String] :checked_value Value for boolean when checked (default +true+)
# @option options [String] :unchecked_value Value for boolean when not checked (default +false+)
# @todo find out how to pass +checked_value+ and +unchecked_value+ to +input_field+
def config_input_field(form, key, options = {})
return unless @cfg.allowed_key? :key
options[:required] ||= false
config_input_field_options form, key, options
config_input_tooltip_options form, key, options
if options[:as] == :boolean
checked_value = options.delete(:checked_value) || 'true'
unchecked_value = options.delete(:unchecked_value) || 'false'
options[:checked] = 'checked' if v=options.delete(:value) and v!='false'
form.hidden_field(key, value: unchecked_value, as: :hidden) + form.check_box(key, options, checked_value, false)
else
form.input_field key, options
end
end
# @return [String] Form heading with checkbox with block passed in expandable +fieldset+.
# @param form [ActionView::Helpers::FormBuilder] Form object.
# @param key [Symbol, String] Configuration key of a boolean (e.g. +use_messages+).
# @option options [String] :label Label to show
def config_use_heading(form, key, options = {})
head = content_tag :label do
lbl = options[:label] || config_input_label(form, key)
field = config_input_field(form, key, as: :boolean, boolean_style: :inline,
data: {toggle: 'collapse', target: "##{key}-fields"})
content_tag :h4 do
# put in span to keep space for tooltip at right
content_tag :span, (lbl + field).html_safe, config_input_tooltip_options(form, key, {})
end
end
fields = content_tag(:fieldset, id: "#{key}-fields", class: "collapse#{' in' if @cfg[key]}") do
yield
end
head + fields
end
# Returns configuration value suitable for rendering in HTML.
# Makes keys different from +app_config.yml+ configuration bold,
# protects sensitive values like keys and passwords, and makes
# links from URLs.
# @param key [String] Configuration key
# @param value [String] Configuration value
# @return [String] Configuration value suitable for rendering in HTML.
def show_config_value(key, value)
if key =~ /passw|secr|key/
'(protected)'
elsif value.is_a? Hash
content_tag :ul do
value.map do |k,v|
content_tag :li, content_tag(:tt, "#{k}: ") + show_config_value(k, v).to_s
end.join.html_safe
end
elsif value.is_a? Enumerable
content_tag :ul, value.map {|v| content_tag :li, h(v)}.join.html_safe
elsif key =~ /url|website|www|homepage/
link_to(value, value).html_safe
else
value
end
end
private
def config_input_tooltip_options(form, key, options)
# tooltip with help info to the right
cfg_path = form.lookup_model_names[1..-1] + [key]
tooltip = I18n.t("config.hints.#{cfg_path.map(&:to_s).join('.')}", default: '')
unless tooltip.blank?
options[:data] ||= {}
options[:data][:toggle] ||= 'tooltip'
options[:data][:placement] ||= 'right'
options[:title] ||= tooltip
end
options
end
def config_input_field_options(form, key, options)
cfg_path = form.lookup_model_names[1..-1] + [key]
# set current value
unless options.has_key?(:value)
value = @cfg
cfg_path.each {|n| value = value[n.to_sym] if value.respond_to? :[] }
options[:value] = value
end
options
end
end

View file

@ -0,0 +1,10 @@
= config_input form, :name, required: true, input_html: {class: 'input-xlarge'}
= form.fields_for :contact do |c|
= config_input c, :street, input_html: {class: 'input-xlarge'}
.fold-line
= config_input c, :zip_code, input_html: {class: 'input-mini'}
= config_input c, :city, input_html: {class: 'input-medium'}
= config_input c, :country, as: :string, input_html: {class: 'input-xlarge'}
= config_input c, :email, required: true, input_html: {class: 'input-xlarge'}
= config_input c, :phone, input_html: {class: 'input-medium'}
= config_input form, :homepage, required: true, as: :url, input_html: {class: 'input-xlarge'}

View file

@ -0,0 +1,9 @@
= config_input form, :default_locale,
collection: I18n.available_locales.map {|l| [t("simple_form.options.settings.profile.language.#{l}"), l]}
= config_input form, :ignore_browser_locale, as: :boolean
= config_input form, :time_zone, as: :time_zone, include_blank: true, input_html: {class: 'input-xlarge'}
= config_input form, :currency_unit do
= config_input_field form, :currency_unit, class: 'input-mini'
%label.inline
= config_input_label form, :currency_space
= config_input_field form, :currency_space, as: :boolean

View file

@ -0,0 +1,15 @@
= config_input form, :page_footer, as: :text, input_html: {class: 'input-xxlarge', rows: 2, placeholder: "#{link_to(FoodsoftConfig[:name], FoodsoftConfig[:homepage])}"}
= config_input form, :custom_css, as: :text, input_html: {class: 'input-xxlarge', rows: 3}
%h4= t '.pdf_title'
.fold-line
= config_input form, :pdf_font_size, as: :integer, input_html: {class: 'input-mini'}
= config_input form, :pdf_page_size, input_html: {class: 'input-medium'}
= config_input form, :pdf_add_page_breaks do
= form.simple_fields_for :pdf_add_page_breaks do |fields|
%label
= config_input_field fields, :order_by_groups, as: :boolean
= t 'config.hints.pdf_add_page_breaks.order_by_groups'
%label
= config_input_field fields, :order_by_articles, as: :boolean
= t 'config.hints.pdf_add_page_breaks.order_by_articles'

View file

@ -0,0 +1,7 @@
%fieldset
%label
%h4= t '.emails_title'
= config_input form, :email_from, as: :string, input_html: {class: 'input-xlarge', placeholder: "#{@cfg[:name]} <#{@cfg[:contact][:email]}>"}
= config_input form, :email_replyto, as: :string, input_html: {class: 'input-xlarge'}
-# sender is better configured by server administrator, since it affects SPF records
-#= config_input form, :email_sender, as: :string, input_html: {class: 'input-xlarge'}

View file

@ -0,0 +1,5 @@
- if defined? FoodsoftWiki # avoid requiring deface here (the single exception)
= config_input form, :use_wiki, as: :boolean
= config_input form, :use_nick, as: :boolean
= config_input form, :tolerance_is_costly, as: :boolean
= config_input form, :help_url, as: :url, input_html: {class: 'input-xlarge'}

View file

@ -0,0 +1,12 @@
= config_input form, :price_markup do
.input-append
= config_input_field form, :price_markup, as: :decimal, class: 'input-mini'
%span.add-on %
= config_input form, :tax_default do
.input-append
= config_input_field form, :tax_default, as: :decimal, class: 'input-mini'
%span.add-on %
= config_input form, :minimum_balance do
.input-prepend
%span.add-on= t 'number.currency.format.unit'
= config_input_field form, :minimum_balance, as: :decimal, class: 'input-small'

View file

@ -0,0 +1,3 @@
-#= config_use_heading form, :use_tasks do
= config_use_heading form, :use_apple_points do
= config_input form, :stop_ordering_under, as: :numeric, input_html: {class: 'input-small'}

View file

@ -0,0 +1,11 @@
%ul.nav.nav-tabs.nav-tabs-with-heading
%li.heading
%h1= t '.title'
- for tab in @tabs
- url = action_name == 'show' ? nil : admin_config_path(tab: tab)
%li{class: ('active' if @current_tab==tab)}= link_to t("config.tabs.#{tab}"), "#{url}#tab-#{tab}", data: ({toggle: 'tab'} unless url)
-# make this a button to give some indicator that navigation away might lose changes
%li.pull-right{class: ('active' if @current_tab=='list')}
= link_to t('config.tabs.list'), list_admin_config_path, class: ('btn' unless @current_tab=='list')

View file

@ -0,0 +1,16 @@
- title t('.title'), false
= render 'tabs'
%table.table
%thead
%tr
%th= t '.key'
%th= t '.value'
%tbody
- @keys.each do |key|
%tr
%td
%tt= key
%td{style: if @cfg[key] != @dfl[key] then 'font-weight: bold' end}
= show_config_value key, @cfg[key]

View file

@ -0,0 +1,13 @@
- title t('admin.configs.tabs.title'), false
= simple_form_for :config, method: :patch do |f|
= render 'tabs', url: nil
.tab-content
- for tab in @tabs
.tab-pane{class: ('active' if @current_tab==tab), id: "tab-#{tab}"}= render "tab_#{tab}", form: f
.form-actions
= f.submit t('.submit'), class: 'btn btn-primary'

View file

@ -10,9 +10,9 @@
%li= link_to t('.profile'), my_profile_path %li= link_to t('.profile'), my_profile_path
%li= link_to t('.ordergroup'), my_ordergroup_path %li= link_to t('.ordergroup'), my_ordergroup_path
%li= link_to t('.logout'), logout_path %li= link_to t('.logout'), logout_path
%li{class: ('disabled' if FoodsoftConfig.config[:homepage].blank?)} %li{class: ('disabled' if FoodsoftConfig[:homepage].blank?)}
= link_to FoodsoftConfig.config[:name], FoodsoftConfig.config[:homepage] = link_to FoodsoftConfig[:name], FoodsoftConfig[:homepage]
%li= link_to t('.help'), FoodsoftConfig.config[:help_url] %li= link_to t('.help'), FoodsoftConfig[:help_url]
%li= link_to t('.feedback.title'), new_feedback_path, title: t('.feedback.desc') %li= link_to t('.feedback.title'), new_feedback_path, title: t('.feedback.desc')
.clearfix .clearfix

View file

@ -37,6 +37,11 @@ default: &defaults
# In case you really want foodsoft in a certain language by default, set this to true. # In case you really want foodsoft in a certain language by default, set this to true.
# When members are logged in, the language from their profile settings is still used. # When members are logged in, the language from their profile settings is still used.
#ignore_browser_locale: false #ignore_browser_locale: false
# Default timezone, e.g. UTC, Amsterdam, Berlin, etc.
#time_zone: Berlin
# Currency symbol, and whether to add a whitespace after the unit.
#currency_unit: €
#currency_space: true
# price markup in percent # price markup in percent
price_markup: 2.0 price_markup: 2.0

View file

@ -7,6 +7,9 @@ Foodsoft::Application.configure do
# and recreated between test runs. Don't rely on the data there! # and recreated between test runs. Don't rely on the data there!
config.cache_classes = true config.cache_classes = true
# We clear the cache for each test, let's do that in memory.
config.cache_store = :memory_store
# Do not eager load code on boot. This avoids loading your whole application # Do not eager load code on boot. This avoids loading your whole application
# just for the purpose of running a single test. If you are using a tool that # just for the purpose of running a single test. If you are using a tool that
# preloads Rails for running tests, you may have to set it to true. # preloads Rails for running tests, you may have to set it to true.

View file

@ -0,0 +1,7 @@
# remove all currency translations, so that we can set the default language and
# have it shown in all other languages too
::I18n.available_locales.each do |locale|
unless locale == ::I18n.default_locale
::I18n.backend.store_translations(locale, number: {currency: {format: {unit: nil}}})
end
end

View file

@ -225,6 +225,22 @@ en:
title: Administration title: Administration
type: type type: type
username: username username: username
configs:
list:
title: Configuration list
key: Key
value: Value
show:
title: Configuration
submit: Save
tab_layout:
pdf_title: PDF documents
tabs:
title: Configuration
tab_messages:
emails_title: Sending email
update:
notice: Configuration saved.
confirm: Do you really want to delete %{name}? confirm: Do you really want to delete %{name}?
ordergroups: ordergroups:
destroy: destroy:
@ -419,6 +435,84 @@ en:
file_label: Please choose a compatible file file_label: Please choose a compatible file
submit: Upload file submit: Upload file
title: '%{supplier} / upload articles' title: '%{supplier} / upload articles'
config:
tabs:
foodcoop: Foodcoop
payment: Finances
language: Language
messages: Messages
tasks: Tasks
layout: Layout
others: Other
list: List
hints:
name: The name of your foodcoop.
contact:
email: General contact email address, shown on website as well as some forms.
street: Address, typically this will be your delivery and pick-up location.
currency_unit: Currency symbol for displaying prices.
currency_space: Whether to add whitespace after the currency symbol.
custom_css: To modify the layout of this site, you can enter style modifications using the cascading stylesheets (CSS) language. Leave blank for the default style.
homepage: Website of your foodcoop.
help_url: Documentation website.
applepear_url: Website where the apple and pear system for tasks is explained.
ignore_browser_locale: "Ignore the language of user's computer when the user has not chosen a language yet."
price_markup: Percentage that is added to the gross price for foodcoop members.
tax_default: Default VAT percentage for new articles.
stop_ordering_under: Members can only order when they have at least this many apple points.
minimum_balance: Members can only order when their account balance is above or equal to this amount.
use_apple_points: When the apple point system is enabled, members are required to do some tasks to be able to keep ordering.
use_nick: Show and use nicknames instead of real names. When enabling this, please check that each user has a nickname.
use_messages: Allow members to communicate with each other within Foodsoft.
use_wiki: Enable editable wiki pages.
email_sender: "Emails will appear to be sent from this email address. To avoid emails sent being classified as spam, the webserver may need to be registered in the SPF record of the email address's domain."
email_from: "Emails will appear to be from this email address. Leave empty to use the foodcoop's contact address."
email_replyto: Set this when you want to receive replies from emails sent by Foodsoft on a different address than the above.
mailing_list: Mailing-list email address to use instead of the messaging system for mail to all members.
mailing_list_subscribe: Email address where members can send an email to for subscribing.
page_footer: 'Shown on each page at the bottom. Enter "blank" to disable the footer completely.'
pdf_font_size: Base font size for PDF documents (12 is standard).
pdf_page_size: 'Page size for PDF documents, typically "A4" or "letter".'
pdf_add_page_breaks:
order_by_articles: Put each article on a separate page.
order_by_groups: Put each ordergroup on a separate page.
tolerance_is_costly: "Order as much of the member tolerance as possible (compared to only as much needed to fill the last box). Enabling this also includes the tolerance in the total price of the open member order."
keys:
name: Name
contact:
street: Street
zip_code: Postcode
city: City
country: Country
email: Email
phone: Phone
currency_unit: Currency
currency_space: add space
custom_css: Custom CSS
homepage: Homepage
help_url: Documentation URL
applepear_url: Apple system URL
default_locale: Default language
ignore_browser_locale: Ignore browser language
price_markup: Foodcoop margin
tax_default: Default VAT
tolerance_is_costly: Tolerance is costly
stop_ordering_under: Minimum apple points
use_apple_points: Apple points
minimum_balance: Minimum account balance
use_nick: Use nicknames
use_messages: Enable messages
use_wiki: Enable wiki
email_sender: Sender address
email_from: From address
email_replyto: Reply-to address
mailing_list: Mailing-list
mailing_list_subscribe: Mailing-list subscribe
page_footer: Page footer
pdf_font_size: Font size
pdf_page_size: Page size
pdf_add_page_breaks: Page breaks
time_zone: Time zone
deliveries: deliveries:
add_stock_change: add_stock_change:
how_many_units: 'How many units (%{unit}) to deliver? Stock article name: %{name}.' how_many_units: 'How many units (%{unit}) to deliver? Stock article name: %{name}.'
@ -1043,6 +1137,7 @@ en:
no_ordergroup: no ordergroup no_ordergroup: no ordergroup
navigation: navigation:
admin: admin:
config: Configuration
home: Overview home: Overview
ordergroups: Ordergroups ordergroups: Ordergroups
title: Administration title: Administration

View file

@ -213,6 +213,22 @@ nl:
title: Administratie title: Administratie
type: Type type: Type
username: Gebruikersnaam username: Gebruikersnaam
configs:
list:
title: Configuratielijst
key: Sleutel
value: Inhoud
show:
title: Configuratie
submit: Opslaan
tab_layout:
pdf_title: PDF documenten
tabs:
title: Configuratie
tab_messages:
emails_title: Emailinstellingen
update:
notice: Configuratie opgeslagen.
confirm: Wil je %{name} daadwerkelijk wissen? confirm: Wil je %{name} daadwerkelijk wissen?
ordergroups: ordergroups:
destroy: destroy:
@ -402,6 +418,80 @@ nl:
file_label: Graag een compatibel bestand uitkiezen file_label: Graag een compatibel bestand uitkiezen
submit: Bestand uploaden submit: Bestand uploaden
title: Artikelen uploaden voor %{supplier} title: Artikelen uploaden voor %{supplier}
config:
tabs:
foodcoop: Foodcoop
payment: Financiën
language: Taal
messages: Berichten
tasks: Taken
layout: Layout
others: Overig
list: Lijst
hints:
name: De naam van de foodcoop.
contact:
email: Algemeen contactadres, zowel voor op de website als in formulieren.
street: Adres, meestal is dit het aflever- en ophaaladres.
custom_css: De layout van deze site kan gewijzigd worden door hier cascading stylesheets (CSS) in te voeren. Laat het leeg voor de standaardstijl.
homepage: Website van de foodcoop.
help_url: Documentatie website.
applepear_url: Website waar het appelpunten systeem wordt uitgelegd.
ignore_browser_locale: Negeer de taal van de computer wanneer iemand nog geen taal gekozen heeft.
price_markup: Percentage dat bovenop het brutobedrag wordt gedaan voor foodcoop leden.
tax_default: Standaard BTW percentage voor nieuwe artikelen.
stop_ordering_under: Leden kunnen slechts bestellen als ze dit aantal appelpunten hebben of meer.
minimum_balance: Leden kunnen slechts bestellen wanneer hun tegoed groter of gelijk is aan dit bedrag.
use_apple_points: Wanneer het appelpunten systeem is geactiveerd, kunnen leden slechts bestellen wanneer ze meewerken aan taken.
use_nick: Toon bijnamen in plaats van volledige naam. Controleer dat iedereen een bijnaam heeft wanneer je dit aanzet.
use_messages: Laat leden met elkaar communiceren door middel van berichten binnen Foodsoft.
use_wiki: "Gebruik wiki pagina's."
email_sender: "Emails worden verzonden vanaf dit emailadres. Om te voorkomen dat emails als spam worden tegengehouden, is het te adviseren het adres van de webserver op te nemen in het SPF record van het email domein."
email_from: "Emails zullen lijken verzonden te zijn vanaf dit email adres. Laat het veld leeg om het contactadres van de foodcoop te gebruiken."
email_replyto: Vul dit in als je antwoord op mails van Foodsoft wilt ontvangen op een ander adres dan het bovenstaande.
mailing_list: Mailing-lijst adres om te gebruiken in plaats van het berichtensysteem voor emails naar alle leden.
mailing_list_subscribe: Emailadres waar leden zich kunnen aanmelden voor de mailing-lijst.
page_footer: 'Wordt op iedere pagina getoond. Vul "blank" in om de voettekst helemaal weg te halen.'
pdf_font_size: Basis tekstgrootte voor PDF bestanden (standaard 12).
pdf_page_size: 'Paginaformaat voor PDF bestanden, meestal "A4" of "letter".'
pdf_add_page_breaks:
order_by_articles: Ieder artikel op een nieuwe pagina.
order_by_groups: Ieder huishouden op een nieuwe pagina.
tolerance_is_costly: "Bestel zoveel artikelen als mogelijk in de tolerantie (in plaats van net genoeg om de laatste doos te vullen). Dit zorgt er ook voor dat de tolerantie in de prijs van open ledenbestellingen wordt meegenomen."
keys:
name: Naam
contact:
street: Straat
zip_code: Postcode
city: Stad
country: Land
email: Email
phone: Telefoon
custom_css: Aangepaste CSS
homepage: Homepage
help_url: Documentatie URL
applepear_url: Appelsysteem uitleg URL
default_locale: Standaardtaal
ignore_browser_locale: Browsertaal negeren
price_markup: Foodcoop marge
tax_default: Standaard BTW
tolerance_is_costly: Tolerantie is duur
stop_ordering_under: Minimum appelpunten
use_apple_points: Appelpunten
minimum_balance: Minimum tegoed
use_nick: Bijnamen gebruiken
use_messages: Berichten gebruiken
use_wiki: Wiki gebruiken
email_sender: Sender adres
email_from: From adres
email_replyto: Reply-to adres
mailing_list: Mailing-lijst
mailing_list_subscribe: Mailing-lijst aanmelden
page_footer: Voettkest
pdf_font_size: Tekstgrootte
pdf_page_size: Paginaformaat
pdf_add_page_breaks: "Nieuwe pagina's"
time_zone: Tijdzone
deliveries: deliveries:
add_stock_change: add_stock_change:
how_many_units: 'Hoeveel eenheden (%{unit}) leveren? Voorraadartikel: %{name}.' how_many_units: 'Hoeveel eenheden (%{unit}) leveren? Voorraadartikel: %{name}.'
@ -1023,6 +1113,7 @@ nl:
no_ordergroup: geen huishouden no_ordergroup: geen huishouden
navigation: navigation:
admin: admin:
config: Instellingen
home: Overzicht home: Overzicht
ordergroups: Huishoudens ordergroups: Huishoudens
title: Administratie title: Administratie

View file

@ -45,6 +45,7 @@ SimpleNavigation::Configuration.run do |navigation|
subnav.item :users, I18n.t('navigation.admin.users'), admin_users_path subnav.item :users, I18n.t('navigation.admin.users'), admin_users_path
subnav.item :ordergroups, I18n.t('navigation.admin.ordergroups'), admin_ordergroups_path subnav.item :ordergroups, I18n.t('navigation.admin.ordergroups'), admin_ordergroups_path
subnav.item :workgroups, I18n.t('navigation.admin.workgroups'), admin_workgroups_path subnav.item :workgroups, I18n.t('navigation.admin.workgroups'), admin_workgroups_path
subnav.item :config, I18n.t('navigation.admin.config'), admin_config_path
end end
engines.each { |e| e.navigation(primary, self) } engines.each { |e| e.navigation(primary, self) }

View file

@ -181,6 +181,10 @@ Foodsoft::Application.routes.draw do
resources :ordergroups do resources :ordergroups do
get :memberships, :on => :member get :memberships, :on => :member
end end
resource :config, :only => [:show, :update] do
get :list
end
end end
############## Feedback ############## Feedback

View file

@ -66,18 +66,6 @@ ActiveRecord::Schema.define(version: 20140521142651) do
add_index "assignments", ["user_id", "task_id"], name: "index_assignments_on_user_id_and_task_id", unique: true, using: :btree add_index "assignments", ["user_id", "task_id"], name: "index_assignments_on_user_id_and_task_id", unique: true, using: :btree
create_table "configurable_settings", force: true do |t|
t.integer "configurable_id"
t.string "configurable_type"
t.integer "targetable_id"
t.string "targetable_type"
t.string "name", default: "", null: false
t.string "value_type"
t.text "value"
end
add_index "configurable_settings", ["name"], name: "index_configurable_settings_on_name", using: :btree
create_table "deliveries", force: true do |t| create_table "deliveries", force: true do |t|
t.integer "supplier_id" t.integer "supplier_id"
t.date "delivered_on" t.date "delivered_on"

View file

@ -1,13 +1,66 @@
# 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 class FoodsoftConfig
mattr_accessor :scope, :config
# @!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
# Configuration file location.
# Taken from environment variable +FOODSOFT_APP_CONFIG+,
# or else +config/app_config.yml+.
APP_CONFIG_FILE = ENV['FOODSOFT_APP_CONFIG'] || 'config/app_config.yml' APP_CONFIG_FILE = ENV['FOODSOFT_APP_CONFIG'] || 'config/app_config.yml'
# Rails.logger isn't ready yet - and we don't want to litter rspec invocation with this msg # Rails.logger isn't ready yet - and we don't want to litter rspec invocation with this msg
puts "-> Loading app configuration from #{APP_CONFIG_FILE}" unless defined? RSpec puts "-> Loading app configuration from #{APP_CONFIG_FILE}" unless defined? RSpec
APP_CONFIG = YAML.load(File.read(File.expand_path(APP_CONFIG_FILE, Rails.root))) # Loaded configuration
APP_CONFIG = ActiveSupport::HashWithIndifferentAccess.new(
YAML.load(File.read(File.expand_path(APP_CONFIG_FILE, Rails.root)))
)
class << self class << self
def init def init
# Gather program-default configuration
self.default_config = get_default_config
# Load initial config from development or production # Load initial config from development or production
set_config Rails.env set_config Rails.env
# Overwrite scope to have a better namescope than 'production' # Overwrite scope to have a better namescope than 'production'
@ -16,18 +69,68 @@ class FoodsoftConfig
set_missing set_missing
end end
# Set config and database connection for specific foodcoop # Set config and database connection for specific foodcoop.
# Only needed in multi coop mode #
# Only needed in multi coop mode.
# @param foodcoop [String, Symbol] Foodcoop to select.
def select_foodcoop(foodcoop) def select_foodcoop(foodcoop)
set_config foodcoop set_config foodcoop
setup_database setup_database
end end
# Provides a nice accessor for config values # 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' # 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) def [](key)
if RailsSettings::CachedSettings.table_exists? and allowed_key?(key)
value = RailsSettings::CachedSettings["foodcoop.#{self.scope}.#{key}"]
value = config[key] if value.nil?
value
else
config[key] config[key]
end 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 or (config[key].nil? and 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
return 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
# Loop through each foodcoop and executes the given block after setup config and database # Loop through each foodcoop and executes the given block after setup config and database
def each_coop def each_coop
@ -41,11 +144,60 @@ class FoodsoftConfig
end end
end 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
return !self.config[:protected][key]
else
return !self.config[:protected][:all]
end
# @todo allow to check nested keys as well
end
# @return [Hash] Full configuration.
def to_hash
Hash[keys.map {|k| [k, self[k]]} ]
end
protected
# @!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
mattr_accessor :default_config
# Reload original configuration file, e.g. in between tests.
# @param filename [String] Override configuration file
def reload!(filename = APP_CONFIG_FILE)
APP_CONFIG.clear.merge! YAML.load(File.read(File.expand_path(filename, Rails.root)))
init
end
private private
def set_config(foodcoop) def set_config(foodcoop)
raise "No config for this environment (#{foodcoop}) available!" if APP_CONFIG[foodcoop].nil? raise "No config for this environment (#{foodcoop}) available!" if APP_CONFIG[foodcoop].nil?
self.config = APP_CONFIG[foodcoop].symbolize_keys self.config = APP_CONFIG[foodcoop]
self.scope = foodcoop self.scope = foodcoop
set_missing set_missing
end end
@ -56,21 +208,58 @@ class FoodsoftConfig
ActiveRecord::Base.establish_connection(database_config) ActiveRecord::Base.establish_connection(database_config)
end end
# When new options are introduced, put backward-compatible defaults here, so that # Completes foodcoop configuration with program defaults.
# configuration files that haven't been updated, still work as they did. # @see #foodsoft_config
def set_missing def set_missing
config.replace({ config.replace(default_config.deep_merge(config))
use_nick: true,
use_apple_points: true,
foodsoft_url: 'https://github.com/foodcoops/foodsoft'
}.merge(config))
end end
# reload original configuration file, e.g. in between tests # Returns program-default configuration.
def reload!(filename = APP_CONFIG_FILE) # When new options are introduced, put backward-compatible defaults here, so that
APP_CONFIG.clear.merge! YAML.load(File.read(File.expand_path(filename, Rails.root))) # configuration files that haven't been updated, still work as they did. This also
init # 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',
currency_unit: '€',
currency_space: true,
foodsoft_url: 'https://github.com/foodcoops/foodsoft',
# 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::Railties.engines.select { |e| e.respond_to?(:default_foodsoft_config) }
engines.each { |e| e.default_foodsoft_config(cfg) }
cfg
end end
# Normalize value recursively (which can be entered as strings, but we want to store it properly)
def normalize_value(value)
value = value.map(&:normalize_value) if value.is_a? Array
value = Hash[ value.to_a.map{|a| [a[0], normalize_value(a[1])]} ] if value.is_a? Hash
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
end end

View file

@ -0,0 +1,4 @@
/ insert_bottom ':root:last-child'
= config_use_heading form, :use_messages, label: 'Messages' do
= config_input form, :mailing_list, as: :string, input_html: {class: 'input-xlarge'}
= config_input form, :mailing_list_subscribe, as: :string, input_html: {class: 'input-xlarge'}

View file

@ -4,8 +4,8 @@ require "deface"
module FoodsoftMessages module FoodsoftMessages
# Return whether messages are used or not. # Return whether messages are used or not.
# Enabled by default since it used to be part of the foodsoft core. # Enabled by default in {FoodsoftConfig} since it used to be part of the foodsoft core.
def self.enabled? def self.enabled?
FoodsoftConfig[:use_messages] != false FoodsoftConfig[:use_messages]
end end
end end

View file

@ -11,5 +11,9 @@ module FoodsoftMessages
sub_nav.items.insert(i, sub_nav.items.delete_at(-1)) sub_nav.items.insert(i, sub_nav.items.delete_at(-1))
end end
end end
def default_foodsoft_config(cfg)
cfg[:use_messages] = true
end
end end
end end

View file

@ -6,8 +6,8 @@ require 'foodsoft_wiki/engine'
module FoodsoftWiki module FoodsoftWiki
# Return whether the wiki is used or not. # Return whether the wiki is used or not.
# Enabled by default since it used to be part of the foodsoft core. # Enabled by default in {FoodsoftConfig} since it used to be part of the foodsoft core.
def self.enabled? def self.enabled?
FoodsoftConfig[:use_wiki] != false FoodsoftConfig[:use_wiki]
end end
end end

View file

@ -11,5 +11,9 @@ module FoodsoftWiki
primary.items.insert(i+1, primary.items.delete_at(-1)) primary.items.insert(i+1, primary.items.delete_at(-1))
end end
end end
def default_foodsoft_config(cfg)
cfg[:use_wiki] = true
end
end end
end end

View file

@ -13,6 +13,9 @@ default: &defaults
contact: contact:
email: fc@minimal.test email: fc@minimal.test
# required by configuration form (but otherwise not)
homepage: http://www.minimal.test/
# true by default to keep compat with older installations, but test with false here # true by default to keep compat with older installations, but test with false here
use_nick: false use_nick: false

View file

@ -0,0 +1,46 @@
require_relative '../spec_helper'
describe 'admin/configs', type: :feature do
let(:name) { Faker::Lorem.words(rand(2..4)).join(' ') }
describe type: :feature, js: true do
let(:admin) { create :admin }
before { login admin }
it 'has initial value' do
FoodsoftConfig[:name] = name
visit admin_config_path
within('form.config') do
expect(find_field('config_name').value).to eq name
end
end
it 'can modify a value' do
visit admin_config_path
fill_in 'config_name', with: name
within('form.config') do
find('input[type="submit"]').click
expect(find_field('config_name').value).to eq name
end
expect(FoodsoftConfig[:name]).to eq name
end
it 'keeps config the same without changes' do
orig_values = get_full_config
visit admin_config_path
within('form.config') do
find('input[type="submit"]').click
expect(find_field('config_name').value).to eq FoodsoftConfig[:name]
end
expect(get_full_config).to eq orig_values
end
def get_full_config
cfg = FoodsoftConfig.to_hash.deep_dup
cfg.each {|k,v| v.reject! {|k,v| v.blank?} if v.is_a? Hash}
cfg.reject! {|k,v| v.blank?}
cfg
end
end
end

View file

@ -0,0 +1,99 @@
require_relative '../spec_helper'
describe FoodsoftConfig do
let(:name) { Faker::Lorem.words(rand(2..4)).join(' ') }
let(:other_name) { Faker::Lorem.words(rand(2..4)).join(' ') }
it 'returns a default value' do
expect(FoodsoftConfig[:protected][:database]).to be true
end
it 'returns an empty default value' do
expect(FoodsoftConfig[:protected][:LIUhniuyGNKUQTWfbiOQIWYexngo78hqexul]).to be nil
end
it 'returns a configuration value' do
FoodsoftConfig.config[:name] = name
expect(FoodsoftConfig[:name]).to eq name
end
it 'can set a configuration value' do
FoodsoftConfig[:name] = name
expect(FoodsoftConfig[:name]).to eq name
end
it 'can override a configuration value' do
FoodsoftConfig.config[:name] = name
FoodsoftConfig[:name] = other_name
expect(FoodsoftConfig[:name]).to eq other_name
end
it 'cannot set a default protected value' do
old = FoodsoftConfig[:database]
FoodsoftConfig[:database] = name
expect(FoodsoftConfig.config[:database]).to eq old
end
it 'can unprotect a default protected value' do
FoodsoftConfig.config[:protected][:database] = false
old = FoodsoftConfig[:database]
FoodsoftConfig[:database] = name
expect(FoodsoftConfig[:database]).to eq name
end
describe 'can protect a value', type: :feature do
before do
FoodsoftConfig.config[:protected][:name] = true
end
it 'can protect a value' do
old_name = FoodsoftConfig[:name]
FoodsoftConfig[:name] = name
expect(FoodsoftConfig[:name]).to eq old_name
end
it 'and unprotect it again' do
old_name = FoodsoftConfig[:name]
FoodsoftConfig.config[:protected][:name] = false
FoodsoftConfig[:name] = name
expect(FoodsoftConfig[:name]).to eq name
end
end
it 'can protect all values' do
old_name = FoodsoftConfig[:name]
FoodsoftConfig.config[:protected][:all] = true
FoodsoftConfig[:name] = name
expect(FoodsoftConfig[:name]).to eq old_name
end
it 'can whitelist a value' do
FoodsoftConfig.config[:protected][:all] = true
FoodsoftConfig.config[:protected][:name] = false
FoodsoftConfig[:name] = name
expect(FoodsoftConfig[:name]).to eq name
end
describe 'has indifferent access', type: :feature do
it 'with symbol' do
FoodsoftConfig[:name] = name
expect(FoodsoftConfig[:name]).to eq FoodsoftConfig['name']
end
it 'with string' do
FoodsoftConfig['name'] = name
expect(FoodsoftConfig['name']).to eq FoodsoftConfig[:name]
end
it 'with nested symbol' do
FoodsoftConfig[:protected][:database] = true
expect(FoodsoftConfig[:protected]['database']).to eq FoodsoftConfig[:protected][:database]
end
it 'with nested string' do
FoodsoftConfig[:protected]['database'] = true
expect(FoodsoftConfig[:protected]['database']).to eq FoodsoftConfig[:protected][:database]
end
end
end

View file

@ -35,6 +35,8 @@ RSpec.configure do |config|
end end
config.after(:each) do config.after(:each) do
DatabaseCleaner.clean DatabaseCleaner.clean
# Need to clear cache for RailsSettings::CachedSettings
Rails.cache.clear
end end
# reload foodsoft configuration, so that tests can use FoodsoftConfig.config[:foo]=x # reload foodsoft configuration, so that tests can use FoodsoftConfig.config[:foo]=x