1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-02-20 14:54:15 +01:00

Merge branch 'dev' for release 5.8.2

This commit is contained in:
Sylvain 2023-03-13 14:06:23 +01:00
commit f79f951fa7
66 changed files with 559 additions and 266 deletions

View File

@ -1,5 +1,23 @@
# Changelog Fab-manager
## v5.8.2 2023 March 13
- Improved upgrade script
- Keep usage history of prepaid packs
- OpenAPI reservation endpoint can be filtered by date
- OpenAPI users endpoint now returns the ID of the InvoicingProfile
- Fix a bug: URL validation regex was wrong
- Fix a bug: privileged users cannot order free carts for themselves in the store
- Fix a bug: unable to select a new machine for an existing category
- Fix a bug: wrong counting of minutes used when using a prepaid pack
- Fix a bug: empty advanced accounting code is not defaulted to the general setting
- Fix a bug: invalid style in accounting codes settings
- Fix a bug: wrong namespace for task cart_operator
- Fix a security issue: updated rack to 2.2.6.3 to fix [CVE-2023-27530](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2023-27530)
- [TODO DEPLOY] `rails fablab:fix:cart_operator`
- [TODO DEPLOY] `rails fablab:setup:build_accounting_lines`
- [TODO DEPLOY] `rails fablab:fix:pack_minutes_used`
## v5.8.1 2023 March 03
- Fix a bug: unable to reserve an event

View File

@ -299,7 +299,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.6.1)
rack (2.2.6.2)
rack (2.2.6.3)
rack-oauth2 (1.19.0)
activesupport
attr_required

View File

@ -6,6 +6,9 @@ class API::UserPacksController < API::ApiController
def index
@user_packs = PrepaidPackService.user_packs(user, item)
@history = params[:history] == 'true'
@user_packs = @user_packs.includes(:prepaid_pack_reservations) if @history
end
private

View File

@ -1,9 +1,12 @@
# frozen_string_literal: true
require_relative 'concerns/accountings_filters_concern'
# authorized 3rd party softwares can fetch the accounting lines through the OpenAPI
class OpenAPI::V1::AccountingController < OpenAPI::V1::BaseController
extend OpenAPI::ApiDoc
include Rails::Pagination
include AccountingsFiltersConcern
expose_doc
def index
@ -16,10 +19,10 @@ class OpenAPI::V1::AccountingController < OpenAPI::V1::BaseController
@lines = AccountingLine.order(date: :desc)
.includes(:invoicing_profile, invoice: :payment_gateway_object)
@lines = @lines.where('date >= ?', Time.zone.parse(params[:after])) if params[:after].present?
@lines = @lines.where('date <= ?', Time.zone.parse(params[:before])) if params[:before].present?
@lines = @lines.where(invoice_id: may_array(params[:invoice_id])) if params[:invoice_id].present?
@lines = @lines.where(line_type: may_array(params[:type])) if params[:type].present?
@lines = filter_by_after(@lines, params)
@lines = filter_by_before(@lines, params)
@lines = filter_by_invoice(@lines, params)
@lines = filter_by_line_type(@lines, params)
@lines = @lines.page(page).per(per_page)
paginate @lines, per_page: per_page

View File

@ -0,0 +1,40 @@
# frozen_string_literal: true
# Filter the list of accounting lines by the given parameters
module AccountingsFiltersConcern
extend ActiveSupport::Concern
included do
# @param lines [ActiveRecord::Relation<AccountingLine>]
# @param filters [ActionController::Parameters]
def filter_by_after(lines, filters)
return lines if filters[:after].blank?
lines.where('date >= ?', Time.zone.parse(filters[:after]))
end
# @param lines [ActiveRecord::Relation<AccountingLine>]
# @param filters [ActionController::Parameters]
def filter_by_before(lines, filters)
return lines if filters[:before].blank?
lines.where('date <= ?', Time.zone.parse(filters[:before]))
end
# @param lines [ActiveRecord::Relation<AccountingLine>]
# @param filters [ActionController::Parameters]
def filter_by_invoice(lines, filters)
return lines if filters[:invoice_id].blank?
lines.where(invoice_id: may_array(filters[:invoice_id]))
end
# @param lines [ActiveRecord::Relation<AccountingLine>]
# @param filters [ActionController::Parameters]
def filter_by_line_type(lines, filters)
return lines if filters[:type].blank?
lines.where(line_type: may_array(filters[:type]))
end
end
end

View File

@ -0,0 +1,53 @@
# frozen_string_literal: true
# Filter the list of reservations by the given parameters
module ReservationsFiltersConcern
extend ActiveSupport::Concern
included do
# @param reservations [ActiveRecord::Relation<Reservation>]
# @param filters [ActionController::Parameters]
def filter_by_after(reservations, filters)
return reservations if filters[:after].blank?
reservations.where('reservations.created_at >= ?', Time.zone.parse(filters[:after]))
end
# @param reservations [ActiveRecord::Relation<Reservation>]
# @param filters [ActionController::Parameters]
def filter_by_before(reservations, filters)
return reservations if filters[:before].blank?
reservations.where('reservations.created_at <= ?', Time.zone.parse(filters[:before]))
end
# @param reservations [ActiveRecord::Relation<Reservation>]
# @param filters [ActionController::Parameters]
def filter_by_user(reservations, filters)
return reservations if filters[:user_id].blank?
reservations.where(statistic_profiles: { user_id: may_array(filters[:user_id]) })
end
# @param reservations [ActiveRecord::Relation<Reservation>]
# @param filters [ActionController::Parameters]
def filter_by_reservable_type(reservations, filters)
return reservations if filters[:reservable_type].blank?
reservations.where(reservable_type: format_type(filters[:reservable_type]))
end
# @param reservations [ActiveRecord::Relation<Reservation>]
# @param filters [ActionController::Parameters]
def filter_by_reservable_id(reservations, filters)
return reservations if filters[:reservable_id].blank?
reservations.where(reservable_id: may_array(filters[:reservable_id]))
end
# @param type [String]
def format_type(type)
type.singularize.classify
end
end
end

View File

@ -0,0 +1,40 @@
# frozen_string_literal: true
# Filter the list of subscriptions by the given parameters
module SubscriptionsFiltersConcern
extend ActiveSupport::Concern
included do
# @param subscriptions [ActiveRecord::Relation<Subscription>]
# @param filters [ActionController::Parameters]
def filter_by_after(subscriptions, filters)
return subscriptions if filters[:after].blank?
subscriptions.where('created_at >= ?', Time.zone.parse(filters[:after]))
end
# @param subscriptions [ActiveRecord::Relation<Subscription>]
# @param filters [ActionController::Parameters]
def filter_by_before(subscriptions, filters)
return subscriptions if filters[:before].blank?
subscriptions.where('created_at <= ?', Time.zone.parse(filters[:before]))
end
# @param subscriptions [ActiveRecord::Relation<Subscription>]
# @param filters [ActionController::Parameters]
def filter_by_user(subscriptions, filters)
return subscriptions if filters[:user_id].blank?
subscriptions.where(statistic_profiles: { user_id: may_array(filters[:user_id]) })
end
# @param subscriptions [ActiveRecord::Relation<Subscription>]
# @param filters [ActionController::Parameters]
def filter_by_plan(subscriptions, filters)
return subscriptions if filters[:plan_id].blank?
subscriptions.where(plan_id: may_array(filters[:plan_id]))
end
end
end

View File

@ -1,9 +1,12 @@
# frozen_string_literal: true
require_relative 'concerns/reservations_filters_concern'
# public API controller for resources of type Reservation
class OpenAPI::V1::ReservationsController < OpenAPI::V1::BaseController
extend OpenAPI::ApiDoc
include Rails::Pagination
include ReservationsFiltersConcern
expose_doc
def index
@ -11,9 +14,11 @@ class OpenAPI::V1::ReservationsController < OpenAPI::V1::BaseController
.includes(slots_reservations: :slot, statistic_profile: :user)
.references(:statistic_profiles)
@reservations = @reservations.where(statistic_profiles: { user_id: may_array(params[:user_id]) }) if params[:user_id].present?
@reservations = @reservations.where(reservable_type: format_type(params[:reservable_type])) if params[:reservable_type].present?
@reservations = @reservations.where(reservable_id: may_array(params[:reservable_id])) if params[:reservable_id].present?
@reservations = filter_by_after(@reservations, params)
@reservations = filter_by_before(@reservations, params)
@reservations = filter_by_user(@reservations, params)
@reservations = filter_by_reservable_type(@reservations, params)
@reservations = filter_by_reservable_id(@reservations, params)
@reservations = @reservations.page(page).per(per_page)
paginate @reservations, per_page: per_page
@ -21,10 +26,6 @@ class OpenAPI::V1::ReservationsController < OpenAPI::V1::BaseController
private
def format_type(type)
type.singularize.classify
end
def page
params[:page] || 1
end

View File

