1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-29 18:52:22 +01:00

Merge branch 'dev' for release 2.4.7

This commit is contained in:
Sylvain 2016-12-14 10:41:46 +01:00
commit 96259ab257
33 changed files with 475 additions and 101 deletions

View File

@ -1 +1 @@
2.4.6
2.4.7

4
.gitignore vendored
View File

@ -15,6 +15,7 @@
# Ignore all logfiles and tempfiles.
/log/*.log
/tmp
/coverage
/public/uploads
/public/assets
@ -39,5 +40,8 @@
.vagrant
.docker
# Do not versionate coveralls token
.coveralls.yml
# Plugins are versioned is their own repository
/plugins/*

View File

@ -1,9 +1,18 @@
# Changelog Fab Manager
## v2.4.6 2016 Novembre 30
## v2.4.7 2016 December 14
- Improved automated testing
- Added an information notice about the processing time of deleting an administrator
- Ability to change the expiration date of a coupon after its creation
- Ability to generate a refund invoice when crediting user's wallet
- Fix a bug: unable to run rake db:migrate on first install
- Fix a bug: unable to create or edit a coupon of type 'percentage'
## v2.4.6 2016 November 30
- Change display of message about coupon application status
- Fix a bug: compute price API return error 500 if reservable_id is not provided
- Fix a bug: compute price API return error 500 if reservable_id is not provided
## v2.4.5 2016 November 29
@ -25,8 +34,8 @@
## v2.4.3 2016 November 21
- Export user's invoicing status in members' excel export
- Fix a bug: Next events descriptions, shown on the home page, display raw html
- Fix a bug: number of reserved seats for an event is always of 1 in the excel export of reservations
- Fix a bug: Next events descriptions, shown on the home page, display raw html
- Fix a bug: number of reserved seats for an event is always of 1 in the excel export of reservations
- Fix a bug: conflict between similar translations around "reservations"
- Fix a bug: later occurrences of recurrent events does not have the initially configured theme and age range
- Fix a bug: some graphs do not display: events, users, trainings and machine hours
@ -44,18 +53,18 @@
## v2.4.1 2016 October 11
- Fix a bug: unable to share a project/event without image on social networks
- Fix a bug: unable to share a project/event without image on social networks
- Fix a bug: after creating an element in the admin calendar, browsing through the calendar and coming back cause the element to appear duplicated
- Fix a bug: after deleting an element in the admin calendar, the confirmation message is wrong and an error is logged in the console
- Fix a bug: erroneous syntax in docker env example file
## v2.4.0 2016 October 4
- RSS feeds to follow new projects and events published
- Use slugs in projects URL opened from notifications
- Ask for confirmation on machine deletion from the public view
- Ability to delete a training from the public view for an admin
- Project images will show in full-size on a click
- Project images will show in full-size on a click
- Add a checkbox "I accept to receive informations from the FabLab" on Sign-up dialog and user's profile
- Share project with Facebook/Twitter
- Display fab-manager's version in "Powered by" label, when logged as admin
@ -70,14 +79,14 @@
- Trainings are associated with a picture and an HTML textual description
- Public gallery of trainings with ability to view details or to book a training on its own calendar
- Ability to switch back to all trainings booking view
- Rename "Courses and Workshops" to "Events"
- Rename "Courses and Workshops" to "Events"
- Admin: Events can be associated with a theme and an age range
- Admin: Event categories, themes and age ranges can be customized
- Filter events by category, theme and age range in public view
- Ability to customise price's categories for the events
- Events can be associated with many custom price's categories, instead of only one "reduced price"
- Statistics views can trigger and display custom aggregations from ElasticSearch
- Machine hours/Trainings statistics: display number of tickets/hours available for booking
- Machine hours/Trainings statistics: display number of tickets/hours available for booking
- Statistics will include informations abouts events category, theme and age range
- Ability to export the current statistics table to an Excel file
- Ability to export every statistics on a given dates range to an Excel file
@ -93,16 +102,16 @@
- More file types allowed as project CAD attachements
- Project CAD attachements are now checked by MIME type in addition of extension check
- Project CAD attachement allowed are now configured in environment variables
- Project CAD attachement extensions allowed are shown next to input field
- Project CAD attachement extensions allowed are shown next to input field
- Display strategy's name in SSO providers list
- SSO: documentation improved with an usage example
- SSO: mapped fields display their data type. Integers, booleans and dates allow some transformations.
- Fix a bug: project drafts are shown on public profiles
- Fix a bug: event category disappear when editing the event
- Fix a bug: machine name is not shown in plan edition
- Fix a bug: machine name is not shown in plan edition
- Fix a bug: machine slots with tags are not displayed correctly on reservation calendar
- Fix a bug: avatar, address and organization details mapping from SSO were broken
- Fix a bug: in SSO configuration some valid endpoints were recognized as erroneous
- Fix a bug: in SSO configuration some valid endpoints were recognized as erroneous
- Fix a bug: clicking on the text in stripe's payment modal, does not validate the checkbox
- Fix a bug: move event reservation is not limited by admin settings (prior-delay & disable)
- Fix a bug: UI issues on small devices (dashboard + admin views)
@ -134,7 +143,7 @@
- [TODO DEPLOY] `bundle install` and `rake db:migrate`
## v2.2.2 2016 June 23
- Fix some bugs: users with uncompleted account (sso imported) won't appear in statistics, in listings and in searches. Moreover, they won't block statistics generation
- Fix some bugs: users with uncompleted account (sso imported) won't appear in statistics, in listings and in searches. Moreover, they won't block statistics generation
- Fix a bug: unable to display next results in statistics tables
- Admin: Category is mandatory when creating an event
@ -150,7 +159,7 @@
- User public profile: UI re-design with possible admin's customization
- Admin: Invoices list and users list are now loaded per 10 items to improve pages load time
- Admin: select member (eg. to buy a subscription for a member) is now loading the user's list dynamically when you type
- Project collaborators selection is now using a list dynamically loaded as you type
- Project collaborators selection is now using a list dynamically loaded as you type
- Admin: select a training before monitoring its reservations -> improves page load time
- API: GET /api/trainings do not load nor send the associated availabilities until they are requested
- List of members is now loaded 10 members by 10, to improve page load time

View File

@ -52,6 +52,8 @@ group :development do
gem 'capistrano-maintenance', '0.0.5', require: false
gem 'active_record_query_trace'
gem 'coveralls', require: false
end
group :test do
@ -62,6 +64,7 @@ group :test do
gem 'webmock'
gem 'vcr'
gem 'byebug'
gem 'pdf-reader'
end
group :production do

View File

@ -1,6 +1,7 @@
GEM
remote: https://rubygems.org/
specs:
Ascii85 (1.0.2)
aasm (4.1.0)
actionmailer (4.2.5)
actionpack (= 4.2.5)
@ -41,6 +42,7 @@ GEM
thread_safe (~> 0.3, >= 0.3.4)
tzinfo (~> 1.1)
addressable (2.3.8)
afm (0.2.2)
ansi (1.5.0)
api-pagination (4.3.0)
apipie-rails (0.3.6)
@ -118,6 +120,12 @@ GEM
sass-rails (<= 5.0.1)
sprockets (< 2.13)
connection_pool (2.2.0)
coveralls (0.8.16)
json (>= 1.8, < 3)
simplecov (~> 0.12.0)
term-ansicolor (~> 1.3.0)
thor (~> 0.19.1)
tins (>= 1.6.0, < 2)
crack (0.4.3)
safe_yaml (~> 1.0.0)
database_cleaner (1.4.1)
@ -133,6 +141,7 @@ GEM
warden (~> 1.2.3)
devise-async (0.9.0)
devise (~> 3.2)
docile (1.1.5)
domain_name (0.5.25)
unf (>= 0.0.5, < 1.0.0)
elasticsearch (1.0.12)
@ -177,6 +186,7 @@ GEM
has_secure_token (1.0.0)
activerecord (>= 3.0)
hashdiff (0.3.0)
hashery (2.1.2)
hashie (3.4.2)
highline (1.7.1)
hike (1.2.3)
@ -265,6 +275,12 @@ GEM
httparty (~> 0.13)
orm_adapter (0.5.0)
pdf-core (0.5.1)
pdf-reader (1.4.0)
Ascii85 (~> 1.0.0)
afm (~> 0.2.1)
hashery (~> 2.0)
ruby-rc4
ttfunk
pg (0.18.1)
pkg-config (1.1.7)
prawn (2.0.1)
@ -335,6 +351,7 @@ GEM
netrc (~> 0.7)
rolify (4.0.0)
ruby-progressbar (1.7.5)
ruby-rc4 (0.1.5)
rubyzip (1.1.7)
rufus-scheduler (3.0.9)
tzinfo
@ -365,6 +382,11 @@ GEM
sidekiq (>= 2.17.3)
tilt (< 2.0.0)
simple_oauth (0.3.1)
simplecov (0.12.0)
docile (~> 1.1.0)
json (>= 1.8, < 3)
simplecov-html (~> 0.10.0)
simplecov-html (0.10.0)
sinatra (1.4.6)
rack (~> 1.4)
rack-protection (~> 1.4)
@ -382,6 +404,8 @@ GEM
stripe (1.30.2)
json (~> 1.8.1)
rest-client (~> 1.4)
term-ansicolor (1.3.2)
tins (~> 1.0)
test_after_commit (1.0.0)
activerecord (>= 3.2)
therubyracer (0.12.0)
@ -392,6 +416,7 @@ GEM
tilt (1.4.1)
timers (4.0.1)
hitimes
tins (1.13.0)
ttfunk (1.4.0)
twitter (5.14.0)
addressable (~> 2.3)
@ -462,6 +487,7 @@ DEPENDENCIES
chroma
coffee-rails (~> 4.1.0)
compass-rails (= 2.0.4)
coveralls
database_cleaner
devise
devise-async
@ -488,6 +514,7 @@ DEPENDENCIES
omniauth
omniauth-oauth2
openlab_ruby
pdf-reader
pg
prawn
prawn-table
@ -522,4 +549,4 @@ DEPENDENCIES
webmock
BUNDLED WITH
1.12.5
1.13.1

View File

@ -58,8 +58,8 @@ Application.Controllers.controller "NewCouponController", ["$scope", "$state", '
##
# Controller used in the coupon edition page
##
Application.Controllers.controller "EditCouponController", ["$scope", "$state", 'Coupon', 'couponPromise', '_t'
, ($scope, $state, Coupon, couponPromise, _t) ->
Application.Controllers.controller "EditCouponController", ["$scope", "$state", 'Coupon', 'couponPromise', '_t', 'growl'
, ($scope, $state, Coupon, couponPromise, _t, growl) ->
### PUBLIC SCOPE ###
@ -73,6 +73,9 @@ Application.Controllers.controller "EditCouponController", ["$scope", "$state",
## Options for the validity per user
$scope.validities = userValidities
## Mapping for validation errors
$scope.errors = {}
## Default parameters for AngularUI-Bootstrap datepicker (used for coupon validity limit selection)
$scope.datePicker =
format: Fablab.uibDateFormat
@ -98,11 +101,12 @@ Application.Controllers.controller "EditCouponController", ["$scope", "$state",
# Callback to save the coupon's changes to the API
##
$scope.updateCoupon = ->
$scope.errors = {}
Coupon.update {id: $scope.coupon.id}, coupon: $scope.coupon, (coupon) ->
$state.go('app.admin.pricing')
, (err)->
growl.error(_t('unable_to_update_the_coupon_an_error_occurred'))
console.error(err)
$scope.errors = err.data

View File

@ -105,8 +105,8 @@ class MembersController
##
# Controller used in the members/groups management page
##
Application.Controllers.controller "AdminMembersController", ["$scope", 'membersPromise', 'adminsPromise', 'growl', 'Admin', 'dialogs', '_t', 'Member', 'Export'
, ($scope, membersPromise, adminsPromise, growl, Admin, dialogs, _t, Member, Export) ->
Application.Controllers.controller "AdminMembersController", ["$scope","$sce", 'membersPromise', 'adminsPromise', 'growl', 'Admin', 'dialogs', '_t', 'Member', 'Export'
, ($scope, $sce, membersPromise, adminsPromise, growl, Admin, dialogs, _t, Member, Export) ->
@ -177,7 +177,7 @@ Application.Controllers.controller "AdminMembersController", ["$scope", 'members
resolve:
object: ->
title: _t('confirmation_required')
msg: _t('do_you_really_want_to_delete_this_administrator_this_cannot_be_undone')
msg: $sce.trustAsHtml(_t('do_you_really_want_to_delete_this_administrator_this_cannot_be_undone') + '<br/><br/>' +_t('this_may_take_a_while_please_wait'))
, -> # cancel confirmed
Admin.delete id: admin.id, ->
admins.splice(findAdminIdxById(admins, admin.id), 1)
@ -423,11 +423,40 @@ Application.Controllers.controller "EditMemberController", ["$scope", "$state",
templateUrl: '<%= asset_path "wallet/credit_modal.html" %>'
controller: ['$scope', '$uibModalInstance', 'Wallet', ($scope, $uibModalInstance, Wallet) ->
# default: do not generate a refund invoice
$scope.generate_avoir = false
# date of the generated refund invoice
$scope.avoir_date = null
# optional description shown on the refund invoice
$scope.description = ''
# default configuration for the avoir date selector widget
$scope.datePicker =
format: Fablab.uibDateFormat
opened: false
options:
startingDay: Fablab.weekStartingDay
##
# Callback to open/close the date picker
##
$scope.toggleDatePicker = ($event) ->
$event.preventDefault()
$event.stopPropagation()
$scope.datePicker.opened = !$scope.datePicker.opened
##
# Modal dialog validation callback
##
$scope.ok = ->
Wallet.credit { id: wallet.id }, { amount: $scope.amount }, (_wallet)->
Wallet.credit { id: wallet.id },
amount: $scope.amount
avoir: $scope.generate_avoir
avoir_date: $scope.avoir_date
avoir_description: $scope.description
, (_wallet)->
growl.success(_t('wallet_credit_successfully'))
$uibModalInstance.close(_wallet)

View File

@ -81,7 +81,7 @@
<span class="help-block error" ng-show="couponForm['coupon[validity_per_user]'].$dirty && couponForm['coupon[validity_per_user]'].$error.required" translate>{{ 'validity_per_user_is_required' }}</span>
</div>
<div class="form-group">
<div class="form-group" ng-class="{'has-error': errors['valid_until']}">
<label for="coupon[valid_until]" translate>{{ 'valid_until' }}</label>
<div class="input-group">
<input type="text" id="coupon[valid_until]"
@ -92,16 +92,16 @@
datepicker-options="datePicker.options"
is-open="datePicker.opened"
min-date="datePicker.minDate"
ng-disabled="mode == 'EDIT'"
ng-click="toggleDatePicker($event)"/>
<span class="input-group-btn">
<button type="button" class="btn btn-default" ng-click="toggleDatePicker($event)" ng-disabled="mode == 'EDIT'"><i class="fa fa-calendar"></i></button>
</span>
</div>
<span class="help-block error" ng-show="errors['valid_until']">{{ errors['valid_until'].join(' ; ') }}</span>
<span class="help-block text-info text-xs">
<i class="fa fa-lightbulb-o"></i> {{ 'leave_empty_for_no_limit' | translate }}
</span>
<span class="text-info text-xs">
<i class="fa fa-lightbulb-o"></i> {{ 'leave_empty_for_no_limit' | translate }}
</span>
</div>
<div class="form-group" ng-class="{'has-error': couponForm['coupon[max_usages]'].$dirty && couponForm['coupon[max_usages]'].$invalid}">
@ -114,7 +114,7 @@
min="0"/>
<span class="help-block error" ng-show="couponForm['coupon[max_usages]'].$dirty && couponForm['coupon[max_usages]'].$error.min" translate>{{ 'max_usages_must_be_equal_or_greater_than_0' }}</span>
<span class="help-block text-info text-xs">
<span class="text-info text-xs">
<i class="fa fa-lightbulb-o"></i> {{ 'leave_empty_for_no_limit' | translate }}
</span>
</div>

View File

@ -3,7 +3,7 @@
<h1>{{object.title}}</h1>
</div>
<div class="modal-body">
<p>{{object.msg}}</p>
<p ng-bind-html="object.msg"></p>
</div>
<div class="modal-footer">
<button class="btn btn-info" ng-click="ok()" translate>{{ 'confirm' }}</button>

View File

@ -3,11 +3,18 @@
<h1 translate>{{ 'credit_title' }}</h1>
</div>
<div class="modal-body">
<div class="alert alert-warning m-b-md m-b-sm">
<i class="fa fa-warning m-sm inline" aria-hidden="true"></i>
<div class="inline pull-right width-90 m-t-n-xs" translate>{{ 'warning_uneditable_credit' }}</div>
</div>
<form name="walletForm" ng-class="{'has-error': walletForm.amount.$dirty && walletForm.amount.$invalid}">
<div class="text-right amountGroup m-r-md">
<span class="beforeAmount" translate>{{ 'credit_label' }}</span>
<label for="amount" class="beforeAmount" translate>{{ 'credit_label' }}</label>
<input class="form-control m-l"
type="number"
id="amount"
name="amount"
ng-model="amount"
required min="1"
@ -16,10 +23,11 @@
<span class="help-block" ng-show="walletForm.amount.$dirty && walletForm.amount.$error.required" translate>{{'amount_is_required'}}</span>
<span class="help-block" ng-show="walletForm.amount.$dirty && walletForm.amount.$error.min">{{ 'amount_minimum_1' | translate }} {{currencySymbol}}.</span>
</div>
<div class="text-right amountGroup m-t m-r-md">
<span class="beforeAmount" translate>{{ 'confirm_credit_label' }}</span>
<div class="text-right amountGroup m-t m-r-md" ng-class="{'has-error': walletForm.amount_confirm.$dirty && walletForm.amount_confirm.$invalid }">
<label for="amount_confirm" class="beforeAmount" translate>{{ 'confirm_credit_label' }}</label>
<input class="form-control m-l"
type="number"
id="amount_confirm"
name="amount_confirm"
ng-model="amount_confirm"
required
@ -30,12 +38,53 @@
<span class="help-block" ng-show="walletForm.amount_confirm.$dirty && walletForm.amount_confirm.$error.required" translate>{{'amount_confirm_is_required'}}</span>
<span class="help-block" ng-show="walletForm.amount_confirm.$dirty && walletForm.amount_confirm.$error.pattern">{{ 'amount_confirm_does_not_match' | translate }}</span>
</div>
</form>
<div class="alert alert-warning m-t-md m-b-sm">
<i class="fa fa-warning m-sm inline" aria-hidden="true"></i>
<div class="inline pull-right width-90 m-t-n-xs" translate>{{ 'warning_uneditable_credit' }}</div>
</div>
<hr/>
<div class="text-right m-t">
<label for="generate_avoir" translate>{{ 'generate_a_refund_invoice' }}</label>
<div class="inline m-l">
<input bs-switch
ng-model="generate_avoir"
id="generate_avoir"
name="generate_avoir"
type="checkbox"
switch-on-text="{{ 'yes' | translate }}"
switch-off-text="{{ 'no' | translate }}"
switch-animate="true"/>
</div>
</div>
<div ng-show="generate_avoir">
<div class="m-t" ng-class="{'has-error': walletForm.avoir_date.$dirty && walletForm.avoir_date.$invalid }">
<label for="avoir_date" translate>{{ 'creation_date_for_the_refund' }}</label>
<div class="input-group">
<span class="input-group-addon"><i class="fa fa-calendar"></i></span>
<input type="text"
class="form-control"
id="avoir_date"
name="avoir_date"
ng-model="avoir_date"
uib-datepicker-popup="{{datePicker.format}}"
datepicker-options="datePicker.options"
is-open="datePicker.opened"
placeholder="{{datePicker.format}}"
ng-click="toggleDatePicker($event)"
ng-required="generate_avoir"/>
</div>
<span class="help-block" ng-show="walletForm.avoir_date.$dirty && walletForm.avoir_date.$error.required" translate>{{ 'creation_date_is_required' }}</span>
</div>
<div class="m-t">
<label for="description" translate>{{ 'description_(optional)' }}</label>
<p translate>{{ 'will_appear_on_the_refund_invoice' }}</p>
<textarea class="form-control m-t-sm"
id="description"
name="description"
ng-model="description">
</textarea>
</div>
</div>
</form>
</div>
<div class="modal-footer">

View File

@ -90,6 +90,6 @@ class API::CouponsController < API::ApiController
end
def coupon_editable_params
params.require(:coupon).permit(:name, :active)
params.require(:coupon).permit(:name, :active, :valid_until)
end
end

View File

@ -57,7 +57,7 @@ class API::InvoicesController < API::ApiController
end
# only for create avoir
# only for create refund invoices (avoir)
def create
authorize Invoice
invoice = Invoice.only_invoice.find(avoir_params[:invoice_id])

View File

@ -14,13 +14,22 @@ class API::WalletController < API::ApiController
end
def credit
@wallet = Wallet.find(params[:id])
@wallet = Wallet.find(credit_params[:id])
authorize @wallet
service = WalletService.new(user: current_user, wallet: @wallet)
if service.credit(params[:amount].to_f)
transaction = service.credit(credit_params[:amount].to_f)
if transaction
if credit_params[:avoir]
service.create_avoir(transaction, credit_params[:avoir_date], credit_params[:avoir_description])
end
render :show
else
head 422
end
end
private
def credit_params
params.permit(:id, :amount, :avoir, :avoir_date, :avoir_description)
end
end

View File

@ -11,6 +11,7 @@ class Coupon < ActiveRecord::Base
validates :validity_per_user, presence: true
validates :validity_per_user, inclusion: { in: %w(once forever) }
validates_with CouponDiscountValidator
validates_with CouponExpirationValidator
def safe_destroy
if self.invoices.size == 0
@ -76,6 +77,7 @@ class Coupon < ActiveRecord::Base
attached_object: self
end
private
def create_stripe_coupon
StripeWorker.perform_async(:create_stripe_coupon, id)
end

View File

@ -37,10 +37,12 @@ module PDF
if Setting.find_by({name: 'invoice_code-active'}).value == 'true'
text I18n.t('invoices.code', CODE:Setting.find_by({name: 'invoice_code-value'}).value), :leading => 3
end
if invoice.is_a?(Avoir)
text I18n.t('invoices.order_number', NUMBER:invoice.invoice.order_number), :leading => 3
else
text I18n.t('invoices.order_number', NUMBER:invoice.order_number), :leading => 3
if invoice.invoiced_type != WalletTransaction.name
if invoice.is_a?(Avoir)
text I18n.t('invoices.order_number', NUMBER:invoice.invoice.order_number), :leading => 3
else
text I18n.t('invoices.order_number', NUMBER:invoice.order_number), :leading => 3
end
end
if invoice.is_a?(Avoir)
text I18n.t('invoices.refund_invoice_issued_on_DATE', DATE:I18n.l(invoice.avoir_date.to_date))
@ -68,7 +70,11 @@ module PDF
# object
move_down 25
if invoice.is_a?(Avoir)
object = I18n.t('invoices.cancellation_of_invoice_REF', REF: invoice.invoice.reference)
if invoice.invoiced_type == WalletTransaction.name
object = I18n.t('invoices.wallet_credit')
else
object = I18n.t('invoices.cancellation_of_invoice_REF', REF: invoice.invoice.reference)
end
else
case invoice.invoiced_type
when 'Reservation'
@ -115,7 +121,7 @@ module PDF
else ### Reservation
case invoice.invoiced.reservable_type
case invoice.invoiced.try(:reservable_type)
### Machine reservation
when 'Machine'
details += I18n.t('invoices.machine_reservation_DESCRIPTION', DESCRIPTION: item.description)
@ -130,6 +136,9 @@ module PDF
invoice.invoiced.tickets.each do |t|
details += "\n "+I18n.t('invoices.other_rate_ticket', count: t.booked, NAME: t.event_price_category.price_category.name)
end
### wallet credit
when nil
details = item.description
### Other cases (not expected)
else

View File

@ -21,7 +21,7 @@ class WalletService
end
raise ActiveRecord::Rollback
end
return false
false
end
## debit an amount to wallet, if debit success then return a wallet transaction
@ -35,6 +35,27 @@ class WalletService
end
raise ActiveRecord::Rollback
end
return false
false
end
## create a refund invoice associated with the given wallet transaction
def create_avoir(wallet_transaction, avoir_date, description)
avoir = Avoir.new
avoir.type = 'Avoir'
avoir.invoiced = wallet_transaction
avoir.avoir_date = avoir_date
avoir.created_at = avoir_date
avoir.description = description
avoir.avoir_mode = 'wallet'
avoir.subscription_to_expire = false
avoir.user_id = wallet_transaction.wallet.user_id
avoir.total = wallet_transaction.amount * 100.0
avoir.save!
ii = InvoiceItem.new
ii.amount = wallet_transaction.amount * 100.0
ii.description = I18n.t('invoices.wallet_credit')
ii.invoice = avoir
ii.save!
end
end

View File

@ -1,15 +1,15 @@
class CouponDiscountValidator < ActiveModel::Validator
def validate(record)
if !record.percent_off.nil?
unless [0..100].include? record.percent_off
record.errors[:percent_off] << 'Percentage must be included between 0 and 100'
unless (0..100).include? record.percent_off
record.errors[:percent_off] << I18n.t('errors.messages.percentage_out_of_range')
end
elsif !record.amount_off.nil?
unless record.amount_off > 0
record.errors[:amount_off] << I18n.t('errors.messages.greater_than_or_equal_to', count: 0)
end
else
record.errors[:percent_off] << 'cannot be blank when amount_off is blank too'
record.errors[:percent_off] << I18n.t('errors.messages.cannot_be_blank_at_same_time', field: 'amount_off')
end
end
end

View File

@ -0,0 +1,19 @@
class CouponExpirationValidator < ActiveModel::Validator
##
# @param record {Coupon}
##
def validate(record)
previous = record.valid_until_was
current = record.valid_until
unless current.blank?
if current.end_of_day < Time.now
record.errors[:valid_until] << I18n.t('errors.messages.cannot_be_in_the_past')
end
if !previous.blank? and current.end_of_day < previous.end_of_day
record.errors[:valid_until] << I18n.t('errors.messages.cannot_be_before_previous_value')
end
end
end
end

View File

@ -1,3 +1,8 @@
client = Elasticsearch::Client.new host: "http://#{Rails.application.secrets.elaticsearch_host}:9200", log: true
if Rails.env.test?
client = Elasticsearch::Client.new host: "http://#{Rails.application.secrets.elaticsearch_host}:9200", log: false
else
client = Elasticsearch::Client.new host: "http://#{Rails.application.secrets.elaticsearch_host}:9200", log: true
end
Elasticsearch::Model.client = client
Elasticsearch::Persistence.client = client

View File

@ -336,6 +336,7 @@ en:
groups: "Groups"
authentication: "Authentication"
do_you_really_want_to_delete_this_administrator_this_cannot_be_undone: "Do you really want to delete this administrator? This cannot be undone."
this_may_take_a_while_please_wait: "Warning: this may take a while, please be patient."
administrator_successfully_deleted: "Administrator successfully deleted."
unable_to_delete_the_administrator: "Unable to delete the administrator."
add_a_group: "Add a group"

View File

@ -336,6 +336,7 @@ fr:
groups: "Groupes"
authentication: "Authentification"
do_you_really_want_to_delete_this_administrator_this_cannot_be_undone: "Êtes-vous sûr de vouloir supprimer cet administrateur ? Cette opération est irréversible."
this_may_take_a_while_please_wait: "Attention : ceci peut prendre un certain temps, merci de patienter."
administrator_successfully_deleted: "L'administrateur a bien été supprimé."
unable_to_delete_the_administrator: "L'administrateur n'a pas pu être supprimé."
add_a_group: "Ajouter un groupe"

View File

@ -324,6 +324,11 @@ en:
credit_title: 'Credit wallet'
credit_label: 'Set the amount to be credited'
confirm_credit_label: 'Confirm the amount to be credited'
generate_a_refund_invoice: "Generate a refund invoice"
creation_date_for_the_refund: "Creation date for the refund"
creation_date_is_required: "Creation date is required."
description_(optional): "Description (optional):"
will_appear_on_the_refund_invoice: "Will appear on the refund invoice."
to_credit: 'Credit'
wallet_credit_successfully: "Wallet of user is credited successfully."
a_problem_occurred_for_wallet_credit: "A problem is occurred while taking the credit of wallet"

View File

@ -324,6 +324,11 @@ fr:
credit_title: 'Créditer le porte-monnaie'
credit_label: 'Indiquez le montant à créditer'
confirm_credit_label: 'Confirmez le montant à créditer'
generate_a_refund_invoice: "Générer une facture d'avoir"
creation_date_for_the_refund: "Date d'émission de l'avoir"
creation_date_is_required: "La date d'émission est requise."
description_(optional): "Description (optionnelle) :"
will_appear_on_the_refund_invoice: "Apparaîtra sur la facture de remboursement."
to_credit: 'Créditer'
wallet_credit_successfully: "Le porte-monnaie de l'utilisateur a été chargé avec succès."
a_problem_occurred_for_wallet_credit: "Un problème est survenu lors du chargement du porte-monnaie."

View File

@ -32,6 +32,10 @@ en:
size_too_small: "is too small (should be at least %{file_size})"
size_too_big: "is too big (should be at most %{file_size})"
export_not_found: "Requested export was not found. It was probably deleted, please generate a new export."
percentage_out_of_range: "Percentage must be included between 0 and 100"
cannot_be_blank_at_same_time: "cannot be blank when %{field} is blank too"
cannot_be_in_the_past: "cannot be in the past"
cannot_be_before_previous_value: "cannot be before the previous value"
activemodel:
errors:
@ -68,6 +72,7 @@ en:
order_number: "Order #: %{NUMBER}"
invoice_issued_on_DATE: "Invoice issued on %{DATE}"
refund_invoice_issued_on_DATE: "Refund invoice issued on %{DATE}"
wallet_credit: "Wallet credit"
cancellation_of_invoice_REF: "Cancellation of invoice %{REF}"
reservation_of_USER_on_DATE_at_TIME: "Reservation of %{USER} on %{DATE} at %{TIME}"
cancellation: "Cancellation"

View File

@ -32,6 +32,10 @@ fr:
size_too_small: "est trop petite (au moins %{file_size})"
size_too_big: "est trop grande (pas plus de %{file_size})"
export_not_found: "L'export demandé n'a pas été trouvé. Il a probablement été supprimé, veuillez lancer la génération d'un nouvel export."
percentage_out_of_range: "Le pourcentage doit être inclus entre 0 et 100"
cannot_be_blank_at_same_time: "ou %{field} doit être rempli(e)"
cannot_be_in_the_past: "ne peut pas être dans le passé"
cannot_be_before_previous_value: "ne peut pas être antérieur(e) à la valeur précédente"
activemodel:
errors:
@ -68,6 +72,7 @@ fr:
order_number: "N° Commande : %{NUMBER}"
invoice_issued_on_DATE: "Facture éditée le %{DATE}"
refund_invoice_issued_on_DATE: "Avoir édité le %{DATE}"
wallet_credit: "Crédit du porte-monnaie"
cancellation_of_invoice_REF: "Annulation de la facture %{REF}"
reservation_of_USER_on_DATE_at_TIME: "Réservation de %{USER} le %{DATE} à %{TIME}"
cancellation: "Annulation"

View File

@ -1,42 +0,0 @@
class InsertCustomAggregations < ActiveRecord::Migration
def up
# available reservations hours for machines
machine = StatisticIndex.find_by(es_type_key: 'machine')
machine_hours = StatisticType.find_by(key: 'hour', statistic_index_id: machine.id)
available_hours = StatisticCustomAggregation.new({
statistic_type_id: machine_hours.id,
es_index: 'fablab',
es_type: 'availabilities',
field: 'available_hours',
query: '{"size":0, "aggregations":{"%{aggs_name}":{"sum":{"field":"hours_duration"}}}, "query":{"bool":{"must":[{"range":{"start_at":{"gte":"%{start_date}", "lte":"%{end_date}"}}}, {"match":{"available_type":"machines"}}]}}}'
})
available_hours.save!
# available training tickets
training = StatisticIndex.find_by(es_type_key: 'training')
training_bookings = StatisticType.find_by(key: 'booking', statistic_index_id: training.id)
available_tickets = StatisticCustomAggregation.new({
statistic_type_id: training_bookings.id,
es_index: 'fablab',
es_type: 'availabilities',
field: 'available_tickets',
query: '{"size":0, "aggregations":{"%{aggs_name}":{"sum":{"field":"nb_total_places"}}}, "query":{"bool":{"must":[{"range":{"start_at":{"gte":"%{start_date}", "lte":"%{end_date}"}}}, {"match":{"available_type":"training"}}]}}}'
})
available_tickets.save!
end
def down
machine = StatisticIndex.find_by(es_type_key: 'machine')
machine_hours = StatisticType.find_by(key: 'hour', statistic_index_id: machine.id)
StatisticCustomAggregation.where(field: 'available_hours', statistic_type_id: machine_hours.id).first.destroy!
training = StatisticIndex.find_by(es_type_key: 'training')
training_bookings = StatisticType.find_by(key: 'booking', statistic_index_id: training.id)
StatisticCustomAggregation.where(field: 'available_tickets', statistic_type_id: training_bookings.id).first.destroy!
end
end

View File

@ -396,4 +396,30 @@ unless Setting.find_by(name: 'reminder_delay').try(:value)
setting = Setting.find_or_initialize_by(name: 'reminder_delay')
setting.value = '24'
setting.save
end
if StatisticCustomAggregation.count == 0
# available reservations hours for machines
machine_hours = StatisticType.find_by(key: 'hour', statistic_index_id: 2)
available_hours = StatisticCustomAggregation.new({
statistic_type_id: machine_hours.id,
es_index: 'fablab',
es_type: 'availabilities',
field: 'available_hours',
query: '{"size":0, "aggregations":{"%{aggs_name}":{"sum":{"field":"hours_duration"}}}, "query":{"bool":{"must":[{"range":{"start_at":{"gte":"%{start_date}", "lte":"%{end_date}"}}}, {"match":{"available_type":"machines"}}]}}}'
})
available_hours.save!
# available training tickets
training_bookings = StatisticType.find_by(key: 'booking', statistic_index_id: 3)
available_tickets = StatisticCustomAggregation.new({
statistic_type_id: training_bookings.id,
es_index: 'fablab',
es_type: 'availabilities',
field: 'available_tickets',
query: '{"size":0, "aggregations":{"%{aggs_name}":{"sum":{"field":"nb_total_places"}}}, "query":{"bool":{"must":[{"range":{"start_at":{"gte":"%{start_date}", "lte":"%{end_date}"}}}, {"match":{"available_type":"training"}}]}}}'
})
available_tickets.save!
end

View File

@ -125,7 +125,7 @@ Requires=docker.service
[Service]
Type=oneshot
ExecStart=/usr/bin/docker run --rm --name letsencrypt -v "/home/core/fabmanager/log:/var/log/letsencrypt" -v "/home/core/fabmanager/letsencrypt/etc:/etc/letsencrypt" -v "/home/core/fabmanager/letsencrypt/config:/letsencrypt-config" quay.io/letsencrypt/letsencrypt:latest -c "/letsencrypt-config/webroot.ini" certonly
ExecStartPost=-/usr/bin/docker restart fabmanager
ExecStartPost=-/usr/bin/docker restart fabmanager_nginx_1
```
Create file (with sudo) /etc/systemd/system/letsencrypt.timer with
@ -138,6 +138,9 @@ Requires=docker.service
OnCalendar=*-*-1 06:00:00
Persistent=true
Unit=letsencrypt.service
[Install]
WantedBy=timers.target
```
Then deploy your app and read the "Generate SSL certificate by Letsencrypt" section to complete the installation of the letsencrypt certificate.
@ -277,7 +280,9 @@ Remove your app and Run your app to apply changes
Finally, if everything is ok, start letsencrypt timer to update the certificate every 1st of the month :
```bash
sudo systemctl enable letsencrypt.timer
sudo systemctl start letsencrypt.timer
(check) sudo systemctl list-timers
```

View File

@ -1,7 +1,7 @@
module Events
class AsUserTest < ActionDispatch::IntegrationTest
test 'reserve event with many prices and payment means' do
test 'reserve event with many prices and payment means and VAT' do
vlonchamp = User.find_by(username: 'vlonchamp')
login_as(vlonchamp, scope: :user)
@ -15,6 +15,15 @@ module Events
users_credit_count = UsersCredit.count
wallet_transactions_count = WalletTransaction.count
# Enable the VAT at 19.6%
vat_active = Setting.find_by(name: 'invoice_VAT-active')
vat_active.value = 'true'
vat_active.save!
vat_rate = Setting.find_by(name: 'invoice_VAT-rate')
vat_rate.value = '19.6'
vat_rate.save!
# Reserve the 'radio' event
VCR.use_cassette('reserve_event_with_many_prices_and_payment_means') do
post reservations_path, {

View File

@ -0,0 +1,82 @@
module Events
class AsAdminTest < ActionDispatch::IntegrationTest
setup do
admin = User.with_role(:admin).first
login_as(admin, scope: :user)
end
test 'compute price for a simple training' do
user = User.find_by(username: 'jdupond')
availability = Availability.find(2)
printer_training = availability.trainings.first
post '/api/prices/compute',
{
reservation: {
user_id: user.id,
reservable_id: printer_training.id,
reservable_type: printer_training.class.name,
slots_attributes: [
{
availability_id: availability.id,
end_at: availability.end_at,
offered: false,
start_at: availability.start_at
}
]
}
}.to_json,
default_headers
# Check response format & status
assert_equal 200, response.status, response.body
assert_equal Mime::JSON, response.content_type
# Check the price was computed correctly
price = json_response(response.body)
assert_equal (printer_training.trainings_pricings.where(group_id: user.group_id).first.amount / 100.0), price[:price], 'Computed price did not match training price'
end
test 'compute price for a machine reservation with an offered slot and a subscription' do
user = User.find_by(username: 'jdupond')
availability = Availability.find(3)
laser = availability.machines.where(id: 1).first
plan = Plan.where(group_id: user.group_id, interval: 'month').first
post '/api/prices/compute',
{
reservation: {
user_id: user.id,
reservable_id: laser.id,
reservable_type: laser.class.name,
plan_id: plan.id,
slots_attributes: [
{
availability_id: availability.id,
end_at: (availability.start_at + 1.hour).strftime('%Y-%m-%d %H:%M:%S.%9N Z'),
offered: true,
start_at: availability.start_at.strftime('%Y-%m-%d %H:%M:%S.%9N Z')
},
{
availability_id: availability.id,
end_at: (availability.start_at + 2.hour).strftime('%Y-%m-%d %H:%M:%S.%9N Z'),
offered: false,
start_at: (availability.start_at + 1.hour).strftime('%Y-%m-%d %H:%M:%S.%9N Z')
}
]
}
}.to_json,
default_headers
# Check response format & status
assert_equal 200, response.status, response.body
assert_equal Mime::JSON, response.content_type
# Check the event was created correctly
price = json_response(response.body)
assert_equal ((laser.prices.where(group_id: user.group_id, plan_id: plan.id).first.amount + plan.amount) / 100.0), price[:price], 'Computed price did not match machine + subscription price'
end
end
end

View File

@ -74,5 +74,38 @@ class WalletsTest < ActionDispatch::IntegrationTest
w.reload
assert_equal w.amount, expected_amount
assert_equal w.amount, wallet[:amount]
# no refund invoices should have been generated
assert_empty Invoice.where(invoiced: w.wallet_transactions.last)
end
test 'admin credit wallet with refund invoice generation' do
admin = users(:user_1)
login_as(admin, scope: :user)
w = @vlonchamp.wallet
amount = 10
avoir_date = Time.now.end_of_day
expected_amount = w.amount + amount
put "/api/wallet/#{w.id}/credit",
{
amount: amount,
avoir: true,
avoir_date: avoir_date,
avoir_description: 'Some text'
}
assert_equal 200, response.status
assert_equal Mime::JSON, response.content_type
wallet = json_response(response.body)
w.reload
assert_equal w.amount, expected_amount
assert_equal w.amount, wallet[:amount]
# refund invoice must be generated
invoice = Invoice.where(invoiced: w.wallet_transactions.last).first
assert_equal amount, (invoice.total / 100.0), 'Avoir total does not match the amount credited to the wallet'
assert_equal amount, (invoice.invoice_items.first.amount / 100.0), 'Invoice item amount does not match'
assert_invoice_pdf invoice
end
end

View File

@ -1,6 +1,14 @@
require 'test_helper'
class CouponTest < ActiveSupport::TestCase
test 'valid coupon with percentage' do
c = Coupon.new({name: 'Hot deals', code: 'HOT15', percent_off: 15, validity_per_user: 'once', valid_until: (Time.now + 2.weeks), max_usages: 100, active: true})
assert c.valid?
assert_equal 'active', c.status, 'Invalid coupon status'
assert_equal 'percent_off', c.type, 'Invalid coupon type'
end
test 'coupon must have a valid percentage' do
c = Coupon.new({name: 'Amazing deal', code: 'DISCOUNT', percent_off: 200, validity_per_user: 'once'})
assert c.invalid?
@ -16,9 +24,11 @@ class CouponTest < ActiveSupport::TestCase
assert c.invalid?
end
test 'coupon with cash amount has amount_off type' do
c = Coupon.new({name: 'Essential Box', code: 'KWXX2M', amount_off: 2000, validity_per_user: 'once', max_usages: 1})
assert_equal 'amount_off', c.type
test 'valid coupon with cash amount' do
c = Coupon.new({name: 'Essential Box', code: 'KWXX2M', amount_off: 2000, validity_per_user: 'once', max_usages: 1, active: true})
assert c.valid?
assert_equal 'active', c.status, 'Invalid coupon status'
assert_equal 'amount_off', c.type, 'Invalid coupon type'
end
test 'coupon with cash amount cannot be used with cheaper cart' do

View File

@ -1,3 +1,6 @@
require 'coveralls'
Coveralls.wear!('rails')
ENV['RAILS_ENV'] ||= 'test'
require File.expand_path('../../config/environment', __FILE__)
require 'rails/test_help'
@ -68,6 +71,33 @@ class ActiveSupport::TestCase
assert File.exist?(invoice.file), 'Invoice PDF was not generated'
# now we check the file content
reader = PDF::Reader.new(invoice.file)
assert_equal 1, reader.page_count # single page invoice
ht_amount = invoice.total
page = reader.pages.first
lines = page.text.scan(/^.+/)
lines.each do |line|
# check that the numbers printed into the PDF file match the total stored in DB
if line.include? I18n.t('invoices.total_amount')
assert_equal invoice.total / 100.0, parse_amount_from_invoice_line(line), 'Invoice total rendered in the PDF file does not match'
end
# check that the VAT was correctly applied if it was configured
if line.include? I18n.t('invoices.including_total_excluding_taxes')
ht_amount = parse_amount_from_invoice_line(line)
end
end
if Setting.find_by(name: 'invoice_VAT-active').value == 'true'
vat_rate = Setting.find_by({name: 'invoice_VAT-rate'}).value.to_f
computed_ht = sprintf('%.2f', (invoice.total / (vat_rate / 100 + 1)) / 100.0).to_f
assert_equal computed_ht, ht_amount, 'Total excluding taxes rendered in the PDF file is not computed correctly'
else
assert_equal invoice.total, ht_amount, 'VAT information was rendered in the PDF file despite that VAT was disabled'
end
File.delete(invoice.file)
end
@ -89,6 +119,15 @@ class ActiveSupport::TestCase
skip('Unable to test export which is not of the category "statistics"')
end
end
private
# Parse a line of text read from a PDF file and return the price included inside
# Line of text should be of form 'Label $10.00'
# @returns {float}
def parse_amount_from_invoice_line line
line[line.rindex(' ')+1..-1].tr(I18n.t('number.currency.format.unit'), '').to_f
end
end
class ActionDispatch::IntegrationTest