@ -1,9 +1,12 @@
# frozen_string_literal: true
require_relative 'concerns/subscriptions_filters_concern'
# authorized 3rd party softwares can fetch the subscriptions through the OpenAPI
class OpenAPI::V1::SubscriptionsController < OpenAPI::V1::BaseController
extend OpenAPI::ApiDoc
include Rails::Pagination
include SubscriptionsFiltersConcern
expose_doc
def index
@ -11,10 +14,10 @@ class OpenAPI::V1::SubscriptionsController < OpenAPI::V1::BaseController
.includes(:plan, statistic_profile: :user)
.references(:statistic_profile, :plan)
@subscriptions = @subscriptions.where('created_at >= ?', Time.zone.parse(params[:after])) if params[:after].present?
@subscriptions = @subscriptions.where('created_at <= ?', Time.zone.parse(params[:before])) if params[:before].present?
@subscriptions = @subscriptions.where(plan_id: may_array(params[:plan_id])) if params[:plan_id].present?
@subscriptions = @subscriptions.where(statistic_profiles: { user_id: may_array(params[:user_id]) }) if params[:user_id].present?
@subscriptions = filter_by_after(@subscriptions, params)
@subscriptions = filter_by_before(@subscriptions, params)
@subscriptions = filter_by_plan(@subscriptions, params)
@subscriptions = filter_by_user(@subscriptions, params)
@subscriptions = @subscriptions.page(page).per(per_page)
paginate @subscriptions, per_page: per_page

View File

@ -15,6 +15,8 @@ class OpenAPI::V1::ReservationsDoc < OpenAPI::V1::BaseDoc
api :GET, "/#{API_VERSION}/reservations", 'Reservations index'
description 'Index of reservations made by users, paginated. Ordered by *created_at* descendant.'
param_group :pagination
param :after, DateTime, optional: true, desc: 'Filter reservations to those created after the given date.'
param :before, DateTime, optional: true, desc: 'Filter reservations to those created before the given date.'
param :user_id, [Integer, Array], optional: true, desc: 'Scope the request to one or various users.'
param :reservable_type, %w[Event Machine Space Training], optional: true, desc: 'Scope the request to a specific type of reservable.'
param :reservable_id, [Integer, Array], optional: true, desc: 'Scope the request to one or various reservables.'

View File

@ -26,6 +26,7 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
"id": 1746,
"email": "xxxxxxx@xxxx.com",
"created_at": "2016-05-04T17:21:48.403+02:00",
"invoicing_profile_id": 7824,
"external_id": "J5821-4"
"full_name": "xxxx xxxx",
"first_name": "xxxx",
@ -43,6 +44,7 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
"id": 1745,
"email": "xxxxxxx@gmail.com",
"created_at": "2016-05-03T15:21:13.125+02:00",
"invoicing_profile_id": 7823,
"external_id": "J5846-4"
"full_name": "xxxxx xxxxx",
"first_name": "xxxxx",
@ -60,6 +62,7 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
"id": 1744,
"email": "xxxxxxx@gmail.com",
"created_at": "2016-05-03T13:51:03.223+02:00",
"invoicing_profile_id": 7822,
"external_id": "J5900-1"
"full_name": "xxxxxxx xxxx",
"first_name": "xxxxxxx",
@ -77,6 +80,7 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
"id": 1743,
"email": "xxxxxxxx@setecastronomy.eu",
"created_at": "2016-05-03T12:24:38.724+02:00",
"invoicing_profile_id": 7821,
"external_id": "P4172-4"
"full_name": "xxx xxxxxxx",
"first_name": "xxx",
@ -100,6 +104,7 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
"id": 1746,
"email": "xxxxxxxxxxxx",
"created_at": "2016-05-04T17:21:48.403+02:00",
"invoicing_profile_id": 7820,
"external_id": "J5500-4"
"full_name": "xxxx xxxxxx",
"first_name": "xxxx",
@ -117,6 +122,7 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
"id": 1745,
"email": "xxxxxxxxx@gmail.com",
"created_at": "2016-05-03T15:21:13.125+02:00",
"invoicing_profile_id": 7819,
"external_id": null,
"full_name": "xxxxx xxxxxx",
"first_name": "xxxx",

View File

@ -3,6 +3,7 @@ import { UseFormRegister, FormState } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { useTranslation } from 'react-i18next';
import { FabOutputCopy } from '../base/fab-output-copy';
import ValidationLib from '../../lib/validation';
interface Oauth2FormProps<TFieldValues> {
register: UseFormRegister<TFieldValues>,
@ -16,10 +17,6 @@ interface Oauth2FormProps<TFieldValues> {
export const Oauth2Form = <TFieldValues extends FieldValues>({ register, strategyName, formState }: Oauth2FormProps<TFieldValues>) => {
const { t } = useTranslation('admin');
// regular expression to validate the input fields
const endpointRegex = /^\/?([-._~:?#[\]@!$&'()*+,;=%\w]+\/?)*$/;
const urlRegex = /^(https?:\/\/)([^.]+)\.(.{2,30})(\/.*)*\/?$/;
/**
* Build the callback URL, based on the strategy name.
*/
@ -35,26 +32,26 @@ export const Oauth2Form = <TFieldValues extends FieldValues>({ register, strateg
register={register}
placeholder="https://sso.example.net..."
label={t('app.admin.authentication.oauth2_form.common_url')}
rules={{ required: true, pattern: urlRegex }}
rules={{ required: true, pattern: ValidationLib.urlRegex }}
formState={formState} />
<FormInput id="providable_attributes.authorization_endpoint"
register={register}
placeholder="/oauth2/auth..."
label={t('app.admin.authentication.oauth2_form.authorization_endpoint')}
rules={{ required: true, pattern: endpointRegex }}
rules={{ required: true, pattern: ValidationLib.endpointRegex }}
formState={formState} />
<FormInput id="providable_attributes.token_endpoint"
register={register}
placeholder="/oauth2/token..."
label={t('app.admin.authentication.oauth2_form.token_acquisition_endpoint')}
rules={{ required: true, pattern: endpointRegex }}
rules={{ required: true, pattern: ValidationLib.endpointRegex }}
formState={formState} />
<FormInput id="providable_attributes.profile_url"
register={register}
placeholder="https://exemple.net/user..."
label={t('app.admin.authentication.oauth2_form.profile_edition_url')}
tooltip={t('app.admin.authentication.oauth2_form.profile_edition_url_help')}
rules={{ required: true, pattern: urlRegex }}
rules={{ required: true, pattern: ValidationLib.urlRegex }}
formState={formState} />
<FormInput id="providable_attributes.client_id"
register={register}

View File

@ -12,6 +12,7 @@ import SsoClient from '../../api/external/sso';
import { FieldPathValue } from 'react-hook-form/dist/types/path';
import { FormMultiSelect } from '../form/form-multi-select';
import { difference } from 'lodash';
import ValidationLib from '../../lib/validation';
interface OpenidConnectFormProps<TFieldValues, TContext extends object> {
register: UseFormRegister<TFieldValues>,
@ -51,10 +52,6 @@ export const OpenidConnectForm = <TFieldValues extends FieldValues, TContext ext
checkForDiscoveryEndpoint({ target: { value: currentFormValues?.issuer } } as React.ChangeEvent<HTMLInputElement>);
}, []);
// regular expression to validate the input fields
const endpointRegex = /^\/?([-._~:?#[\]@!$&'()*+,;=%\w]+\/?)*$/;
const urlRegex = /^(https?:\/\/)([^.]+)\.(.{2,30})(\/.*)*\/?$/;
/**
* If the discovery endpoint is available, the user will be able to choose to use it or not.
* Otherwise, he will need to end the client configuration manually.
@ -109,7 +106,7 @@ export const OpenidConnectForm = <TFieldValues extends FieldValues, TContext ext
label={t('app.admin.authentication.openid_connect_form.issuer')}
placeholder="https://sso.exemple.com"
tooltip={t('app.admin.authentication.openid_connect_form.issuer_help')}
rules={{ required: true, pattern: urlRegex }}
rules={{ required: true, pattern: ValidationLib.urlRegex }}
onChange={checkForDiscoveryEndpoint}
debounce={400}
warning={!discoveryAvailable && { message: t('app.admin.authentication.openid_connect_form.discovery_unavailable') } }
@ -161,7 +158,7 @@ export const OpenidConnectForm = <TFieldValues extends FieldValues, TContext ext
placeholder="https://sso.exemple.com/my-account"
label={t('app.admin.authentication.openid_connect_form.profile_edition_url')}
tooltip={t('app.admin.authentication.openid_connect_form.profile_edition_url_help')}
rules={{ required: false, pattern: urlRegex }}
rules={{ required: false, pattern: ValidationLib.urlRegex }}
formState={formState} />
<h4>{t('app.admin.authentication.openid_connect_form.client_options')}</h4>
<FormInput id="providable_attributes.client__identifier"
@ -178,31 +175,31 @@ export const OpenidConnectForm = <TFieldValues extends FieldValues, TContext ext
<FormInput id="providable_attributes.client__authorization_endpoint"
label={t('app.admin.authentication.openid_connect_form.client__authorization_endpoint')}
placeholder="/authorize"
rules={{ required: !currentFormValues?.discovery, pattern: endpointRegex }}
rules={{ required: !currentFormValues?.discovery, pattern: ValidationLib.endpointRegex }}
formState={formState}
register={register} />
<FormInput id="providable_attributes.client__token_endpoint"
label={t('app.admin.authentication.openid_connect_form.client__token_endpoint')}
placeholder="/token"
rules={{ required: !currentFormValues?.discovery, pattern: endpointRegex }}
rules={{ required: !currentFormValues?.discovery, pattern: ValidationLib.endpointRegex }}
formState={formState}
register={register} />
<FormInput id="providable_attributes.client__userinfo_endpoint"
label={t('app.admin.authentication.openid_connect_form.client__userinfo_endpoint')}
placeholder="/userinfo"
rules={{ required: !currentFormValues?.discovery, pattern: endpointRegex }}
rules={{ required: !currentFormValues?.discovery, pattern: ValidationLib.endpointRegex }}
formState={formState}
register={register} />
{currentFormValues?.client_auth_method === 'jwks' && <FormInput id="providable_attributes.client__jwks_uri"
label={t('app.admin.authentication.openid_connect_form.client__jwks_uri')}
rules={{ required: currentFormValues.client_auth_method === 'jwks', pattern: endpointRegex }}
rules={{ required: currentFormValues.client_auth_method === 'jwks', pattern: ValidationLib.endpointRegex }}
formState={formState}
placeholder="/jwk"
register={register} />}
<FormInput id="providable_attributes.client__end_session_endpoint"
label={t('app.admin.authentication.openid_connect_form.client__end_session_endpoint')}
tooltip={t('app.admin.authentication.openid_connect_form.client__end_session_endpoint_help')}
rules={{ pattern: endpointRegex }}
rules={{ pattern: ValidationLib.endpointRegex }}
formState={formState}
register={register} />
</div>}

View File

@ -41,7 +41,7 @@ const PrepaidPacksPanel: React.FC<PrepaidPacksPanelProps> = ({ user, onError })
const { handleSubmit, control, formState } = useForm<{ machine_id: number }>();
useEffect(() => {
UserPackAPI.index({ user_id: user.id })
UserPackAPI.index({ user_id: user.id, history: true })
.then(setUserPacks)
.catch(onError);
SettingAPI.get('renew_pack_threshold')
@ -106,7 +106,7 @@ const PrepaidPacksPanel: React.FC<PrepaidPacksPanelProps> = ({ user, onError })
*/
const onPackBoughtSuccess = () => {
togglePacksModal();
UserPackAPI.index({ user_id: user.id })
UserPackAPI.index({ user_id: user.id, history: true })
.then(setUserPacks)
.catch(onError);
};
@ -125,19 +125,21 @@ const PrepaidPacksPanel: React.FC<PrepaidPacksPanelProps> = ({ user, onError })
<div className='prepaid-packs-list-item'>
<p className='name'>{pack.prepaid_pack.priceable.name}</p>
{FormatLib.date(pack.expires_at) && <p className="end">{FormatLib.date(pack.expires_at)}</p>}
<p className="countdown"><span>{pack.minutes_used / 60}H</span> / {pack.prepaid_pack.minutes / 60}H</p>
<p className="countdown"><span>{(pack.prepaid_pack.minutes - pack.minutes_used) / 60}H</span> / {pack.prepaid_pack.minutes / 60}H</p>
</div>
</div>
{ /* usage history is not saved for now
{pack.history?.length > 0 &&
<div className="prepaid-packs-list is-history">
<span className='prepaid-packs-list-label'>{t('app.logged.dashboard.reservations_dashboard.prepaid_packs_panel.history')}</span>
<div className='prepaid-packs-list-item'>
<p className='name'>00{t('app.logged.dashboard.reservations_dashboard.prepaid_packs_panel.consumed_hours')}</p>
<p className="date">00/00/00</p>
</div>
{pack.history.map(prepaidReservation => (
<div className='prepaid-packs-list-item' key={prepaidReservation.id}>
<p className='name'>{t('app.logged.dashboard.reservations_dashboard.prepaid_packs_panel.consumed_hours', { COUNT: prepaidReservation.consumed_minutes / 60 })}</p>
<p className="date">{FormatLib.date(prepaidReservation.reservation_date)}</p>
</div>
))}
</div>
*/ }
}
</div>
))}

View File

@ -5,6 +5,7 @@ import { FormSwitch } from '../form/form-switch';
import { FormRichText } from '../form/form-rich-text';
import { FormInput } from '../form/form-input';
import { SettingName, SettingValue } from '../../models/setting';
import ValidationLib from '../../lib/validation';
export type EditorialKeys = 'active_text_block' | 'text_block' | 'active_cta' | 'cta_label' | 'cta_url';
interface EditorialBlockFormProps {
@ -15,9 +16,6 @@ interface EditorialBlockFormProps {
keys: Record<EditorialKeys, SettingName>
}
// regular expression to validate the input fields
const urlRegex = /^(https?:\/\/)([^.]+)\.(.{2,30})(\/.*)*\/?$/;
/**
* Allows to create a formatted text and optional cta button in a form block, to be included in a resource form managed by react-hook-form.
*/
@ -78,7 +76,7 @@ export const EditorialBlockForm: React.FC<EditorialBlockFormProps> = ({ register
formState={formState}
rules={{
required: { value: isActiveCta, message: t('app.admin.editorial_block_form.url_is_required') },
pattern: { value: urlRegex, message: t('app.admin.editorial_block_form.url_must_be_safe') }
pattern: { value: ValidationLib.urlRegex, message: t('app.admin.editorial_block_form.url_must_be_safe') }
}}
label={t('app.admin.editorial_block_form.cta_url')} />
</>}

View File

@ -1,43 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { FabModal } from '../base/fab-modal';
import MachineCategoryAPI from '../../api/machine-category';
interface DeleteMachineCategoryModalProps {
isOpen: boolean,
machineCategoryId: number,
toggleModal: () => void,
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
/**
* Modal dialog to remove a requested machine category
*/
export const DeleteMachineCategoryModal: React.FC<DeleteMachineCategoryModalProps> = ({ isOpen, toggleModal, onSuccess, machineCategoryId, onError }) => {
const { t } = useTranslation('admin');
/**
* The user has confirmed the deletion of the requested machine category
*/
const handleDeleteMachineCategory = async (): Promise<void> => {
try {
await MachineCategoryAPI.destroy(machineCategoryId);
onSuccess(t('app.admin.delete_machine_category_modal.deleted'));
} catch (e) {
onError(t('app.admin.delete_machine_category_modal.unable_to_delete') + e);
}
};
return (
<FabModal title={t('app.admin.delete_machine_category_modal.confirmation_required')}
isOpen={isOpen}
toggleModal={toggleModal}
closeButton={true}
confirmButton={t('app.admin.delete_machine_category_modal.confirm')}
onConfirm={handleDeleteMachineCategory}
className="delete-machine-category-modal">
<p>{t('app.admin.delete_machine_category_modal.confirm_machine_category')}</p>
</FabModal>
);
};

View File

@ -1,15 +1,13 @@
import React, { useEffect, useState } from 'react';
import { MachineCategory } from '../../models/machine-category';
import { Machine } from '../../models/machine';
import { IApplication } from '../../models/application';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import MachineCategoryAPI from '../../api/machine-category';
import MachineAPI from '../../api/machine';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
import { MachineCategoryModal } from './machine-category-modal';
import { DeleteMachineCategoryModal } from './delete-machine-category-modal';
import { EditDestroyButtons } from '../base/edit-destroy-buttons';
declare const Application: IApplication;
@ -26,25 +24,16 @@ export const MachineCategoriesList: React.FC<MachineCategoriesListProps> = ({ on
// shown machine categories
const [machineCategories, setMachineCategories] = useState<Array<MachineCategory>>([]);
// all machines, for assign to category
const [machines, setMachines] = useState<Array<Machine>>([]);
// creation/edition modal
const [modalIsOpen, setModalIsOpen] = useState<boolean>(false);
// currently added/edited category
const [machineCategory, setMachineCategory] = useState<MachineCategory>(null);
// deletion modal
const [destroyModalIsOpen, setDestroyModalIsOpen] = useState<boolean>(false);
// currently deleted machine category
const [machineCategoryId, setMachineCategoryId] = useState<number>(null);
// retrieve the full list of machine categories on component mount
useEffect(() => {
MachineCategoryAPI.index()
.then(setMachineCategories)
.catch(onError);
MachineAPI.index({ category: 'none' })
.then(setMachines)
.catch(onError);
}, []);
/**
@ -59,7 +48,6 @@ export const MachineCategoriesList: React.FC<MachineCategoriesListProps> = ({ on
*/
const onSaveTypeSuccess = (message: string): void => {
setModalIsOpen(false);
MachineAPI.index({ category: 'none' }).then(setMachines).catch(onError);
MachineCategoryAPI.index().then(data => {
setMachineCategories(data);
onSuccess(message);
@ -86,28 +74,10 @@ export const MachineCategoriesList: React.FC<MachineCategoriesListProps> = ({ on
};
};
/**
* Init the process of deleting a machine category (ask for confirmation)
*/
const destroyMachineCategory = (id: number): () => void => {
return (): void => {
setMachineCategoryId(id);
setDestroyModalIsOpen(true);
};
};
/**
* Open/closes the confirmation before deletion modal
*/
const toggleDestroyModal = (): void => {
setDestroyModalIsOpen(!destroyModalIsOpen);
};
/**
* Callback triggred when the current machine category was successfully deleted
*/
const onDestroySuccess = (message: string): void => {
setDestroyModalIsOpen(false);
MachineCategoryAPI.index().then(data => {
setMachineCategories(data);
onSuccess(message);
@ -125,16 +95,10 @@ export const MachineCategoriesList: React.FC<MachineCategoriesListProps> = ({ on
</div>
</header>
<MachineCategoryModal isOpen={modalIsOpen}
machines={machines}
machineCategory={machineCategory}
toggleModal={toggleCreateAndEditModal}
onSuccess={onSaveTypeSuccess}
onError={onError} />
<DeleteMachineCategoryModal isOpen={destroyModalIsOpen}
machineCategoryId={machineCategoryId}
toggleModal={toggleDestroyModal}
onSuccess={onDestroySuccess}
onError={onError}/>
<table className="machine-categories-table">
<thead>
<tr>
@ -155,12 +119,12 @@ export const MachineCategoriesList: React.FC<MachineCategoriesListProps> = ({ on
</td>
<td>
<div className="buttons">
<FabButton className="edit-btn" onClick={editMachineCategory(category)}>
<i className="fa fa-edit" /> {t('app.admin.machine_categories_list.edit')}
</FabButton>
<FabButton className="delete-btn" onClick={destroyMachineCategory(category.id)}>
<i className="fa fa-trash" />
</FabButton>
<EditDestroyButtons onDeleteSuccess={onDestroySuccess}
onError={onError}
onEdit={editMachineCategory(category)}
itemId={category.id}
itemType={t('app.admin.machine_categories_list.machine_category')}
apiDestroy={MachineCategoryAPI.destroy} />
</div>
</td>
</tr>

View File

@ -1,25 +1,36 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FabModal, ModalSize } from '../base/fab-modal';
import { MachineCategory } from '../../models/machine-category';
import { Machine } from '../../models/machine';
import MachineCategoryAPI from '../../api/machine-category';
import { MachineCategoryForm } from './machine-category-form';
import MachineAPI from '../../api/machine';
interface MachineCategoryModalProps {
isOpen: boolean,
toggleModal: () => void,
onSuccess: (message: string) => void,
onError: (message: string) => void,
machines: Array<Machine>,
machineCategory?: MachineCategory,
}
/**
* Modal dialog to create/edit a machine category
*/
export const MachineCategoryModal: React.FC<MachineCategoryModalProps> = ({ isOpen, toggleModal, onSuccess, onError, machines, machineCategory }) => {
export const MachineCategoryModal: React.FC<MachineCategoryModalProps> = ({ isOpen, toggleModal, onSuccess, onError, machineCategory }) => {
const { t } = useTranslation('admin');
// all machines, to assign to the category
const [machines, setMachines] = useState<Array<Machine>>([]);
// retrieve the full list of machines on component mount
useEffect(() => {
if (!isOpen) return;
MachineAPI.index({ category: [machineCategory?.id, 'none'] })
.then(setMachines)
.catch(onError);
}, [isOpen]);
/**
* Save the current machine category to the API

View File

@ -75,7 +75,7 @@ export const ConfigurePacksButton: React.FC<ConfigurePacksButtonProps> = ({ pack
};
/**
* When the user clicks on the edition button, query the full data of the current pack from the API, then open te edition modal
* When the user clicks on the edition button, query the full data of the current pack from the API, then open the edition modal
*/
const handleRequestEdit = (pack: PrepaidPack): void => {
PrepaidPackAPI.get(pack.id)

View File

@ -7,6 +7,7 @@ import Icons from '../../../../images/social-icons.svg';
import { FormInput } from '../form/form-input';
import { Trash } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
import ValidationLib from '../../lib/validation';
interface EditSocialsProps<TFieldValues> {
register: UseFormRegister<TFieldValues>,
@ -21,8 +22,6 @@ interface EditSocialsProps<TFieldValues> {
*/
export const EditSocials = <TFieldValues extends FieldValues>({ register, setValue, networks, formState, disabled }: EditSocialsProps<TFieldValues>) => {
const { t } = useTranslation('shared');
// regular expression to validate the input fields
const urlRegex = /^(https?:\/\/)([^.]+)\.(.{2,30})(\/.*)*\/?$/;
const initSelectedNetworks = networks.filter(el => !['', null, undefined].includes(el.url));
const [selectedNetworks, setSelectedNetworks] = useState(initSelectedNetworks);
@ -72,7 +71,7 @@ export const EditSocials = <TFieldValues extends FieldValues>({ register, setVal
register={register}
rules= {{
pattern: {
value: urlRegex,
value: ValidationLib.urlRegex,
message: t('app.shared.edit_socials.website_invalid')
}
}}

View File

@ -12,6 +12,7 @@ import Icons from '../../../../images/social-icons.svg';
import { Trash } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
import ValidationLib from '../../lib/validation';
declare const Application: IApplication;
@ -26,8 +27,6 @@ interface FabSocialsProps {
*/
export const FabSocials: React.FC<FabSocialsProps> = ({ show = false, onError, onSuccess }) => {
const { t } = useTranslation('shared');
// regular expression to validate the input fields
const urlRegex = /^(https?:\/\/)([\da-z.-]+)\.([-a-z\d.]{2,30})([/\w .-]*)*\/?$/;
const { handleSubmit, register, setValue, formState } = useForm();
@ -109,7 +108,7 @@ export const FabSocials: React.FC<FabSocialsProps> = ({ show = false, onError, o
register={register}
rules={{
pattern: {
value: urlRegex,
value: ValidationLib.urlRegex,
message: t('app.shared.fab_socials.website_invalid')
}
}}

View File

@ -32,6 +32,7 @@ import { ProfileCustomField } from '../../models/profile-custom-field';
import { SettingName } from '../../models/setting';
import SettingAPI from '../../api/setting';
import { SelectOption } from '../../models/select';
import ValidationLib from '../../lib/validation';
declare const Application: IApplication;
@ -55,10 +56,6 @@ interface UserProfileFormProps {
export const UserProfileForm: React.FC<UserProfileFormProps> = ({ action, size, user, operator, className, onError, onSuccess, showGroupInput, showTermsAndConditionsInput, showTrainingsInput, showTagsInput }) => {
const { t } = useTranslation('shared');
// regular expression to validate the input fields
const phoneRegex = /^((00|\+)\d{2,3})?[\d -]{4,14}$/;
const urlRegex = /^(https?:\/\/)([^.]+)\.(.{2,30})(\/.*)*\/?$/;
const { handleSubmit, register, control, formState, setValue, reset } = useForm<User>({ defaultValues: { ...user } });
const output = useWatch<User>({ control });
@ -215,7 +212,7 @@ export const UserProfileForm: React.FC<UserProfileFormProps> = ({ action, size,
register={register}
rules={{
pattern: {
value: phoneRegex,
value: ValidationLib.phoneRegex,
message: t('app.shared.user_profile_form.phone_number_invalid')
},
required: fieldsSettings.get('phone_required') === 'true'
@ -314,7 +311,7 @@ export const UserProfileForm: React.FC<UserProfileFormProps> = ({ action, size,
register={register}
rules={{
pattern: {
value: urlRegex,
value: ValidationLib.urlRegex,
message: t('app.shared.user_profile_form.website_invalid')
}
}}

View File

@ -0,0 +1,6 @@
// Provides regular expressions to validate user inputs
export default class ValidationLib {
static urlRegex = /^(https?:\/\/)(([^.]+)\.)+(.{2,30})(\/.*)*\/?$/;
static endpointRegex = /^\/?([-._~:?#[\]@!$&'()*+,;=%\w]+\/?)*$/;
static phoneRegex = /^((00|\+)\d{2,3})?[\d -]{4,14}$/;
}

View File

@ -5,7 +5,7 @@ import { AdvancedAccounting } from './advanced-accounting';
export interface MachineIndexFilter extends ApiFilter {
disabled?: boolean,
category?: number | 'none'
category?: number | 'none' | Array<number|'none'>
}
export interface Machine {

View File

@ -4,7 +4,8 @@ import { ApiFilter } from './api';
export interface UserPackIndexFilter extends ApiFilter {
user_id: number,
priceable_type?: string,
priceable_id?: number
priceable_id?: number,
history?: boolean
}
export interface UserPack {
@ -17,5 +18,11 @@ export interface UserPack {
priceable: {
name: string
}
}
},
history?: Array<{
id: number,
consumed_minutes: number,
reservation_id: number,
reservation_date: TDateISO
}>
}

View File

@ -1,4 +1,6 @@
.edit-destroy-buttons {
border-radius: var(--border-radius-sm);
overflow: hidden;
button {
@include btn;
border-radius: 0;
@ -12,4 +14,4 @@
}
}
}
}

View File

@ -1,4 +1,4 @@
.events {
.events-list-page {
max-width: 1600px;
margin: 2rem;
padding-bottom: 6rem;

View File

@ -14,18 +14,6 @@
display: flex;
justify-content: flex-end;
align-items: center;
button {
border-radius: 5;
&:hover { opacity: 0.75; }
}
.edit-btn {
color: var(--gray-hard-darkest);
margin-right: 10px;
}
.delete-btn {
color: var(--gray-soft-lightest);
background: var(--main);
}
}
.machine-categories-table {

View File

@ -19,7 +19,7 @@
</div>
<section class="events">
<section class="events-list-page">
<events-editorial-block on-error="onError"></events-editorial-block>
<div class="row">

View File

@ -74,7 +74,7 @@ module ApplicationHelper
def may_array(param)
return param if param.is_a?(Array)
return param unless param&.chars&.first == '[' && param&.chars&.last == ']'
return param unless param.chars&.first == '[' && param.chars&.last == ']'
param.gsub(/[\[\]]/i, '').split(',')
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
# Save the association between a Reservation and a PrepaidPack to keep the usage history.
class PrepaidPackReservation < ApplicationRecord
belongs_to :statistic_profile_prepaid_pack
belongs_to :reservation
end

View File

@ -21,6 +21,8 @@ class Reservation < ApplicationRecord
has_many :invoice_items, as: :object, dependent: :destroy
has_one :payment_schedule_object, as: :object, dependent: :destroy
has_many :prepaid_pack_reservations, dependent: :destroy
validates :reservable_id, :reservable_type, presence: true
validate :machine_not_already_reserved, if: -> { reservable.is_a?(Machine) }
validate :training_not_fully_reserved, if: -> { reservable.is_a?(Training) }

View File

@ -8,6 +8,7 @@ class StatisticProfilePrepaidPack < ApplicationRecord
has_many :invoice_items, as: :object, dependent: :destroy
has_one :payment_schedule_object, as: :object, dependent: :destroy
has_many :prepaid_pack_reservations, dependent: :restrict_with_error
before_create :set_expiration_date

View File

@ -44,7 +44,7 @@ class Accounting::AccountingCodeService
if type == :code
item_code = Setting.get('advanced_accounting') ? invoice_item.object.reservable.advanced_accounting&.send(section) : nil
return Setting.get("accounting_#{invoice_item.object.reservable_type}_code") if item_code.nil? && section == :code
return Setting.get("accounting_#{invoice_item.object.reservable_type}_code") if item_code.blank? && section == :code
item_code
else
@ -58,7 +58,7 @@ class Accounting::AccountingCodeService
if type == :code
item_code = Setting.get('advanced_accounting') ? invoice_item.object.plan.advanced_accounting&.send(section) : nil
return Setting.get('accounting_subscription_code') if item_code.nil? && section == :code
return Setting.get('accounting_subscription_code') if item_code.blank? && section == :code
item_code
else
@ -72,7 +72,7 @@ class Accounting::AccountingCodeService
if type == :code
item_code = Setting.get('advanced_accounting') ? invoice_item.object.orderable.advanced_accounting&.send(section) : nil
return Setting.get('accounting_Product_code') if item_code.nil? && section == :code
return Setting.get('accounting_Product_code') if item_code.blank? && section == :code
item_code
else

View File

@ -17,7 +17,7 @@ class Checkout::PaymentService
CouponService.new.validate(coupon_code, order.statistic_profile.user.id)
amount = debit_amount(order)
amount = debit_amount(order, coupon_code)
if (operator.privileged? && operator != order.statistic_profile.user) || amount.zero?
Payments::LocalService.new.payment(order, coupon_code)
elsif Stripe::Helper.enabled? && payment_id.present?

View File

@ -3,6 +3,8 @@
# Provides methods for Machines
class MachineService
class << self
include ApplicationHelper
# @param filters [ActionController::Parameters]
def list(filters)
sort_by = Setting.get('machines_sort_by') || 'default'
@ -15,7 +17,7 @@ class MachineService
machines = machines.where(deleted_at: nil)
machines = filter_by_disabled(machines, filters)
filter_by_category(machines, filters)
filter_by_categories(machines, filters)
end
private
@ -31,12 +33,10 @@ class MachineService
# @param machines [ActiveRecord::Relation<Machine>]
# @param filters [ActionController::Parameters]
def filter_by_category(machines, filters)
def filter_by_categories(machines, filters)
return machines if filters[:category].blank?
return machines.where(machine_category_id: nil) if filters[:category] == 'none'
machines.where(machine_category_id: filters[:category])
machines.where(machine_category_id: filters[:category].split(',').map { |id| id == 'none' ? nil : id })
end
end
end

View File

@ -7,6 +7,7 @@ class PrepaidPackService
# @option filters [Integer] :group_id
# @option filters [Integer] :priceable_id
# @option filters [String] :priceable_type 'Machine' | 'Space'
# @return [ActiveRecord::Relation<PrepaidPack>]
def list(filters)
packs = PrepaidPack.where(nil)
@ -25,6 +26,7 @@ class PrepaidPackService
# return the not expired packs for the given item bought by the given user
# @param user [User]
# @param priceable [Machine,Space,NilClass]
# @return [ActiveRecord::Relation<StatisticProfilePrepaidPack>]
def user_packs(user, priceable = nil)
sppp = StatisticProfilePrepaidPack.includes(:prepaid_pack)
.references(:prepaid_packs)
@ -60,12 +62,12 @@ class PrepaidPackService
packs = user_packs(user, reservation.reservable).order(minutes_used: :desc)
packs.each do |pack|
pack_available = pack.prepaid_pack.minutes - pack.minutes_used
remaining = pack_available - consumed
remaining = 0 if remaining.negative?
pack_consumed = pack.prepaid_pack.minutes - remaining
pack.update(minutes_used: pack_consumed)
remaining = consumed > pack_available ? 0 : pack_available - consumed
pack.update(minutes_used: pack.prepaid_pack.minutes - remaining)
pack_consumed = consumed > pack_available ? pack_available : consumed
consumed -= pack_consumed
PrepaidPackReservation.create!(statistic_profile_prepaid_pack: pack, reservation: reservation, consumed_minutes: pack_consumed)
end
end

View File

@ -8,4 +8,10 @@ json.array!(@user_packs) do |user_pack|
json.extract! user_pack.prepaid_pack.priceable, :name
end
end
if @history
json.history user_pack.prepaid_pack_reservations do |ppr|
json.extract! ppr, :id, :consumed_minutes, :reservation_id
json.reservation_date ppr.reservation.created_at
end
end
end

View File

@ -5,6 +5,7 @@ json.extract! user.profile, :full_name, :first_name, :last_name if user.associat
json.gender user.statistic_profile.gender ? 'man' : 'woman'
if user.association(:invoicing_profile).loaded?
json.invoicing_profile_id user.invoicing_profile.id
json.external_id user.invoicing_profile.external_id
json.organization !user.invoicing_profile.organization.nil?
json.address user.invoicing_profile.invoicing_address

View File

@ -28,7 +28,7 @@ de:
add_a_machine_category: "Add a machine category"
name: "Name"
machines_number: "Number of machines"
edit: "Edit"
machine_category: "Machine category"
machine_category_modal:
new_machine_category: "New category"
edit_machine_category: "Edit category"
@ -40,12 +40,6 @@ de:
name: "Name of category"
assigning_machines: "Assign machines to this category"
save: "Save"
delete_machine_category_modal:
confirmation_required: "Confirmation required"
confirm: "Confirm"
deleted: "The machine category has been successfully deleted."
unable_to_delete: "Unable to delete the machine category: "
confirm_machine_category: "Do you really want to remove this machine category?"
machine_form:
ACTION_title: "{ACTION, select, create{New} other{Update the}} machine"
watch_out_when_creating_a_new_machine_its_prices_are_initialized_at_0_for_all_subscriptions: "Watch out! When creating a new machine, its prices are initialized at 0 for all subscriptions."

View File

@ -28,7 +28,7 @@ en:
add_a_machine_category: "Add a machine category"
name: "Name"
machines_number: "Number of machines"
edit: "Edit"
machine_category: "Machine category"
machine_category_modal:
new_machine_category: "New category"
edit_machine_category: "Edit category"
@ -40,12 +40,6 @@ en:
name: "Name of category"
assigning_machines: "Assign machines to this category"
save: "Save"
delete_machine_category_modal:
confirmation_required: "Confirmation required"
confirm: "Confirm"
deleted: "The machine category has been successfully deleted."
unable_to_delete: "Unable to delete the machine category: "
confirm_machine_category: "Do you really want to remove this machine category?"
machine_form:
ACTION_title: "{ACTION, select, create{New} other{Update the}} machine"
watch_out_when_creating_a_new_machine_its_prices_are_initialized_at_0_for_all_subscriptions: "Watch out! When creating a new machine, its prices are initialized at 0 for all subscriptions."

View File

@ -28,7 +28,7 @@ es:
add_a_machine_category: "Add a machine category"
name: "Name"
machines_number: "Number of machines"
edit: "Edit"
machine_category: "Machine category"
machine_category_modal:
new_machine_category: "New category"
edit_machine_category: "Edit category"
@ -40,12 +40,6 @@ es:
name: "Name of category"
assigning_machines: "Assign machines to this category"
save: "Save"
delete_machine_category_modal:
confirmation_required: "Confirmation required"
confirm: "Confirm"
deleted: "The machine category has been successfully deleted."
unable_to_delete: "Unable to delete the machine category: "
confirm_machine_category: "Do you really want to remove this machine category?"
machine_form:
ACTION_title: "{ACTION, select, create{New} other{Update the}} machine"
watch_out_when_creating_a_new_machine_its_prices_are_initialized_at_0_for_all_subscriptions: "Watch out! When creating a new machine, its prices are initialized at 0 for all subscriptions."

View File

@ -28,7 +28,7 @@ fr:
add_a_machine_category: "Ajouter une catégorie de machine"
name: "Nom"
machines_number: "Nombre de machines"
edit: "Modifier"
machine_category: "Catégorie de machines"
machine_category_modal:
new_machine_category: "Nouvelle catégorie"
edit_machine_category: "Modifier la catégorie"
@ -40,12 +40,6 @@ fr:
name: "Nom de la catégorie"
assigning_machines: "Affectation des machines à cette catégorie"
save: "Enregistrer"
delete_machine_category_modal:
confirmation_required: "Confirmation requise"
confirm: "Confirmer"
deleted: "La catégorie de machine a bien été supprimée."
unable_to_delete: "Impossible de supprimer la catégorie de machines : "
confirm_machine_category: "Voulez-vous vraiment supprimer cette catégorie de machine ?"
machine_form:
ACTION_title: "{ACTION, select, create{Nouvelle} other{Mettre à jour la}} machine"
watch_out_when_creating_a_new_machine_its_prices_are_initialized_at_0_for_all_subscriptions: "Attention ! Lors de la création d'une machine, ses tarifs de réservation sont initialisés à zéro pour tous les abonnements."

View File

@ -28,7 +28,7 @@
add_a_machine_category: "Add a machine category"
name: "Name"
machines_number: "Number of machines"
edit: "Edit"
machine_category: "Machine category"
machine_category_modal:
new_machine_category: "New category"
edit_machine_category: "Edit category"
@ -40,12 +40,6 @@
name: "Name of category"
assigning_machines: "Assign machines to this category"
save: "Save"
delete_machine_category_modal:
confirmation_required: "Confirmation required"
confirm: "Confirm"
deleted: "The machine category has been successfully deleted."
unable_to_delete: "Unable to delete the machine category: "
confirm_machine_category: "Do you really want to remove this machine category?"
machine_form:
ACTION_title: "{ACTION, select, create{New} other{Update the}} machine"
watch_out_when_creating_a_new_machine_its_prices_are_initialized_at_0_for_all_subscriptions: "Watch out! When creating a new machine, its prices are initialized at 0 for all subscriptions."

View File

@ -28,7 +28,7 @@ pt:
add_a_machine_category: "Add a machine category"
name: "Name"
machines_number: "Number of machines"
edit: "Edit"
machine_category: "Machine category"
machine_category_modal:
new_machine_category: "New category"
edit_machine_category: "Edit category"
@ -40,12 +40,6 @@ pt:
name: "Name of category"
assigning_machines: "Assign machines to this category"
save: "Save"
delete_machine_category_modal:
confirmation_required: "Confirmation required"
confirm: "Confirm"
deleted: "The machine category has been successfully deleted."
unable_to_delete: "Unable to delete the machine category: "
confirm_machine_category: "Do you really want to remove this machine category?"
machine_form:
ACTION_title: "{ACTION, select, create{New} other{Update the}} machine"
watch_out_when_creating_a_new_machine_its_prices_are_initialized_at_0_for_all_subscriptions: "Watch out! When creating a new machine, its prices are initialized at 0 for all subscriptions."

View File

@ -28,7 +28,7 @@ zu:
add_a_machine_category: "crwdns36071:0crwdne36071:0"
name: "crwdns36073:0crwdne36073:0"
machines_number: "crwdns36075:0crwdne36075:0"
edit: "crwdns36077:0crwdne36077:0"
machine_category: "crwdns37393:0crwdne37393:0"
machine_category_modal:
new_machine_category: "crwdns36079:0crwdne36079:0"
edit_machine_category: "crwdns36081:0crwdne36081:0"
@ -40,12 +40,6 @@ zu:
name: "crwdns36091:0crwdne36091:0"
assigning_machines: "crwdns36093:0crwdne36093:0"
save: "crwdns36095:0crwdne36095:0"
delete_machine_category_modal:
confirmation_required: "crwdns36097:0crwdne36097:0"
confirm: "crwdns36099:0crwdne36099:0"
deleted: "crwdns36101:0crwdne36101:0"
unable_to_delete: "crwdns36103:0crwdne36103:0"
confirm_machine_category: "crwdns36825:0crwdne36825:0"
machine_form:
ACTION_title: "crwdns36827:0ACTION={ACTION}crwdne36827:0"
watch_out_when_creating_a_new_machine_its_prices_are_initialized_at_0_for_all_subscriptions: "crwdns36829:0crwdne36829:0"

View File

@ -166,7 +166,7 @@ de:
end: "Expiry date"
countdown: "Countdown"
history: "History"
consumed_hours: "H consumed"
consumed_hours: "{COUNT, plural, =1{1H consumed} other{{COUNT}H consumed}}"
cta_info: "You can buy prepaid hours packs to book machines and benefit from discounts. Choose a machine to buy a corresponding pack."
select_machine: "Select a machine"
cta_button: "Buy a pack"

View File

@ -166,7 +166,7 @@ en:
end: "Expiry date"
countdown: "Countdown"
history: "History"
consumed_hours: "H consumed"
consumed_hours: "{COUNT, plural, =1{1H consumed} other{{COUNT}H consumed}}"
cta_info: "You can buy prepaid hours packs to book machines and benefit from discounts. Choose a machine to buy a corresponding pack."
select_machine: "Select a machine"
cta_button: "Buy a pack"

View File

@ -166,7 +166,7 @@ es:
end: "Expiry date"
countdown: "Countdown"
history: "History"
consumed_hours: "H consumed"
consumed_hours: "{COUNT, plural, =1{1H consumed} other{{COUNT}H consumed}}"
cta_info: "You can buy prepaid hours packs to book machines and benefit from discounts. Choose a machine to buy a corresponding pack."
select_machine: "Select a machine"
cta_button: "Buy a pack"

View File

@ -166,7 +166,7 @@ fr:
end: "Expire le"
countdown: "Décompte"
history: "Historique"
consumed_hours: "H consommée(s)"
consumed_hours: "{COUNT, plural, =1{1H consommée} other{{COUNT}H consommées}}"
cta_info: "Vous pouvez acheter des packs d'heures prépayées pour réserver des machines et bénéficier de réductions. Choisissez une machine pour acheter un pack correspondant."
select_machine: "Sélectionnez une machine"
cta_button: "Acheter un pack"

View File

@ -166,7 +166,7 @@
end: "Expiry date"
countdown: "Countdown"
history: "History"
consumed_hours: "H consumed"
consumed_hours: "{COUNT, plural, =1{1H consumed} other{{COUNT}H consumed}}"
cta_info: "You can buy prepaid hours packs to book machines and benefit from discounts. Choose a machine to buy a corresponding pack."
select_machine: "Select a machine"
cta_button: "Buy a pack"

View File

@ -166,7 +166,7 @@ pt:
end: "Expiry date"
countdown: "Countdown"
history: "History"
consumed_hours: "H consumed"
consumed_hours: "{COUNT, plural, =1{1H consumed} other{{COUNT}H consumed}}"
cta_info: "You can buy prepaid hours packs to book machines and benefit from discounts. Choose a machine to buy a corresponding pack."
select_machine: "Select a machine"
cta_button: "Buy a pack"

View File

@ -166,7 +166,7 @@ zu:
end: "crwdns37057:0crwdne37057:0"
countdown: "crwdns37059:0crwdne37059:0"
history: "crwdns37061:0crwdne37061:0"
consumed_hours: "crwdns37063:0crwdne37063:0"
consumed_hours: "crwdns37395:0COUNT={COUNT}crwdnd37395:0COUNT={COUNT}crwdne37395:0"
cta_info: "crwdns37065:0crwdne37065:0"
select_machine: "crwdns37067:0crwdne37067:0"
cta_button: "crwdns37069:0crwdne37069:0"

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
# From this migration we save the association between a Reservation and a PrepaidPack to keep the
# usage history.
class CreatePrepaidPackReservations < ActiveRecord::Migration[5.2]
def change
create_table :prepaid_pack_reservations do |t|
t.references :statistic_profile_prepaid_pack, foreign_key: true, index: { name: 'index_prepaid_pack_reservations_on_sp_prepaid_pack_id' }
t.references :reservation, foreign_key: true
t.integer :consumed_minutes
t.timestamps
end
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2023_03_02_120458) do
ActiveRecord::Schema.define(version: 2023_03_09_094535) do
# These are extensions that must be enabled in order to support this database
enable_extension "fuzzystrmatch"
@ -739,6 +739,17 @@ ActiveRecord::Schema.define(version: 2023_03_02_120458) do
t.text "description"
end
create_table "plan_limitations", force: :cascade do |t|
t.bigint "plan_id"
t.string "limitable_type"
t.bigint "limitable_id"
t.integer "limit", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["limitable_type", "limitable_id"], name: "index_plan_limitations_on_limitable_type_and_limitable_id"
t.index ["plan_id"], name: "index_plan_limitations_on_plan_id"
end
create_table "plans", id: :serial, force: :cascade do |t|
t.string "name"
t.integer "amount"
@ -758,6 +769,7 @@ ActiveRecord::Schema.define(version: 2023_03_02_120458) do
t.boolean "disabled"
t.boolean "monthly_payment"
t.bigint "plan_category_id"
t.boolean "limiting"
t.index ["group_id"], name: "index_plans_on_group_id"
t.index ["plan_category_id"], name: "index_plans_on_plan_category_id"
end
@ -769,6 +781,16 @@ ActiveRecord::Schema.define(version: 2023_03_02_120458) do
t.index ["plan_id"], name: "index_plans_availabilities_on_plan_id"
end
create_table "prepaid_pack_reservations", force: :cascade do |t|
t.bigint "statistic_profile_prepaid_pack_id"
t.bigint "reservation_id"
t.integer "consumed_minutes"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["reservation_id"], name: "index_prepaid_pack_reservations_on_reservation_id"
t.index ["statistic_profile_prepaid_pack_id"], name: "index_prepaid_pack_reservations_on_sp_prepaid_pack_id"
end
create_table "prepaid_packs", force: :cascade do |t|
t.string "priceable_type"
t.bigint "priceable_id"
@ -1422,7 +1444,10 @@ ActiveRecord::Schema.define(version: 2023_03_02_120458) do
add_foreign_key "payment_schedules", "invoicing_profiles", column: "operator_profile_id"
add_foreign_key "payment_schedules", "statistic_profiles"
add_foreign_key "payment_schedules", "wallet_transactions"
add_foreign_key "plan_limitations", "plans"
add_foreign_key "plans", "plan_categories"
add_foreign_key "prepaid_pack_reservations", "reservations"
add_foreign_key "prepaid_pack_reservations", "statistic_profile_prepaid_packs"
add_foreign_key "prepaid_packs", "groups"
add_foreign_key "prices", "groups"
add_foreign_key "prices", "plans"

View File

@ -294,15 +294,59 @@ namespace :fablab do
Fablab::Application.load_tasks if Rake::Task.tasks.empty?
Rake::Task['fablab:chain:invoices_items'].invoke
end
end
desc '[release 5.7] fix operator of self-bought carts'
task cart_operator: :environment do |_task, _args|
Order.where.not(statistic_profile_id: nil).find_each do |order|
order.update(operator_profile_id: order.user&.invoicing_profile&.id)
desc '[release 5.8.2] fix operator of self-bought carts'
task cart_operator: :environment do |_task, _args|
Order.where.not(statistic_profile_id: nil).find_each do |order|
order.update(operator_profile_id: order.user&.invoicing_profile&.id)
end
Order.where.not(operator_profile_id: nil).find_each do |order|
order.update(statistic_profile_id: order.operator_profile&.user&.statistic_profile&.id)
end
end
Order.where.not(operator_profile_id: nil).find_each do |order|
order.update(statistic_profile_id: order.operator_profile&.user&.statistic_profile&.id)
desc '[release 5.8.2] fix prepaid packs minutes_used'
task pack_minutes_used: :environment do |_task, _args|
StatisticProfilePrepaidPack.find_each do |sppp|
previous_packs = sppp.statistic_profile.statistic_profile_prepaid_packs
.includes(:prepaid_pack)
.where(prepaid_packs: { priceable: sppp.prepaid_pack.priceable })
.where("statistic_profile_prepaid_packs.created_at <= '#{sppp.created_at.utc.strftime('%Y-%m-%d %H:%M:%S.%6N')}'")
.order('statistic_profile_prepaid_packs.created_at')
remaining = {}
previous_packs.each do |pack|
available_minutes = pack.prepaid_pack.minutes
reservations = Reservation.where(reservable: sppp.prepaid_pack.priceable)
.where(statistic_profile_id: sppp.statistic_profile_id)
.where("created_at > '#{pack.created_at.utc.strftime('%Y-%m-%d %H:%M:%S.%6N')}'")
reservations.each do |reservation|
next if available_minutes.zero?
# if the previous pack has not covered all the duration of this reservation, we substract the remaining minutes from the current pack
if remaining[reservation.id]
if remaining[reservation.id] > available_minutes
consumed = available_minutes
remaining[reservation.id] = remaining[reservation.id] - available_minutes
else
consumed = remaining[reservation.id]
remaining.except!(reservation.id)
end
else
# if there was no remaining from the previous pack, we substract the reservation duration from the current pack
reservation_minutes = reservation.slots.map { |slot| (slot.end_at.to_time - slot.start_at.to_time) / 60.0 }.reduce(:+) || 0
if reservation_minutes > available_minutes
consumed = available_minutes
remaining[reservation.id] = reservation_minutes - consumed
else
consumed = reservation_minutes
end
end
available_minutes -= consumed
PrepaidPackReservation.find_or_create_by!(statistic_profile_prepaid_pack: pack, reservation: reservation, consumed_minutes: consumed)
end
pack.update(minutes_used: pack.prepaid_pack.minutes - available_minutes)
end
end
end
end
end

View File

@ -1,6 +1,6 @@
{
"name": "fab-manager",
"version": "5.8.1",
"version": "5.8.2",
"description": "Fab-manager is the FabLab management solution. It provides a comprehensive, web-based, open-source tool to simplify your administrative tasks and your marker's projects.",
"keywords": [
"fablab",

View File

@ -6,8 +6,11 @@ parseparams()
SCRIPTS=()
ENVIRONMENTS=()
PREPROCESSING=()
while getopts "hyt:s:p:c:e:" opt; do
while getopts "hyit:s:p:c:e:" opt; do
case "${opt}" in
i)
IGNORE=true
;;
y)
Y=true
;;
@ -173,20 +176,17 @@ version_check()
VERSION=$(docker-compose exec -T "$SERVICE" cat package.json | jq -r '.version')
fi
target_version
if [ "$TARGET" = 'custom' ]; then return; fi
if [ "$TARGET" = 'custom' ] || [ "$IGNORE" = "true" ]; then return; fi
if verlt "$VERSION" 2.8.3 && verlt 2.8.3 "$TARGET"; then
version_error "v2.8.3 first"
elif verlt "$VERSION" 3.1.2 && verlt 3.1.2 "$TARGET"; then
version_error "v3.1.2 first"
elif verlt "$VERSION" 4.0.4 && verlt 4.0.4 "$TARGET"; then
version_error "v4.0.4 first"
elif verlt "$VERSION" 4.4.6 && verlt 4.4.6 "$TARGET"; then
version_error "v4.4.6 first"
elif verlt "$VERSION" 4.7.14 && verlt 4.7.14 "$TARGET"; then
version_error "v4.7.14 first"
elif verlt "$TARGET" "$VERSION"; then
version_error "a version > $VERSION"
HTTP_CODE=$(curl -I -s -w "%{http_code}\n" -o /dev/null "https://hub.fab-manager.com/api/versions/next_step?version=$VERSION")
if [ "$HTTP_CODE" != 200 ]; then
printf "\n\n\e[91m[ ❌ ] Unable to check the next step version. Please check your internet connection or restart this script providing the \e[1m-i\e[0m\e[91m option\n\e[39m"
exit 3
fi
STEP=$(\curl -sSL "https://hub.fab-manager.com/api/versions/next_step?version=$VERSION" | jq -r '.next_step.semver')
if verlt "$VERSION" "$STEP" && verlt "$STEP" "$TARGET"; then
version_error "$STEP first"
fi
}
@ -262,7 +262,10 @@ restore_tag()
upgrade()
{
[[ "$YES_ALL" = "true" ]] && confirm="y" || read -rp ":: Proceed with upgrading to version $TARGET ? (Y/n) " confirm </dev/tty
local user_target="$TARGET"
if [ "$TARGET" = 'custom' ]; then user_target="$TAG"; fi
[[ "$YES_ALL" = "true" ]] && confirm="y" || read -rp ":: Proceed with upgrading to version $user_target ? (Y/n) " confirm </dev/tty
if [[ "$confirm" = "n" ]]; then exit 2; fi
add_environments
@ -311,7 +314,6 @@ upgrade()
fi
done
docker-compose up -d
restore_tag
docker ps
}
@ -331,6 +333,7 @@ usage()
Options:
-h Print this message and quit
-y Answer yes to all questions
-i Ignore the target version check
-t <string> Force the upgrade to target the specified version
-p <string> Run the preprocessing command (TODO DEPLOY)
-c <string> Provides additional upgrade command, run in the context of the app (TODO DEPLOY)

View File

@ -47,3 +47,11 @@ twentyp:
updated_at: '2021-06-18 14:53:54.770895'
validity_per_user: forever
amount_off:
internal:
name: Internal use
code: INTERNCOUP100
percent_off: 100
valid_until:
max_usages:
active: true
validity_per_user: forever

View File

@ -46,6 +46,19 @@ class OpenApi::ReservationsTest < ActionDispatch::IntegrationTest
assert_equal [3], reservations[:reservations].pluck(:user_id).uniq
end
test 'list all reservations with dates filtering' do
get '/open_api/v1/reservations?after=2012-01-01T00:00:00+02:00&before=2012-12-31T23:59:59+02:00', headers: open_api_headers(@token)
assert_response :success
assert_equal Mime[:json], response.content_type
reservations = json_response(response.body)
assert reservations[:reservations].count.positive?
assert(reservations[:reservations].all? do |line|
date = Time.zone.parse(line[:created_at])
date >= '2012-01-01'.to_date && date <= '2012-12-31'.to_date
end)
end
test 'list all machine reservations for a user' do
get '/open_api/v1/reservations?reservable_type=Machine&user_id=3', headers: open_api_headers(@token)
assert_response :success

View File

@ -20,20 +20,38 @@ class OpenApi::SubscriptionsTest < ActionDispatch::IntegrationTest
test 'list subscriptions with pagination' do
get '/open_api/v1/subscriptions?page=1&per_page=5', headers: open_api_headers(@token)
assert_response :success
assert_equal Mime[:json], response.content_type
subscriptions = json_response(response.body)
assert subscriptions[:subscriptions].count <= 5
end
test 'list all subscriptions for a user' do
get '/open_api/v1/subscriptions?user_id=3', headers: open_api_headers(@token)
assert_response :success
subscriptions = json_response(response.body)
assert_not_empty subscriptions[:subscriptions]
assert_equal [3], subscriptions[:subscriptions].pluck(:user_id).uniq
end
test 'list all subscriptions for a user with pagination' do
get '/open_api/v1/subscriptions?user_id=3&page=1&per_page=5', headers: open_api_headers(@token)
assert_response :success
subscriptions = json_response(response.body)
assert_not_empty subscriptions[:subscriptions]
assert_equal [3], subscriptions[:subscriptions].pluck(:user_id).uniq
assert subscriptions[:subscriptions].count <= 5
end
test 'list all subscriptions for a plan with pagination' do
get '/open_api/v1/subscriptions?plan_id=1&page=1&per_page=5', headers: open_api_headers(@token)
assert_response :success
subscriptions = json_response(response.body)
assert_not_empty subscriptions[:subscriptions]
assert_equal [1], subscriptions[:subscriptions].pluck(:plan_id).uniq
assert subscriptions[:subscriptions].count <= 5
end
end

View File

@ -19,10 +19,14 @@ class OpenApi::UsersTest < ActionDispatch::IntegrationTest
assert_not_nil(users[:users].detect { |u| u[:external_id] == 'J5821-4' })
assert(users[:users].all? { |u| %w[man woman].include?(u[:gender]) })
assert(users[:users].all? { |u| u[:organization] != User.find(u[:id]).invoicing_profile.organization.nil? })
assert(users[:users].all? { |u| u[:invoicing_profile_id].present? })
assert(users[:users].all? { |u| u[:full_name].present? })
assert(users[:users].all? { |u| u[:first_name].present? })
assert(users[:users].all? { |u| u[:last_name].present? })
assert(users[:users].any? { |u| u[:address].present? })
assert(users[:users].all? { |u| u[:group][:id] == User.find(u[:id]).group_id })
assert(users[:users].all? { |u| u[:group][:name].present? })
assert(users[:users].all? { |u| u[:group][:slug].present? })
end
test 'list all users with pagination' do

View File

@ -206,4 +206,51 @@ class Store::AdminOrderForHimselfTest < ActionDispatch::IntegrationTest
assert_equal 403, response.status
end
test 'admin pay a free order with success' do
login_as(@admin, scope: :user)
invoice_count = Invoice.count
invoice_items_count = InvoiceItem.count
post '/api/checkout/payment',
params: {
coupon_code: 'INTERNCOUP100',
order_token: @cart1.token,
customer_id: @admin.id
}.to_json, headers: default_headers
@cart1.reload
# general assertions
assert_equal 200, response.status
assert_equal invoice_count + 1, Invoice.count
assert_equal invoice_items_count + 2, InvoiceItem.count
# invoice_items assertions
invoice_item = InvoiceItem.last
assert invoice_item.check_footprint
# invoice assertions
invoice = Invoice.last
assert_invoice_pdf invoice
assert_not_nil invoice.debug_footprint
assert @cart1.payment_gateway_object.blank?
assert invoice.payment_gateway_object.blank?
assert invoice.total.zero?
assert invoice.check_footprint
# notification
assert_not_empty Notification.where(attached_object: invoice)
assert_equal 'paid', @cart1.state
assert_equal 'local', @cart1.payment_method
assert_equal 0, @cart1.paid_total
activity = @cart1.order_activities.last
assert_equal 'paid', activity.activity_type
assert_equal @admin.invoicing_profile.id, activity.operator_profile_id
end
end

View File

@ -3,7 +3,7 @@
require 'test_helper'
class StatisticProfilePrepaidPackTest < ActiveSupport::TestCase
test 'coupon have a expries date' do
test 'prepaid pack have a expries date' do
prepaid_pack = PrepaidPack.first
user = User.find_by(username: 'jdupond')
p = StatisticProfilePrepaidPack.create!(prepaid_pack: prepaid_pack, statistic_profile: user.statistic_profile)

View File

@ -17,8 +17,16 @@ class AccountingServiceTest < ActionDispatch::IntegrationTest
# enable the VAT
Setting.set('invoice_VAT-active', true)
Setting.set('invoice_VAT-rate', 19.6)
Setting.set('invoice_VAT-rate', '19.6')
# enable advanced accounting on the plan
Setting.set('advanced_accounting', true)
plan.update(advanced_accounting_attributes: {
code: '7021',
analytical_section: 'PL21'
})
# book and pay
post '/api/local_payment/confirm_payment', params: {
customer_id: @vlonchamp.id,
coupon_code: 'GIME3EUR',
@ -73,11 +81,13 @@ class AccountingServiceTest < ActionDispatch::IntegrationTest
assert_not_nil item_machine
assert_equal invoice.main_item.net_amount, item_machine&.credit
assert_equal Setting.get('accounting_sales_journal_code'), item_machine&.journal_code
# Check the subscription line
item_suscription = lines.find { |l| l.account_code == Setting.get('accounting_subscription_code') }
item_suscription = lines.find { |l| l.account_code == '7021' }
assert_not_nil item_suscription
assert_equal invoice.other_items.last.net_amount, item_suscription&.credit
assert_equal Setting.get('accounting_sales_journal_code'), item_suscription&.journal_code
assert_equal '7021', item_suscription&.account_code
# Check the VAT line
vat_service = VatHistoryService.new

View File

@ -37,4 +37,40 @@ class PrepaidPackServiceTest < ActiveSupport::TestCase
minutes_available = PrepaidPackService.minutes_available(@acamus, @machine)
assert_equal minutes_available, 480
end
test 'member has multiple active packs' do
availabilities_service = Availabilities::AvailabilitiesService.new(@acamus)
# user with current pack reserve 8 slots (on 10 available in the pack)
slots = availabilities_service.machines([@machine], @acamus, { start: Time.current, end: 10.days.from_now })
reservation = Reservation.create(
reservable_id: @machine.id,
reservable_type: Machine.name,
slots: [slots[0], slots[1], slots[2], slots[3], slots[4], slots[5], slots[6], slots[7]],
statistic_profile_id: @acamus.statistic_profile.id
)
PrepaidPackService.update_user_minutes(@acamus, reservation)
minutes_available = PrepaidPackService.minutes_available(@acamus, @machine)
assert_equal 120, minutes_available
# user buy a new pack
prepaid_pack = PrepaidPack.first
StatisticProfilePrepaidPack.create!(prepaid_pack: prepaid_pack, statistic_profile: @acamus.statistic_profile)
minutes_available = PrepaidPackService.minutes_available(@acamus, @machine)
assert_equal 720, minutes_available
# user books a new reservation of 4 slots
reservation = Reservation.create(
reservable_id: @machine.id,
reservable_type: Machine.name,
slots: [slots[8], slots[9], slots[10], slots[11]],
statistic_profile_id: @acamus.statistic_profile.id
)
PrepaidPackService.update_user_minutes(@acamus, reservation)
minutes_available = PrepaidPackService.minutes_available(@acamus, @machine)
assert_equal 480, minutes_available
end
end