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

Merge branch 'product-store_dev' into product-store

This commit is contained in:
Du Peng 2022-11-07 18:55:55 +01:00
commit 76a3a5c37c
153 changed files with 4947 additions and 1742 deletions

View File

@ -7,9 +7,9 @@ Layout/LineLength:
Metrics/MethodLength:
Max: 35
Metrics/CyclomaticComplexity:
Max: 13
Max: 14
Metrics/PerceivedComplexity:
Max: 12
Max: 14
Metrics/AbcSize:
Max: 45
Metrics/ClassLength:
@ -34,7 +34,7 @@ Style/AndOr:
EnforcedStyle: conditionals
Style/FormatString:
EnforcedStyle: sprintf
Style/FormatStringToken:
EnforcedStyle: template
Rails/RedundantPresenceValidationOnBelongsTo:
Enabled: false
Rails/UnknownEnv:
Environments: development, test, staging, production

View File

@ -1,5 +1,30 @@
# Changelog Fab-manager
- Allow searching by username (#401)
- Fix a bug: adding a new event without updating the dates results in internal server error (undefined method `div' for nil)
- Fix a bug: portuguese time formatting (#405)
- Fix a bug: admin users groups being overriden by SSO group_id (#404)
- Fix a bug: no statistics on trainings and spaces reservations
- Fix a bug: invalid ventilation for amount coupons
- Fix a bug: invalid VAT for invoices using amount coupons
- Fix a bug: invalid 1 cent rounding for invoices using coupons
- Fix a bug: plans list error when there was no plan for the user's group
- Fix a security issue: updated nokogiri to 1.13.9 to fix [GHSA-2qc6-mcvw-92cw](https://github.com/advisories/GHSA-2qc6-mcvw-92cw)
- [TODO DEPLOY] `rails fablab:maintenance:regenerate_statistics[2021,6]`
- [TODO DEPLOY] `rails fablab:setup:set_admins_group`
## v5.4.25 2022 October 19
- Fix a bug: unable apply a coupon if this coupon has used by an user removed
- Improved automated test on prepaid pack
## v5.4.24 2022 October 14
- Fix a bug: unable debit hours of prepaid pack after a reservation of machine
## v5.4.23 2022 October 12
- Fix a bug: unable to build docker image
- Fablab's store module
- Fix a bug: missing translations in PayZen configuration screens
- Fix a bug: wrong translation key prevents the display of the schedule deadline's payment mean

View File

@ -17,6 +17,7 @@ RUN apk update && apk upgrade && \
libc-dev \
ruby-dev \
zlib-dev \
xz \
xz-dev \
postgresql-dev \
postgresql-client \

View File

@ -236,7 +236,7 @@ GEM
multi_xml (0.6.0)
multipart-post (2.1.1)
nio4r (2.5.8)
nokogiri (1.13.8)
nokogiri (1.13.9)
mini_portile2 (~> 2.8.0)
racc (~> 1.4)
notify_with (0.0.2)

View File

@ -35,7 +35,7 @@ class API::AdminsController < API::ApiController
def admin_params
params.require(:admin).permit(
:username, :email,
:username, :email, :group_id,
profile_attributes: %i[first_name last_name phone],
invoicing_profile_attributes: [address_attributes: [:address]],
statistic_profile_attributes: %i[gender birthday]

View File

@ -32,7 +32,7 @@ class API::CartController < API::ApiController
end
def set_offer
authorize @current_order, policy_class: CartPolicy
authorize CartContext.new(params[:customer_id], cart_params[:is_offered])
@order = Cart::SetOfferService.new.call(@current_order, orderable, cart_params[:is_offered])
render 'api/orders/show'
end

View File

@ -6,7 +6,7 @@ class API::GroupsController < API::ApiController
before_action :authenticate_user!, except: :index
def index
@groups = GroupService.list(current_user, params)
@groups = GroupService.list(params)
end
def create

View File

@ -157,7 +157,7 @@ class API::MembersController < API::ApiController
end
def search
@members = Members::ListService.search(current_user, params[:query], params[:subscription], params[:include_admins])
@members = Members::ListService.search(current_user, params[:query], params[:subscription])
end
def mapping

View File

@ -8,7 +8,7 @@ class API::PaymentSchedulesController < API::ApiController
# retrieve all payment schedules for the current user, paginated
def index
@payment_schedules = PaymentSchedule.where('invoicing_profile_id = ?', current_user.invoicing_profile.id)
@payment_schedules = PaymentSchedule.where(invoicing_profile_id: current_user.invoicing_profile.id)
.includes(:invoicing_profile, :payment_schedule_items, :payment_schedule_objects)
.joins(:invoicing_profile)
.order('payment_schedules.created_at DESC')
@ -34,14 +34,14 @@ class API::PaymentSchedulesController < API::ApiController
def download
authorize @payment_schedule
send_file File.join(Rails.root, @payment_schedule.file), type: 'application/pdf', disposition: 'attachment'
send_file Rails.root.join(@payment_schedule.file), type: 'application/pdf', disposition: 'attachment'
end
def cash_check
authorize @payment_schedule_item.payment_schedule
PaymentScheduleService.new.generate_invoice(@payment_schedule_item, payment_method: 'check')
attrs = { state: 'paid', payment_method: 'check' }
@payment_schedule_item.update_attributes(attrs)
@payment_schedule_item.update(attrs)
render json: attrs, status: :ok
end
@ -50,7 +50,7 @@ class API::PaymentSchedulesController < API::ApiController
authorize @payment_schedule_item.payment_schedule
PaymentScheduleService.new.generate_invoice(@payment_schedule_item, payment_method: 'transfer')
attrs = { state: 'paid', payment_method: 'transfer' }
@payment_schedule_item.update_attributes(attrs)
@payment_schedule_item.update(attrs)
render json: attrs, status: :ok
end

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -24,7 +24,7 @@ export default class CartAPI {
}
static async setOffer (order: Order, orderableId: number, isOffered: boolean): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/set_offer', { order_token: order.token, orderable_id: orderableId, is_offered: isOffered });
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/set_offer', { order_token: order.token, orderable_id: orderableId, is_offered: isOffered, customer_id: order.user?.id });
return res?.data;
}

View File

@ -5,7 +5,7 @@ import ApiLib from '../lib/api';
export default class OrderAPI {
static async index (filters?: OrderIndexFilter): Promise<OrderIndex> {
const res: AxiosResponse<OrderIndex> = await apiClient.get(`/api/orders${ApiLib.filtersToQuery(filters)}`);
const res: AxiosResponse<OrderIndex> = await apiClient.get(`/api/orders${ApiLib.filtersToQuery(filters, false)}`);
return res?.data;
}

View File

@ -12,7 +12,7 @@ import ProductLib from '../lib/product';
export default class ProductAPI {
static async index (filters?: ProductIndexFilter): Promise<ProductsIndex> {
const res: AxiosResponse<ProductsIndex> = await apiClient.get(`/api/products${ApiLib.filtersToQuery(ProductLib.indexFiltersToIds(filters))}`);
const res: AxiosResponse<ProductsIndex> = await apiClient.get(`/api/products${ApiLib.filtersToQuery(ProductLib.indexFiltersToIds(filters), false)}`);
return res?.data;
}

View File

@ -101,9 +101,9 @@ const FabTextEditor: React.ForwardRefRenderFunction<FabTextEditorRef, FabTextEdi
<div className={`fab-text-editor ${disabled ? 'is-disabled' : ''}`}>
<MenuBar editor={editor} heading={heading} bulletList={bulletList} blockquote={blockquote} video={video} image={image} link={link} disabled={disabled} />
<EditorContent editor={editor} />
<div className="fab-text-editor-character-count">
{limit && <div className="fab-text-editor-character-count">
{editor?.storage.characterCount.characters()} / {limit}
</div>
</div>}
{error &&
<div className="fab-text-editor-error">
<WarningOctagon size={24} />

View File

@ -181,7 +181,14 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
* Change cart's customer by admin/manger
*/
const handleChangeMember = (user: User): void => {
setCart({ ...cart, user: { id: user.id, role: 'member' } });
// if the selected user is the operator, he cannot offer products to himself
if (user.id === currentUser.id && cart.order_items_attributes.filter(item => item.is_offered).length > 0) {
Promise.all(cart.order_items_attributes.filter(item => item.is_offered).map(item => {
return CartAPI.setOffer(cart, item.orderable_id, false);
})).then((data) => setCart({ ...data[data.length - 1], user: { id: user.id, role: user.role } }));
} else {
setCart({ ...cart, user: { id: user.id, role: user.role } });
}
};
/**
@ -205,7 +212,13 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
return (checked: boolean) => {
CartAPI.setOffer(cart, item.orderable_id, checked).then(data => {
setCart(data);
}).catch(onError);
}).catch(e => {
if (e.match(/code 403/)) {
onError(t('app.public.store_cart.errors.unauthorized_offering_product'));
} else {
onError(e);
}
});
};
};

View File

@ -4,17 +4,13 @@ import { Path } from 'react-hook-form';
import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
import { FieldPathValue } from 'react-hook-form/dist/types/path';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FormInput } from '../form/form-input';
import { FormInput } from './form-input';
import { FormComponent } from '../../models/form-component';
import { AbstractFormItemProps } from './abstract-form-item';
import { FabButton } from '../base/fab-button';
import { FilePdf, Trash } from 'phosphor-react';
export interface FileType {
id?: number,
attachment_name?: string,
attachment_url?: string
}
import { FileType } from '../../models/file';
import FileUploadLib from '../../lib/file-upload';
interface FormFileUploadProps<TFieldValues> extends FormComponent<TFieldValues>, AbstractFormItemProps<TFieldValues> {
setValue: UseFormSetValue<TFieldValues>,
@ -36,7 +32,7 @@ export const FormFileUpload = <TFieldValues extends FieldValues>({ id, register,
* Check if file is selected
*/
const hasFile = (): boolean => {
return !!file?.attachment_name;
return FileUploadLib.hasFile(file);
};
/**
@ -49,7 +45,7 @@ export const FormFileUpload = <TFieldValues extends FieldValues>({ id, register,
attachment_name: f.name
});
setValue(
`${id}[_destroy]` as Path<TFieldValues>,
`${id}._destroy` as Path<TFieldValues>,
false as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
if (typeof onFileChange === 'function') {
@ -62,20 +58,7 @@ export const FormFileUpload = <TFieldValues extends FieldValues>({ id, register,
* Callback triggered when the user clicks on the delete button.
*/
function onRemoveFile () {
if (file?.id) {
setValue(
`${id}[_destroy]` as Path<TFieldValues>,
true as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
}
setValue(
`${id}[attachment_files]` as Path<TFieldValues>,
null as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
setFile(null);
if (typeof onFileRemove === 'function') {
onFileRemove();
}
FileUploadLib.onRemoveFile(file, id, setFile, setValue, onFileRemove);
}
// Compose classnames from props

View File

@ -1,24 +1,19 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Path } from 'react-hook-form';
import { Path, Controller } from 'react-hook-form';
import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
import { FieldPathValue } from 'react-hook-form/dist/types/path';
import { FieldPath, FieldPathValue } from 'react-hook-form/dist/types/path';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FormInput } from '../form/form-input';
import { FormComponent } from '../../models/form-component';
import { FormInput } from './form-input';
import { FormComponent, FormControlledComponent } from '../../models/form-component';
import { AbstractFormItemProps } from './abstract-form-item';
import { FabButton } from '../base/fab-button';
import noImage from '../../../../images/no_image.png';
import { Trash } from 'phosphor-react';
import { ImageType } from '../../models/file';
import FileUploadLib from '../../lib/file-upload';
export interface ImageType {
id?: number,
attachment_name?: string,
attachment_url?: string,
is_main?: boolean
}
interface FormImageUploadProps<TFieldValues> extends FormComponent<TFieldValues>, AbstractFormItemProps<TFieldValues> {
interface FormImageUploadProps<TFieldValues, TContext extends object> extends FormComponent<TFieldValues>, FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
setValue: UseFormSetValue<TFieldValues>,
defaultImage?: ImageType,
accept?: string,
@ -26,13 +21,13 @@ interface FormImageUploadProps<TFieldValues> extends FormComponent<TFieldValues>
mainOption?: boolean,
onFileChange?: (value: ImageType) => void,
onFileRemove?: () => void,
onFileIsMain?: () => void,
onFileIsMain?: (setIsMain: () => void) => void,
}
/**
* This component allows to upload image, in forms managed by react-hook-form.
*/
export const FormImageUpload = <TFieldValues extends FieldValues>({ id, register, defaultImage, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue, size, onFileIsMain, mainOption = false }: FormImageUploadProps<TFieldValues>) => {
export const FormImageUpload = <TFieldValues extends FieldValues, TContext extends object>({ id, label, register, control, defaultImage, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue, size, onFileIsMain, mainOption = false }: FormImageUploadProps<TFieldValues, TContext>) => {
const { t } = useTranslation('shared');
const [file, setFile] = useState<ImageType>(defaultImage);
@ -46,7 +41,7 @@ export const FormImageUpload = <TFieldValues extends FieldValues>({ id, register
* Check if image is selected
*/
const hasImage = (): boolean => {
return !!file?.attachment_name;
return FileUploadLib.hasFile(file);
};
/**
@ -65,12 +60,11 @@ export const FormImageUpload = <TFieldValues extends FieldValues>({ id, register
attachment_name: f.name
});
setValue(
`${id}[attachment_name]` as Path<TFieldValues>,
f.name as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
setValue(
`${id}[_destroy]` as Path<TFieldValues>,
false as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
id as Path<TFieldValues>,
{
attachment_name: f.name,
_destroy: false
} as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
if (typeof onFileChange === 'function') {
onFileChange({ attachment_name: f.name });
@ -82,20 +76,7 @@ export const FormImageUpload = <TFieldValues extends FieldValues>({ id, register
* Callback triggered when the user clicks on the delete button.
*/
function onRemoveFile () {
if (file?.id) {
setValue(
`${id}[_destroy]` as Path<TFieldValues>,
true as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
}
setValue(
`${id}[attachment_files]` as Path<TFieldValues>,
null as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
setFile(null);
if (typeof onFileRemove === 'function') {
onFileRemove();
}
FileUploadLib.onRemoveFile(file, id, setFile, setValue, onFileRemove);
}
/**
@ -103,44 +84,44 @@ export const FormImageUpload = <TFieldValues extends FieldValues>({ id, register
*/
const placeholder = (): string => hasImage() ? t('app.shared.form_image_upload.edit') : t('app.shared.form_image_upload.browse');
/**
* Callback triggered when the user set the image is main
*/
function setMainImage () {
setValue(
`${id}[is_main]` as Path<TFieldValues>,
true as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
onFileIsMain();
}
// Compose classnames from props
const classNames = [
`${className || ''}`
].join(' ');
return (
<div className={`form-image-upload form-image-upload--${size} ${classNames}`}>
<div className={`form-image-upload form-image-upload--${size} ${label ? 'with-label' : ''} ${classNames}`}>
<div className={`image image--${size}`}>
<img src={image || noImage} />
<img src={hasImage() ? image : noImage} alt={file?.attachment_name || 'no image'} onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = noImage;
}} />
</div>
<div className="actions">
{mainOption &&
<label className='fab-button'>
{t('app.shared.form_image_upload.main_image')}
<input type="radio" checked={!!file?.is_main} onChange={setMainImage} />
<Controller name={`${id}.is_main` as FieldPath<TFieldValues>}
control={control}
render={({ field: { onChange, value } }) =>
<input id={`${id}.is_main`}
type="radio"
checked={value}
onChange={() => { onFileIsMain(onChange); }} />
} />
</label>
}
<FormInput className="image-file-input"
type="file"
accept={accept}
register={register}
label={label}
formState={formState}
rules={rules}
disabled={disabled}
error={error}
warning={warning}
id={`${id}[attachment_files]`}
id={`${id}.attachment_files`}
onChange={onFileSelected}
placeholder={placeholder()}/>
{hasImage() && <FabButton onClick={onRemoveFile} icon={<Trash size={20} weight="fill" />} className="is-main" />}
@ -148,3 +129,7 @@ export const FormImageUpload = <TFieldValues extends FieldValues>({ id, register
</div>
);
};
FormImageUpload.defaultProps = {
size: 'medium'
};

View File

@ -0,0 +1,48 @@
import React, { ReactNode } from 'react';
import { FormFileUpload } from './form-file-upload';
import { FabButton } from '../base/fab-button';
import { Plus } from 'phosphor-react';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FormComponent, FormControlledComponent } from '../../models/form-component';
import { AbstractFormItemProps } from './abstract-form-item';
import { UseFormSetValue } from 'react-hook-form/dist/types/form';
import { ArrayPath, FieldArray, useFieldArray } from 'react-hook-form';
import { FileType } from '../../models/file';
import { UnpackNestedValue } from 'react-hook-form/dist/types';
interface FormMultiFileUploadProps<TFieldValues, TContext extends object> extends FormComponent<TFieldValues>, FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
setValue: UseFormSetValue<TFieldValues>,
addButtonLabel: ReactNode,
accept: string
}
/**
* This component allows to upload multiple files, in forms managed by react-hook-form.
*/
export const FormMultiFileUpload = <TFieldValues extends FieldValues, TContext extends object>({ id, className, register, control, setValue, formState, addButtonLabel, accept }: FormMultiFileUploadProps<TFieldValues, TContext>) => {
const { fields, append, remove } = useFieldArray({ control, name: id as ArrayPath<TFieldValues> });
return (
<div className={`form-multi-file-upload ${className || ''}`}>
<div className="list">
{fields.map((field: FileType, index) => (
<FormFileUpload key={field.id}
defaultFile={field}
id={`${id}.${index}`}
accept={accept}
register={register}
setValue={setValue}
formState={formState}
className={field._destroy ? 'hidden' : ''}
onFileRemove={() => remove(index)}/>
))}
</div>
<FabButton
onClick={() => append({ _destroy: false } as UnpackNestedValue<FieldArray<TFieldValues, ArrayPath<TFieldValues>>>)}
className='is-secondary'
icon={<Plus size={24} />}>
{addButtonLabel}
</FabButton>
</div>
);
};

View File

@ -0,0 +1,102 @@
import React, { ReactNode } from 'react';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FormComponent, FormControlledComponent } from '../../models/form-component';
import { AbstractFormItemProps } from './abstract-form-item';
import { UseFormSetValue } from 'react-hook-form/dist/types/form';
import { ArrayPath, FieldArray, Path, useFieldArray, useWatch } from 'react-hook-form';
import { FormImageUpload } from './form-image-upload';
import { FabButton } from '../base/fab-button';
import { Plus } from 'phosphor-react';
import { ImageType } from '../../models/file';
import { UnpackNestedValue } from 'react-hook-form/dist/types';
import { FieldPathValue } from 'react-hook-form/dist/types/path';
interface FormMultiImageUploadProps<TFieldValues, TContext extends object> extends FormComponent<TFieldValues>, FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
setValue: UseFormSetValue<TFieldValues>,
addButtonLabel: ReactNode
}
/**
* This component allows to upload multiple images, in forms managed by react-hook-form.
*/
export const FormMultiImageUpload = <TFieldValues extends FieldValues, TContext extends object>({ id, className, register, control, setValue, formState, addButtonLabel }: FormMultiImageUploadProps<TFieldValues, TContext>) => {
const { fields, append, remove } = useFieldArray({ control, name: id as ArrayPath<TFieldValues> });
const output = useWatch({ control, name: id as Path<TFieldValues> });
/**
* Add new image, set as main if it is the first
*/
const addImage = () => {
append({
is_main: output.filter(i => i.is_main).length === 0,
_destroy: false
} as UnpackNestedValue<FieldArray<TFieldValues, ArrayPath<TFieldValues>>>);
};
/**
* Remove an image and set the first image as the new main image if the provided was main
*/
const handleRemoveImage = (image: ImageType, index: number) => {
return () => {
if (image.is_main && output.length > 1) {
setValue(
`${id}.${index === 0 ? 1 : 0}.is_main` as Path<TFieldValues>,
true as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
}
if (typeof image.id === 'string') {
remove(index);
} else {
setValue(
`${id}.${index}._destroy` as Path<TFieldValues>,
true as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
}
};
};
/**
* Set the image at the given index as the new main image, and unset the current main image
*/
const handleSetMainImage = (index: number) => {
return (setNewImageValue) => {
const mainImageIndex = output.findIndex(i => i.is_main && i !== index);
if (mainImageIndex > -1) {
setValue(
`${id}.${mainImageIndex}.is_main` as Path<TFieldValues>,
false as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
}
setNewImageValue(true);
};
};
return (
<div className={`form-multi-image-upload ${className || ''}`}>
<div className="list">
{fields.map((field: ImageType, index) => (
<FormImageUpload key={field.id}
defaultImage={field}
id={`${id}.${index}`}
accept="image/*"
size="small"
register={register}
control={control}
setValue={setValue}
formState={formState}
className={field._destroy ? 'hidden' : ''}
onFileRemove={handleRemoveImage(field, index)}
onFileIsMain={handleSetMainImage(index)}
mainOption
/>
))}
</div>
<FabButton
onClick={addImage}
className='is-secondary'
icon={<Plus size={24} />}>
{addButtonLabel}
</FabButton>
</div>
);
};

View File

@ -38,7 +38,7 @@ export const ChangeGroup: React.FC<ChangeGroupProps> = ({ user, onSuccess, onErr
const { handleSubmit, control } = useForm();
useEffect(() => {
GroupAPI.index({ disabled: false, admins: user?.role === 'admin' }).then(setGroups).catch(onError);
GroupAPI.index({ disabled: false }).then(setGroups).catch(onError);
MemberAPI.current().then(setOperator).catch(onError);
SettingAPI.get('user_change_group').then((setting) => {
setAllowedUserChangeGoup(setting.value === 'true');

View File

@ -44,12 +44,12 @@ const MachineCard: React.FC<MachineCardProps> = ({ user, machine, onShowMachine,
* Return the machine's picture or a placeholder
*/
const machinePicture = (): ReactNode => {
if (!machine.machine_image) {
if (!machine.machine_image_attributes) {
return <div className="machine-picture no-picture" />;
}
return (
<div className="machine-picture" style={{ backgroundImage: `url(${machine.machine_image})` }} onClick={handleShowMachine} />
<div className="machine-picture" style={{ backgroundImage: `url(${machine.machine_image_attributes.attachment_url}), url('/default-image.png')` }} onClick={handleShowMachine} />
);
};

View File

@ -0,0 +1,89 @@
import React from 'react';
import { SubmitHandler, useForm, useWatch } from 'react-hook-form';
import { Machine } from '../../models/machine';
import MachineAPI from '../../api/machine';
import { useTranslation } from 'react-i18next';
import { FormInput } from '../form/form-input';
import { FormImageUpload } from '../form/form-image-upload';
import { IApplication } from '../../models/application';
import { Loader } from '../base/loader';
import { react2angular } from 'react2angular';
import { ErrorBoundary } from '../base/error-boundary';
import { FormRichText } from '../form/form-rich-text';
import { FormSwitch } from '../form/form-switch';
declare const Application: IApplication;
interface MachineFormProps {
action: 'create' | 'update',
machine?: Machine,
onError: (message: string) => void,
onSuccess: (message: string) => void,
}
/**
* Form to edit or create machines
*/
export const MachineForm: React.FC<MachineFormProps> = ({ action, machine, onError, onSuccess }) => {
const { handleSubmit, register, control, setValue, formState } = useForm<Machine>({ defaultValues: { ...machine } });
const output = useWatch<Machine>({ control });
const { t } = useTranslation('admin');
/**
* Callback triggered when the user validates the machine form: handle create or update
*/
const onSubmit: SubmitHandler<Machine> = (data: Machine) => {
MachineAPI[action](data).then(() => {
onSuccess(t(`app.admin.machine_form.${action}_success`));
}).catch(error => {
onError(error);
});
};
return (
<form className="machine-form" onSubmit={handleSubmit(onSubmit)}>
<FormInput register={register} id="name"
formState={formState}
rules={{ required: true }}
label={t('app.admin.machine_form.name')} />
<FormImageUpload setValue={setValue}
register={register}
control={control}
formState={formState}
rules={{ required: true }}
id="machine_image_attributes"
accept="image/*"
defaultImage={output.machine_image_attributes}
label={t('app.admin.machine_form.illustration')} />
<FormRichText control={control}
id="description"
rules={{ required: true }}
label={t('app.admin.machine_form.description')}
limit={null}
heading bulletList blockquote link video image />
<FormRichText control={control}
id="spec"
rules={{ required: true }}
label={t('app.admin.machine_form.technical_specifications')}
limit={null}
heading bulletList blockquote link video image />
<FormSwitch control={control}
id="disabled"
label={t('app.admin.machine_form.disable_machine')}
tooltip={t('app.admin.machine_form.disabled_help')} />
</form>
);
};
const MachineFormWrapper: React.FC<MachineFormProps> = (props) => {
return (
<Loader>
<ErrorBoundary>
<MachineForm {...props} />
</ErrorBoundary>
</Loader>
);
};
Application.Components.component('machineForm', react2angular(MachineFormWrapper, ['action', 'machine', 'onError', 'onSuccess']));

View File

@ -100,10 +100,10 @@ export const MachinesList: React.FC<MachinesListProps> = ({ onError, onSuccess,
);
};
const MachinesListWrapper: React.FC<MachinesListProps> = ({ user, onError, onSuccess, onShowMachine, onReserveMachine, onLoginRequested, onEnrollRequested, canProposePacks }) => {
const MachinesListWrapper: React.FC<MachinesListProps> = (props) => {
return (
<Loader>
<MachinesList user={user} onError={onError} onSuccess={onSuccess} onShowMachine={onShowMachine} onReserveMachine={onReserveMachine} onLoginRequested={onLoginRequested} onEnrollRequested={onEnrollRequested} canProposePacks={canProposePacks}/>
<MachinesList {...props} />
</Loader>
);
};

View File

@ -34,7 +34,7 @@ export const PlansFilter: React.FC<PlansFilterProps> = ({ user, groups, onGroupS
* Convert all groups to the react-select format
*/
const buildGroupOptions = (): Array<SelectOption<number>> => {
return groups.filter(g => !g.disabled && g.slug !== 'admins').map(g => {
return groups.filter(g => !g.disabled).map(g => {
return { value: g.id, label: g.name };
});
};

View File

@ -235,7 +235,7 @@ export const PlansList: React.FC<PlansListProps> = ({ onError, onPlanSelection,
{plans && Array.from(filteredPlans()).map(([groupId, plansByGroup]) => {
return (
<div key={groupId} className="plans-per-group">
{plansByGroup.size > 0 && <h2 className="group-title">{ groupName(groupId) }</h2>}
{plansByGroup?.size > 0 && <h2 className="group-title">{ groupName(groupId) }</h2>}
{plansByGroup && renderPlansByCategory(plansByGroup)}
</div>
);

View File

@ -41,7 +41,7 @@ export const MachinesPricing: React.FC<MachinesPricingProps> = ({ onError, onSuc
MachineAPI.index({ disabled: false })
.then(data => setMachines(data))
.catch(error => onError(error));
GroupAPI.index({ disabled: false, admins: false })
GroupAPI.index({ disabled: false })
.then(data => setGroups(data))
.catch(error => onError(error));
PriceAPI.index({ priceable_type: 'Machine', plan_id: null })

View File

@ -38,7 +38,7 @@ export const SpacesPricing: React.FC<SpacesPricingProps> = ({ onError, onSuccess
SpaceAPI.index()
.then(data => setSpaces(data))
.catch(error => onError(error));
GroupAPI.index({ disabled: false, admins: false })
GroupAPI.index({ disabled: false })
.then(data => setGroups(data))
.catch(error => onError(error));
PriceAPI.index({ priceable_type: 'Space', plan_id: null })

View File

@ -10,20 +10,19 @@ import { FormSwitch } from '../form/form-switch';
import { FormSelect } from '../form/form-select';
import { FormChecklist } from '../form/form-checklist';
import { FormRichText } from '../form/form-rich-text';
import { FormFileUpload } from '../form/form-file-upload';
import { FormImageUpload } from '../form/form-image-upload';
import { FabButton } from '../base/fab-button';
import { FabAlert } from '../base/fab-alert';
import ProductCategoryAPI from '../../api/product-category';
import MachineAPI from '../../api/machine';
import ProductAPI from '../../api/product';
import { Plus } from 'phosphor-react';
import { ProductStockForm } from './product-stock-form';
import { CloneProductModal } from './clone-product-modal';
import ProductLib from '../../lib/product';
import { UnsavedFormAlert } from '../form/unsaved-form-alert';
import { UIRouter } from '@uirouter/angularjs';
import { SelectOption, ChecklistOption } from '../../models/select';
import { FormMultiFileUpload } from '../form/form-multi-file-upload';
import { FormMultiImageUpload } from '../form/form-multi-image-upload';
interface ProductFormProps {
product: Product,
@ -149,101 +148,6 @@ export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSucc
setOpenCloneModal(!openCloneModal);
};
/**
* Add new product file
*/
const addProductFile = () => {
setValue('product_files_attributes', output.product_files_attributes.concat({}));
};
/**
* Remove a product file
*/
const handleRemoveProductFile = (i: number) => {
return () => {
const productFile = output.product_files_attributes[i];
if (!productFile.id) {
output.product_files_attributes.splice(i, 1);
setValue('product_files_attributes', output.product_files_attributes);
}
};
};
/**
* Add new product image
*/
const addProductImage = () => {
setValue('product_images_attributes', output.product_images_attributes.concat({
is_main: output.product_images_attributes.filter(i => i.is_main).length === 0
}));
};
/**
* Remove a product image
*/
const handleRemoveProductImage = (i: number) => {
return () => {
const productImage = output.product_images_attributes[i];
if (!productImage.id) {
output.product_images_attributes.splice(i, 1);
if (productImage.is_main) {
setValue('product_images_attributes', output.product_images_attributes.map((image, k) => {
if (k === 0) {
return {
...image,
is_main: true
};
}
return image;
}));
} else {
setValue('product_images_attributes', output.product_images_attributes);
}
} else {
if (productImage.is_main) {
let mainImage = false;
setValue('product_images_attributes', output.product_images_attributes.map((image, k) => {
if (i !== k && !mainImage) {
mainImage = true;
return {
...image,
_destroy: i === k,
is_main: true
};
}
return {
...image,
is_main: i === k ? false : image.is_main,
_destroy: i === k
};
}));
}
}
};
};
/**
* Remove main image in others product images
*/
const handleSetMainImage = (i: number) => {
return () => {
if (output.product_images_attributes.length > 1) {
setValue('product_images_attributes', output.product_images_attributes.map((image, k) => {
if (i !== k) {
return {
...image,
is_main: false
};
}
return {
...image,
is_main: true
};
}));
}
};
};
return (
<>
<header>
@ -336,31 +240,12 @@ export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSucc
<FabAlert level="warning">
<HtmlTranslate trKey="app.admin.store.product_form.product_images_info" />
</FabAlert>
<div className="product-images">
<div className="list">
{output.product_images_attributes.map((image, i) => (
<FormImageUpload key={i}
defaultImage={image}
id={`product_images_attributes[${i}]`}
accept="image/*"
size="small"
<FormMultiImageUpload setValue={setValue}
addButtonLabel={t('app.admin.store.product_form.add_product_image')}
register={register}
setValue={setValue}
formState={formState}
className={image._destroy ? 'hidden' : ''}
mainOption={true}
onFileRemove={handleRemoveProductImage(i)}
onFileIsMain={handleSetMainImage(i)}
/>
))}
</div>
<FabButton
onClick={addProductImage}
className='is-secondary'
icon={<Plus size={24} />}>
{t('app.admin.store.product_form.add_product_image')}
</FabButton>
</div>
control={control}
id="product_images_attributes"
className="product-images" />
</div>
<hr />
@ -413,27 +298,13 @@ export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSucc
<FabAlert level="warning">
<HtmlTranslate trKey="app.admin.store.product_form.product_files_info" />
</FabAlert>
<div className="product-documents">
<div className="list">
{output.product_files_attributes.map((file, i) => (
<FormFileUpload key={i}
defaultFile={file}
id={`product_files_attributes[${i}]`}
accept="application/pdf"
register={register}
setValue={setValue}
formState={formState}
className={file._destroy ? 'hidden' : ''}
onFileRemove={handleRemoveProductFile(i)}/>
))}
</div>
<FabButton
onClick={addProductFile}
className='is-secondary'
icon={<Plus size={24} />}>
{t('app.admin.store.product_form.add_product_file')}
</FabButton>
</div>
<FormMultiFileUpload setValue={setValue}
addButtonLabel={t('app.admin.store.product_form.add_product_file')}
control={control}
accept="application/pdf"
register={register}
id="product_files_attributes"
className="product-documents" />
</div>
<div className="main-actions">

View File

@ -45,7 +45,7 @@ const SupportingDocumentsTypesList: React.FC<SupportingDocumentsTypesListProps>
// get groups
useEffect(() => {
GroupAPI.index({ disabled: false, admins: false }).then(data => {
GroupAPI.index({ disabled: false }).then(data => {
setGroups(data);
ProofOfIdentityTypeAPI.index().then(pData => {
setSupportingDocumentsTypes(pData);

View File

@ -45,7 +45,7 @@ export const ChangeRoleModal: React.FC<ChangeRoleModalProps> = ({ isOpen, toggle
const [selectedRole, setSelectedRole] = useState<UserRole>(user.role);
useEffect(() => {
GroupAPI.index({ disabled: false, admins: false }).then(setGroups).catch(onError);
GroupAPI.index({ disabled: false }).then(setGroups).catch(onError);
}, []);
/**

View File

@ -72,7 +72,7 @@ export const UserProfileForm: React.FC<UserProfileFormProps> = ({ action, size,
setIsLocalDatabaseProvider(data.providable_type === 'DatabaseProvider');
}).catch(error => onError(error));
if (showGroupInput) {
GroupAPI.index({ disabled: false, admins: user.role === 'admin' }).then(data => {
GroupAPI.index({ disabled: false }).then(data => {
setGroups(buildOptions(data));
}).catch(error => onError(error));
}
@ -150,11 +150,6 @@ export const UserProfileForm: React.FC<UserProfileFormProps> = ({ action, size,
* Check if the given field path should be disabled
*/
const isDisabled = function (id: string) {
// never allows admins to change their group
if (id === 'group_id' && user.role === 'admin') {
return true;
}
// if the current provider is the local database, then all fields are enabled
if (isLocalDatabaseProvider) {
return false;

View File

@ -584,7 +584,7 @@ Application.Controllers.controller('NewEventController', ['$scope', '$state', 'C
end_date: new Date(),
start_time: new Date(),
end_time: new Date(),
all_day: 'false',
all_day: true,
recurrence: 'none',
category_id: null,
prices: []

View File

@ -38,7 +38,7 @@
class MembersController {
constructor ($scope, $state, Group, Training) {
// Retrieve the profiles groups (e.g. students ...)
Group.query(function (groups) { $scope.groups = groups.filter(function (g) { return (g.slug !== 'admins') && !g.disabled; }); });
Group.query(function (groups) { $scope.groups = groups.filter(function (g) { return !g.disabled; }); });
// Retrieve the list of available trainings
Training.query().$promise.then(function (data) {
@ -1118,8 +1118,8 @@ Application.Controllers.controller('ImportMembersResultController', ['$scope', '
/**
* Controller used in the admin creation page (admin view)
*/
Application.Controllers.controller('NewAdminController', ['$state', '$scope', 'Admin', 'growl', '_t', 'settingsPromise',
function ($state, $scope, Admin, growl, _t, settingsPromise) {
Application.Controllers.controller('NewAdminController', ['$state', '$scope', 'Admin', 'growl', '_t', 'settingsPromise', 'groupsPromise',
function ($state, $scope, Admin, growl, _t, settingsPromise, groupsPromise) {
// default admin profile
let getGender;
$scope.admin = {
@ -1145,6 +1145,9 @@ Application.Controllers.controller('NewAdminController', ['$state', '$scope', 'A
// is the address required in _admin_form?
$scope.addressRequired = (settingsPromise.address_required === 'true');
// all available groups
$scope.groups = groupsPromise;
/**
* Shows the birthday datepicker
*/
@ -1208,7 +1211,7 @@ Application.Controllers.controller('NewManagerController', ['$state', '$scope',
};
// list of all groups
$scope.groups = groupsPromise.filter(function (g) { return (g.slug !== 'admins') && !g.disabled; });
$scope.groups = groupsPromise.filter(function (g) { return !g.disabled; });
// list of all tags
$scope.tags = tagsPromise;

View File

@ -27,7 +27,7 @@ class PlanController {
// groups list
$scope.groups = groups
.filter(function (g) { return (g.slug !== 'admins') && !g.disabled; })
.filter(function (g) { return !g.disabled; })
.map(e => Object.assign({}, e, { category: 'app.shared.plan.groups', id: `${e.id}` }));
$scope.groups.push({ id: 'all', name: 'app.shared.plan.transversal_all_groups', category: 'app.shared.plan.all' });

View File

@ -30,8 +30,8 @@ Application.Controllers.controller('EditPricingController', ['$scope', '$state',
$scope.enabledPlans = plans.filter(function (p) { return !p.disabled; });
// List of groups (eg. normal, student ...)
$scope.groups = groups.filter(function (g) { return g.slug !== 'admins'; });
$scope.enabledGroups = groups.filter(function (g) { return (g.slug !== 'admins') && !g.disabled; });
$scope.groups = groups;
$scope.enabledGroups = groups.filter(function (g) { return !g.disabled; });
// List of all plan-categories
$scope.planCategories = planCategories;

View File

@ -118,9 +118,7 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
// retrieve the groups (standard, student ...)
Group.query(function (groups) {
$scope.groups = groups;
$scope.enabledGroups = groups.filter(function (g) {
return (g.slug !== 'admins') && !g.disabled;
});
$scope.enabledGroups = groups.filter(g => !g.disabled);
});
// retrieve the CGU

View File

@ -263,7 +263,21 @@ Application.Controllers.controller('EditMachineController', ['$scope', '$state',
$scope.method = 'put';
// Retrieve the details for the machine id in the URL, if an error occurs redirect the user to the machines list
$scope.machine = machinePromise;
$scope.machine = cleanMachine(machinePromise);
/**
* Shows an error message forwarded from a child component
*/
$scope.onError = function (message) {
growl.error(message);
}
/**
* Shows a success message forwarded from a child react components
*/
$scope.onSuccess = function (message) {
growl.success(message)
}
/* PRIVATE SCOPE */
@ -277,6 +291,13 @@ Application.Controllers.controller('EditMachineController', ['$scope', '$state',
return new MachinesController($scope, $state);
};
// prepare the machine for the react-hook-form
function cleanMachine (machine) {
delete machine.$promise;
delete machine.$resolved;
return machine;
}
// !!! MUST BE CALLED AT THE END of the controller
return initialize();
}
@ -403,7 +424,7 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$tran
// the moment when the slot selection changed for the last time, used to trigger changes in the cart
$scope.selectionTime = null;
// the last clicked event in the calender
// the last clicked event in the calendar
$scope.selectedEvent = null;
// the application global settings

View File

@ -161,7 +161,7 @@ Application.Controllers.controller('EditProfileController', ['$scope', '$rootSco
* Check if it is allowed the change the group of the current user
*/
$scope.isAllowedChangingGroup = function () {
return !$scope.user.subscribed_plan?.name && $scope.user.role !== 'admin';
return !$scope.user.subscribed_plan?.name;
};
/**

View File

@ -231,7 +231,7 @@ class ProjectsController {
const asciiName = Diacritics.remove(nameLookup);
Member.search(
{ query: asciiName, include_admins: 'true' },
{ query: asciiName },
function (users) { $scope.matchingMembers = users; },
function (error) { console.error(error); }
);

View File

@ -291,13 +291,11 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
};
/**
* Check if the currently logged user has the 'admin' role OR the 'manager' role, but is not taking reseravtion for himself
* Check if the currently logged user has the 'admin' OR 'manager' role, but is not taking reseravtion for himself
* @returns {boolean}
*/
$scope.isAuthorized = function () {
if (AuthService.isAuthorized('admin')) return true;
if (AuthService.isAuthorized('manager')) {
if (AuthService.isAuthorized(['admin', 'manager'])) {
return ($rootScope.currentUser.id !== $scope.user.id);
}
@ -823,11 +821,10 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
return Wallet.getWalletByUser({ user_id: $scope.user.id }, function (wallet) {
const amountToPay = helpers.getAmountToPay($scope.amountTotal, wallet.amount);
if ((AuthService.isAuthorized(['member']) && (amountToPay > 0 || (amountToPay === 0 && hasOtherDeadlines()))) ||
(AuthService.isAuthorized('manager') && $scope.user.id === $rootScope.currentUser.id && amountToPay > 0)) {
($scope.user.id === $rootScope.currentUser.id && amountToPay > 0)) {
return payOnline(items);
} else {
if (AuthService.isAuthorized(['admin']) ||
(AuthService.isAuthorized('manager') && $scope.user.id !== $rootScope.currentUser.id) ||
if (AuthService.isAuthorized(['admin', 'manager'] && $scope.user.id !== $rootScope.currentUser.id) ||
(amountToPay === 0 && !hasOtherDeadlines())) {
return payOnSite(items);
}

View File

@ -2,11 +2,11 @@ import _ from 'lodash';
import { ApiFilter } from '../models/api';
export default class ApiLib {
static filtersToQuery (filters?: ApiFilter): string {
static filtersToQuery (filters?: ApiFilter, keepNullValues = true): string {
if (!filters) return '';
return '?' + Object.entries(filters)
.filter(filter => !_.isNil(filter[1]))
.filter(filter => keepNullValues || !_.isNil(filter[1]))
.map(filter => `${filter[0]}=${filter[1]}`)
.join('&');
}

View File

@ -0,0 +1,29 @@
import { Path } from 'react-hook-form';
import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
import { FieldPathValue } from 'react-hook-form/dist/types/path';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FileType } from '../models/file';
import { Dispatch, SetStateAction } from 'react';
export default class FileUploadLib {
public static onRemoveFile<TFieldValues extends FieldValues> (file: FileType, id: string, setFile: Dispatch<SetStateAction<FileType>>, setValue: UseFormSetValue<TFieldValues>, onFileRemove: () => void) {
if (file?.id) {
setValue(
`${id}._destroy` as Path<TFieldValues>,
true as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
}
setValue(
`${id}.attachment_files` as Path<TFieldValues>,
null as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
setFile(null);
if (typeof onFileRemove === 'function') {
onFileRemove();
}
}
public static hasFile (file: FileType): boolean {
return file?.attachment_name && !file?._destroy;
}
}

View File

@ -13,9 +13,7 @@ export default class UserLib {
* Check if the current user has privileged access for resources concerning the provided customer
*/
isPrivileged = (customer: User): boolean => {
if (this.user?.role === 'admin') return true;
if (this.user?.role === 'manager') {
if (this.user?.role === 'admin' || this.user?.role === 'manager') {
return (this.user?.id !== customer.id);
}

View File

@ -0,0 +1,10 @@
export interface FileType {
id?: number|string,
attachment_name?: string,
attachment_url?: string,
_destroy?: boolean
}
export interface ImageType extends FileType {
is_main?: boolean
}

View File

@ -1,8 +1,7 @@
import { ApiFilter } from './api';
export interface GroupIndexFilter extends ApiFilter {
disabled?: boolean,
admins?: boolean,
disabled?: boolean
}
export interface Group {

View File

@ -1,23 +1,20 @@
import { Reservation } from './reservation';
import { ApiFilter } from './api';
import { FileType } from './file';
export interface MachineIndexFilter extends ApiFilter {
disabled: boolean,
}
export interface Machine {
id: number,
id?: number,
name: string,
description?: string,
spec?: string,
disabled: boolean,
slug: string,
machine_image: string,
machine_files_attributes?: Array<{
id: number,
attachment: string,
attachment_url: string
}>,
machine_image_attributes: FileType,
machine_files_attributes?: Array<FileType>,
trainings?: Array<{
id: number,
name: string,

View File

@ -73,8 +73,8 @@ export const stockMovementAllReasons = [...stockMovementInReasons, ...stockMovem
export type StockMovementReason = typeof stockMovementAllReasons[number];
export interface Stock {
internal: number,
external: number,
internal?: number,
external?: number,
}
export type ProductsIndex = PaginatedIndex<Product>;
@ -99,21 +99,21 @@ export interface StockMovementIndexFilter extends ApiFilter {
export interface Product {
id?: number,
name: string,
slug: string,
name?: string,
slug?: string,
sku?: string,
description?: string,
is_active: boolean,
is_active?: boolean,
product_category_id?: number,
is_active_price?: boolean,
amount?: number,
quantity_min?: number,
stock: Stock,
low_stock_alert: boolean,
stock?: Stock,
low_stock_alert?: boolean,
low_stock_threshold?: number,
machine_ids: number[],
machine_ids?: number[],
created_at?: TDateISO,
product_files_attributes: Array<{
product_files_attributes?: Array<{
id?: number,
attachment?: File,
attachment_files?: FileList,
@ -121,7 +121,7 @@ export interface Product {
attachment_url?: string,
_destroy?: boolean
}>,
product_images_attributes: Array<{
product_images_attributes?: Array<{
id?: number,
attachment?: File,
attachment_files?: FileList,

View File

@ -1084,7 +1084,8 @@ angular.module('application.router', ['ui.router'])
}
},
resolve: {
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['phone_required', 'address_required']" }).$promise; }]
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['phone_required', 'address_required']" }).$promise; }],
groupsPromise: ['Group', function (Group) { return Group.query({ disabled: false }).$promise; }]
}
})
.state('app.admin.managers_new', {

View File

@ -41,6 +41,8 @@
@import "modules/events/event";
@import "modules/form/abstract-form-item";
@import "modules/form/form-input";
@import "modules/form/form-multi-file-upload";
@import "modules/form/form-multi-image-upload";
@import "modules/form/form-rich-text";
@import "modules/form/form-select";
@import "modules/form/form-switch";

View File

@ -14,6 +14,12 @@
@include base;
}
&.with-label {
margin-top: 2.6rem;
position: relative;
margin-bottom: 1.6rem;
}
.image {
flex-shrink: 0;
display: flex;
@ -51,5 +57,11 @@
.image-file-input {
margin-bottom: 0;
}
.form-item-header {
position: absolute;
top: -1.5em;
left: 0;
margin-bottom: 0.8rem;
}
}
}

View File

@ -0,0 +1,11 @@
.form-multi-file-upload {
display: flex;
flex-direction: column;
.list {
margin-bottom: 2.4rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
gap: 2.4rem;
}
button { margin-left: auto; }
}

View File

@ -0,0 +1,11 @@
.form-multi-image-upload {
display: flex;
flex-direction: column;
.list {
margin-bottom: 2.4rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
gap: 2.4rem;
}
button { margin-left: auto; }
}

View File

@ -49,18 +49,8 @@
border-top-right-radius: 5px;
position: relative;
&.no-picture::before {
position: absolute;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
content: '\f03e';
font-family: 'Font Awesome 5 Free' !important;
font-weight: 900;
font-size: 80px;
color: #ebebeb;
&.no-picture {
background-image: url('../../../../images/default-image.png');
}
}

View File

@ -50,7 +50,7 @@
flex: 1 1 320px;
}
}
.header-switch {
display: flex;
flex-direction: row;
@ -65,18 +65,4 @@
gap: 0 3.2rem;
align-items: flex-end;
}
.product-images,
.product-documents {
display: flex;
flex-direction: column;
.list {
margin-bottom: 2.4rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
gap: 2.4rem;
}
button { margin-left: auto; }
}
}
}

View File

@ -37,7 +37,7 @@
<i class="fa fa-times"></i>
</button>
</form>
<div class="buttons" ng-hide="rowform.$visible || group.slug === 'admins'">
<div class="buttons" ng-hide="rowform.$visible">
<button class="btn btn-default" ng-click="rowform.$show()">
<i class="fa fa-edit"></i> <span class="hidden-xs hidden-sm" translate>{{ 'app.shared.buttons.edit' }}</span>
</button>

View File

@ -31,6 +31,9 @@
<td>{{ admin.email }}</td>
<td>{{ admin.profile_attributes.phone }}</td>
<td>
<button class="btn btn-default edit-member" ui-sref="app.admin.members_edit({id: admin.id})">
<i class="fa fa-edit"></i>
</button>
<button class="btn btn-danger" ng-if="isAuthorized('admin') && admin.id != currentUser.id" ng-click="destroyAdmin(admins, admin)">
<i class="fa fa-trash-o"></i>
</button>

View File

@ -24,6 +24,11 @@
<div class="row no-gutter">
<div class="col-sm-12 col-md-12 col-lg-9 b-r-lg nopadding">
<!-- <div class="panel panel-default bg-light m-lg">-->
<!-- <div class="panel-body m-r">-->
<!-- <machine-form action="'update'" machine="machine" on-error="onError" on-success="onSuccess"></machine-form>-->
<!-- </div>-->
<!-- </div>-->
<ng-include src="'/machines/_form.html'"></ng-include>
</div>
</div>

View File

@ -119,6 +119,19 @@
ng-required="phoneRequired">
</div>
</div>
<div class="form-group" ng-class="{'has-error': adminForm['admin[group_id]'].$dirty && adminForm['admin[group_id]'].$invalid}">
<div class="input-group">
<span class="input-group-addon"><i class="fa fa-group"></i> <span class="exponent"><i class="fa fa-asterisk" aria-hidden="true"></i></span></span>
<select ng-model="admin.group_id"
name="admin[group_id]"
class="form-control"
id="group_id"
required
ng-options="g.id as g.name for g in groups">
</select>
</div>
</div>
</div>
</div>

View File

@ -19,7 +19,7 @@ class AccountingPeriod < ApplicationRecord
validates_with PeriodOverlapValidator
validates_with PeriodIntegrityValidator
belongs_to :user, class_name: 'User', foreign_key: 'closed_by'
belongs_to :user, class_name: 'User', foreign_key: 'closed_by', inverse_of: :accounting_periods
def delete
false

View File

@ -47,6 +47,6 @@ class CartItem::MachineReservation < CartItem::Reservation
return 0 if @plan.nil?
machine_credit = @plan.machine_credits.find { |credit| credit.creditable_id == @reservable.id }
credits_hours(machine_credit, @new_subscription)
credits_hours(machine_credit, new_plan_being_bought: @new_subscription)
end
end

View File

@ -23,7 +23,7 @@ class CartItem::Reservation < CartItem::BaseItem
amount = 0
hours_available = credits
grouped_slots.values.each do |slots|
grouped_slots.each_value do |slots|
prices = applicable_prices(slots)
slots.each_with_index do |slot, index|
amount += get_slot_price_from_prices(prices, slot, is_privileged,
@ -100,7 +100,7 @@ class CartItem::Reservation < CartItem::BaseItem
slot_minutes = (slot[:slot_attributes][:end_at].to_time - slot[:slot_attributes][:start_at].to_time) / SECONDS_PER_MINUTE
price = prices[:prices].find { |p| p[:duration] <= slot_minutes && p[:duration].positive? }
price = prices[:prices].first if price.nil?
hourly_rate = (price[:price].amount.to_f / price[:price].duration) * MINUTES_PER_HOUR
hourly_rate = ((Rational(price[:price].amount.to_f) / Rational(price[:price].duration)) * Rational(MINUTES_PER_HOUR)).to_f
# apply the base price to the real slot duration
real_price = get_slot_price(hourly_rate, slot, is_privileged, options)
@ -130,7 +130,7 @@ class CartItem::Reservation < CartItem::BaseItem
slot_minutes = (slot[:slot_attributes][:end_at].to_time - slot[:slot_attributes][:start_at].to_time) / SECONDS_PER_MINUTE
# apply the base price to the real slot duration
real_price = if options[:is_division]
(slot_rate / MINUTES_PER_HOUR) * slot_minutes
((Rational(slot_rate) / Rational(MINUTES_PER_HOUR)) * Rational(slot_minutes)).to_f
else
slot_rate
end
@ -138,7 +138,7 @@ class CartItem::Reservation < CartItem::BaseItem
if real_price.positive? && options[:prepaid][:minutes]&.positive?
consumed = slot_minutes
consumed = options[:prepaid][:minutes] if slot_minutes > options[:prepaid][:minutes]
real_price = (slot_minutes - consumed) * (slot_rate / MINUTES_PER_HOUR)
real_price = (Rational(slot_minutes - consumed) * (Rational(slot_rate) / Rational(MINUTES_PER_HOUR))).to_f
options[:prepaid][:minutes] -= consumed
end
@ -158,7 +158,9 @@ class CartItem::Reservation < CartItem::BaseItem
# and the base price (1 hours), we use the 7 hours price, then 3 hours price, and finally the base price twice (7+3+1+1 = 12).
# All these prices are returned to be applied to the reservation.
def applicable_prices(slots)
total_duration = slots.map { |slot| (slot[:slot_attributes][:end_at].to_time - slot[:slot_attributes][:start_at].to_time) / SECONDS_PER_MINUTE }.reduce(:+)
total_duration = slots.map do |slot|
(slot[:slot_attributes][:end_at].to_time - slot[:slot_attributes][:start_at].to_time) / SECONDS_PER_MINUTE
end.reduce(:+)
rates = { prices: [] }
remaining_duration = total_duration
@ -182,7 +184,7 @@ class CartItem::Reservation < CartItem::BaseItem
##
# Compute the number of remaining hours in the users current credits (for machine or space)
##
def credits_hours(credits, new_plan_being_bought = false)
def credits_hours(credits, new_plan_being_bought: false)
return 0 unless credits
hours_available = credits.hours

View File

@ -41,6 +41,6 @@ class CartItem::SpaceReservation < CartItem::Reservation
return 0 if @plan.nil?
space_credit = @plan.space_credits.find { |credit| credit.creditable_id == @reservable.id }
credits_hours(space_credit, @new_subscription)
credits_hours(space_credit, new_plan_being_bought: @new_subscription)
end
end

View File

@ -13,27 +13,8 @@ module SingleSignOnConcern
## Retrieve the requested data in the User and user's Profile tables
## @param sso_mapping {String} must be of form 'user._field_' or 'profile._field_'. Eg. 'user.email'
def get_data_from_sso_mapping(sso_mapping)
parsed = /^(user|profile)\.(.+)$/.match(sso_mapping)
if parsed[1] == 'user'
self[parsed[2].to_sym]
elsif parsed[1] == 'profile'
case sso_mapping
when 'profile.avatar'
profile.user_avatar.remote_attachment_url
when 'profile.address'
invoicing_profile.address&.address
when 'profile.organization_name'
invoicing_profile.organization&.name
when 'profile.organization_address'
invoicing_profile.organization&.address&.address
when 'profile.gender'
statistic_profile.gender
when 'profile.birthday'
statistic_profile.birthday
else
profile[parsed[2].to_sym]
end
end
service = UserSetterService.new(self)
service.read_attribute(sso_mapping)
end
## Set some data on the current user, according to the sso_key given
@ -42,36 +23,8 @@ module SingleSignOnConcern
def set_data_from_sso_mapping(sso_mapping, data)
return if data.nil? || data.blank?
if sso_mapping.to_s.start_with? 'user.'
self[sso_mapping[5..-1].to_sym] = data
elsif sso_mapping.to_s.start_with? 'profile.'
case sso_mapping.to_s
when 'profile.avatar'
profile.user_avatar ||= UserAvatar.new
profile.user_avatar.remote_attachment_url = data
when 'profile.address'
self.invoicing_profile ||= InvoicingProfile.new
self.invoicing_profile.address ||= Address.new
self.invoicing_profile.address.address = data
when 'profile.organization_name'
self.invoicing_profile ||= InvoicingProfile.new
self.invoicing_profile.organization ||= Organization.new
self.invoicing_profile.organization.name = data
when 'profile.organization_address'
self.invoicing_profile ||= InvoicingProfile.new
self.invoicing_profile.organization ||= Organization.new
self.invoicing_profile.organization.address ||= Address.new
self.invoicing_profile.organization.address.address = data
when 'profile.gender'
self.statistic_profile ||= StatisticProfile.new
self.statistic_profile.gender = data
when 'profile.birthday'
self.statistic_profile ||= StatisticProfile.new
self.statistic_profile.birthday = data
else
profile[sso_mapping[8..-1].to_sym] = data
end
end
service = UserSetterService.new(self)
service.assign_attibute(sso_mapping, data)
return if mapped_from_sso&.include?(sso_mapping)
@ -80,7 +33,7 @@ module SingleSignOnConcern
## used to allow the migration of existing users between authentication providers
def generate_auth_migration_token
update_attributes(auth_token: Devise.friendly_token)
update(auth_token: Devise.friendly_token)
end
## link the current user to the given provider (omniauth attributes hash)
@ -93,7 +46,7 @@ module SingleSignOnConcern
raise DuplicateIndexError, "This #{active_provider.name} account is already linked to an existing user"
end
update_attributes(provider: auth.provider, uid: auth.uid, auth_token: nil)
update(provider: auth.provider, uid: auth.uid, auth_token: nil)
end
## Merge the provided User's SSO details into the current user and drop the provided user to ensure the unity
@ -118,13 +71,16 @@ module SingleSignOnConcern
# update the user's profile to set the data managed by the SSO
auth_provider = AuthProvider.from_strategy_name(sso_user.provider)
logger.debug "found auth_provider=#{auth_provider.name}"
auth_provider.sso_fields.each do |field|
logger.debug "found auth_provider=#{auth_provider&.name}"
auth_provider&.sso_fields&.each do |field|
value = sso_user.get_data_from_sso_mapping(field)
logger.debug "mapping sso field #{field} with value=#{value}"
# we do not merge the email field if its end with the special value '-duplicate' as this means
# that the user is currently merging with the account that have the same email than the sso
set_data_from_sso_mapping(field, value) unless (field == 'user.email' && value.end_with?('-duplicate')) || (field == 'user.group_id' && sso_user.admin?)
# We do not merge the email field if its end with the special value '-duplicate' as this means
# that the user is currently merging with the account that have the same email than the sso.
# Moreover, if the user is an administrator, we must keep him in his group
unless (field == 'user.email' && value.end_with?('-duplicate')) || (field == 'user.group_id' && admin?)
set_data_from_sso_mapping(field, value)
end
end
# run the account transfer in an SQL transaction to ensure data integrity

View File

@ -83,7 +83,8 @@ class Coupon < ApplicationRecord
end
def users
invoices.map(&:user).uniq(&:id)
# compact to user removed
invoices.map(&:user).compact.uniq(&:id)
end
def users_ids

View File

@ -2,17 +2,15 @@
# Group is way to bind users with prices. Different prices can be defined for each plan/reservable, for each group
class Group < ApplicationRecord
has_many :plans
has_many :users
has_many :statistic_profiles
has_many :plans, dependent: :destroy
has_many :users, dependent: :nullify
has_many :statistic_profiles, dependent: :nullify
has_many :trainings_pricings, dependent: :destroy
has_many :machines_prices, -> { where(priceable_type: 'Machine') }, class_name: 'Price', dependent: :destroy
has_many :spaces_prices, -> { where(priceable_type: 'Space') }, class_name: 'Price', dependent: :destroy
has_many :machines_prices, -> { where(priceable_type: 'Machine') }, class_name: 'Price', dependent: :destroy, inverse_of: :group
has_many :spaces_prices, -> { where(priceable_type: 'Space') }, class_name: 'Price', dependent: :destroy, inverse_of: :group
has_many :proof_of_identity_types_groups, dependent: :destroy
has_many :proof_of_identity_types, through: :proof_of_identity_types_groups
scope :all_except_admins, -> { where.not(slug: 'admins') }
extend FriendlyId
friendly_id :name, use: :slugged
@ -41,26 +39,26 @@ class Group < ApplicationRecord
end
def create_trainings_pricings
Training.all.each do |training|
Training.find_each do |training|
TrainingsPricing.create(group: self, training: training, amount: 0)
end
end
def create_machines_prices
Machine.all.each do |machine|
Machine.find_each do |machine|
Price.create(priceable: machine, group: self, amount: 0)
end
end
def create_spaces_prices
Space.all.each do |space|
Space.find_each do |space|
Price.create(priceable: space, group: self, amount: 0)
end
end
def create_statistic_subtype
user_index = StatisticIndex.find_by(es_type_key: 'user')
StatisticSubType.create!( statistic_types: user_index.statistic_types, key: slug, label: name)
StatisticSubType.create!(statistic_types: user_index.statistic_types, key: slug, label: name)
end
def update_statistic_subtype
@ -74,7 +72,7 @@ class Group < ApplicationRecord
def disable_plans
plans.each do |plan|
plan.update_attributes(disabled: disabled)
plan.update(disabled: disabled)
end
end
end

View File

@ -13,15 +13,17 @@ class Invoice < PaymentDocument
belongs_to :wallet_transaction
belongs_to :coupon
has_one :avoir, class_name: 'Invoice', foreign_key: :invoice_id, dependent: :destroy
has_one :payment_schedule_item
has_one :payment_gateway_object, as: :item
belongs_to :operator_profile, foreign_key: :operator_profile_id, class_name: 'InvoicingProfile'
has_one :avoir, class_name: 'Invoice', dependent: :destroy, inverse_of: :avoir
has_one :payment_schedule_item, dependent: :nullify
has_one :payment_gateway_object, as: :item, dependent: :destroy
belongs_to :operator_profile, class_name: 'InvoicingProfile'
delegate :user, to: :invoicing_profile
before_create :add_environment
after_create :update_reference, :chain_record
after_commit :generate_and_send_invoice, on: [:create], if: :persisted?
after_update :log_changes
after_commit :generate_and_send_invoice, on: [:create], if: :persisted?
validates_with ClosedPeriodValidator
@ -44,10 +46,6 @@ class Invoice < PaymentDocument
"#{prefix}-#{id}_#{created_at.strftime('%d%m%Y')}.pdf"
end
def user
invoicing_profile.user
end
def order_number
PaymentDocumentService.generate_order_number(self)
end
@ -75,7 +73,7 @@ class Invoice < PaymentDocument
invoice_items.each do |ii|
paid_items += 1 unless ii.amount.zero?
next unless attrs[:invoice_items_ids].include? ii.id # list of items to refund (partial refunds)
raise Exception if ii.invoice_item # cannot refund an item that was already refunded
raise StandardError if ii.invoice_item # cannot refund an item that was already refunded
refund_items += 1 unless ii.amount.zero?
avoir_ii = avoir.invoice_items.build(ii.dup.attributes)
@ -84,17 +82,7 @@ class Invoice < PaymentDocument
avoir.total += avoir_ii.amount
end
# handle coupon
unless avoir.coupon_id.nil?
discount = avoir.total
if avoir.coupon.type == 'percent_off'
discount = avoir.total * avoir.coupon.percent_off / 100.0
elsif avoir.coupon.type == 'amount_off'
discount = (avoir.coupon.amount_off / paid_items) * refund_items
else
raise InvalidCouponError
end
avoir.total -= discount
end
avoir.total = CouponService.apply_on_refund(avoir.total, avoir.coupon, paid_items, refund_items)
avoir
end
@ -145,6 +133,10 @@ class Invoice < PaymentDocument
invoice_items.where(main: true).first
end
def other_items
invoice_items.where(main: [nil, false])
end
# get amount total paid
def amount_paid
total - (wallet_amount || 0)

View File

@ -4,8 +4,8 @@
class InvoiceItem < Footprintable
belongs_to :invoice
has_one :invoice_item # associates invoice_items of an invoice to invoice_items of an Avoir
has_one :payment_gateway_object, as: :item
has_one :invoice_item, dependent: :destroy # associates invoice_items of an invoice to invoice_items of an Avoir
has_one :payment_gateway_object, as: :item, dependent: :destroy
belongs_to :object, polymorphic: true
@ -15,7 +15,8 @@ class InvoiceItem < Footprintable
def amount_after_coupon
# deduct coupon discount
coupon_service = CouponService.new
coupon_service.ventilate(invoice.total, amount, invoice.coupon)
total_without_coupon = coupon_service.invoice_total_no_coupon(invoice)
coupon_service.ventilate(total_without_coupon, amount, invoice.coupon)
end
# return the item amount, coupon discount deducted, if any, and VAT excluded, if applicable

View File

@ -4,7 +4,7 @@
class PaymentScheduleItem < Footprintable
belongs_to :payment_schedule
belongs_to :invoice
has_one :payment_gateway_object, as: :item
has_one :payment_gateway_object, as: :item, dependent: :destroy
after_create :chain_record

View File

@ -7,13 +7,13 @@ class Plan < ApplicationRecord
belongs_to :plan_category
has_many :credits, dependent: :destroy
has_many :training_credits, -> { where(creditable_type: 'Training') }, class_name: 'Credit'
has_many :machine_credits, -> { where(creditable_type: 'Machine') }, class_name: 'Credit'
has_many :space_credits, -> { where(creditable_type: 'Space') }, class_name: 'Credit'
has_many :subscriptions
has_many :training_credits, -> { where(creditable_type: 'Training') }, class_name: 'Credit', dependent: :destroy, inverse_of: :plan
has_many :machine_credits, -> { where(creditable_type: 'Machine') }, class_name: 'Credit', dependent: :destroy, inverse_of: :plan
has_many :space_credits, -> { where(creditable_type: 'Space') }, class_name: 'Credit', dependent: :destroy, inverse_of: :plan
has_many :subscriptions, dependent: :nullify
has_one :plan_file, as: :viewable, dependent: :destroy
has_many :prices, dependent: :destroy
has_one :payment_gateway_object, as: :item
has_one :payment_gateway_object, as: :item, dependent: :destroy
extend FriendlyId
friendly_id :base_name, use: :slugged
@ -37,7 +37,7 @@ class Plan < ApplicationRecord
def self.create_for_all_groups(plan_params)
plans = []
Group.all_except_admins.each do |group|
Group.find_each do |group|
plan = if plan_params[:type] == 'PartnerPlan'
PartnerPlan.new(plan_params.except(:group_id, :type))
else
@ -59,14 +59,14 @@ class Plan < ApplicationRecord
end
def create_machines_prices
Machine.all.each do |machine|
Machine.all.find_each do |machine|
default_price = Price.find_by(priceable: machine, plan: nil, group_id: group_id)&.amount || 0
Price.create(priceable: machine, plan: self, group_id: group_id, amount: default_price)
end
end
def create_spaces_prices
Space.all.each do |space|
Space.all.find_each do |space|
default_price = Price.find_by(priceable: space, plan: nil, group_id: group_id)&.amount || 0
Price.create(priceable: space, plan: self, group_id: group_id, amount: default_price)
end
@ -123,12 +123,12 @@ class Plan < ApplicationRecord
StatisticTypeSubType.create!(statistic_type: stat_type, statistic_sub_type: stat_subtype)
else
Rails.logger.error 'Unable to create the statistics association for the new plan. ' \
'Possible causes: the type or the subtype were not created successfully.'
'Possible causes: the type or the subtype were not created successfully.'
end
end
def set_name
update_columns(name: human_readable_name)
update_columns(name: human_readable_name) # rubocop:disable Rails/SkipsModelValidations
end
def update_gateway_product

View File

@ -204,7 +204,7 @@ class Setting < ApplicationRecord
# @return {String|Boolean}
##
def self.get(name)
res = find_by(name: name)&.value
res = find_by('LOWER(name) = ? ', name.downcase)&.value
# handle boolean values
return true if res == 'true'

View File

@ -160,6 +160,7 @@ class ShoppingCart
# The total booked minutes are subtracted from the user's prepaid minutes
def update_packs(objects)
objects.filter { |o| o.is_a? Reservation }.each do |reservation|
reservation.reload
PrepaidPackService.update_user_minutes(@customer, reservation)
end
end

View File

@ -31,7 +31,6 @@ class Subscription < ApplicationRecord
if expired?
false
else
# TODO, check if the rubocop:disable directove can be deleted
update_columns(expiration_date: time, canceled_at: time) # rubocop:disable Rails/SkipsModelValidations
notify_admin_subscription_canceled
notify_member_subscription_canceled

View File

@ -43,9 +43,9 @@ class User < ApplicationRecord
has_many :exports, dependent: :destroy
has_many :imports, dependent: :nullify
has_one :payment_gateway_object, as: :item
has_one :payment_gateway_object, as: :item, dependent: :nullify
has_many :accounting_periods, foreign_key: 'closed_by', dependent: :nullify
has_many :accounting_periods, foreign_key: 'closed_by', dependent: :nullify, inverse_of: :user
has_many :proof_of_identity_files, dependent: :destroy
has_many :proof_of_identity_refusals, dependent: :destroy
@ -56,14 +56,15 @@ class User < ApplicationRecord
end
before_create :assign_default_role
after_commit :create_gateway_customer, on: [:create]
after_commit :notify_admin_when_user_is_created, on: :create
after_create :init_dependencies
after_update :update_invoicing_profile, if: :invoicing_data_was_modified?
after_update :update_statistic_profile, if: :statistic_data_was_modified?
before_destroy :remove_orphan_drafts
after_commit :create_gateway_customer, on: [:create]
after_commit :notify_admin_when_user_is_created, on: :create
attr_accessor :cgu
delegate :first_name, to: :profile
delegate :last_name, to: :profile
delegate :subscriptions, to: :statistic_profile
@ -116,11 +117,11 @@ class User < ApplicationRecord
end
def self.online_payers
User.with_any_role(:manager, :member)
User.with_any_role(:admin, :manager, :member)
end
def self.adminsys
return unless Rails.application.secrets.adminsys_email.present?
return if Rails.application.secrets.adminsys_email.blank?
User.find_by('lower(email) = ?', Rails.application.secrets.adminsys_email&.downcase)
end
@ -225,7 +226,7 @@ class User < ApplicationRecord
def update_statistic_profile
raise NoProfileError if statistic_profile.nil? || statistic_profile.id.nil?
statistic_profile.update_attributes(
statistic_profile.update(
group_id: group_id,
role_id: roles.first.id
)
@ -255,7 +256,7 @@ class User < ApplicationRecord
def remove_orphan_drafts
orphans = my_projects
.joins('LEFT JOIN project_users ON projects.id = project_users.project_id')
.where('project_users.project_id IS NULL')
.where(project_users: { project_id: nil })
.where(state: 'draft')
orphans.map(&:destroy!)
end
@ -342,7 +343,7 @@ class User < ApplicationRecord
def update_invoicing_profile
raise NoProfileError if invoicing_profile.nil?
invoicing_profile.update_attributes(
invoicing_profile.update(
email: email,
first_name: first_name,
last_name: last_name
@ -351,7 +352,7 @@ class User < ApplicationRecord
def password_complexity
return if password.blank? || SecurePassword.is_secured?(password)
errors.add I18n.t("app.public.common.password_is_too_weak"), I18n.t("app.public.common.password_is_too_weak_explanations")
end
end

View File

@ -53,10 +53,10 @@ class PDF::Invoice < Prawn::Document
end
# user/organization's information
if invoice&.invoicing_profile&.organization
if invoice.invoicing_profile.organization
name = invoice.invoicing_profile.organization.name
full_name = "#{name} (#{invoice.invoicing_profile.full_name})"
others = invoice&.invoicing_profile&.user_profile_custom_fields&.joins(:profile_custom_field)
others = invoice.invoicing_profile.user_profile_custom_fields&.joins(:profile_custom_field)
&.where('profile_custom_fields.actived' => true)
&.order('profile_custom_fields.id ASC')
&.select { |f| f.value.present? }
@ -102,7 +102,7 @@ class PDF::Invoice < Prawn::Document
next unless item.object_type == Subscription.name
subscription = item.object
cancellation = invoice.is_a?(Avoir) ? I18n.t('invoices.cancellation') + ' - ' : ''
cancellation = invoice.is_a?(Avoir) ? "#{I18n.t('invoices.cancellation')} - " : ''
object = "\n- #{object}\n- #{cancellation + subscription_verbose(subscription, name)}"
break
end
@ -120,7 +120,7 @@ class PDF::Invoice < Prawn::Document
Rails.logger.error "specified main_item.object_type type (#{invoice.main_item.object_type}) is unknown"
end
end
text I18n.t('invoices.object') + ' ' + object
text "#{I18n.t('invoices.object')} #{object}"
# details table of the invoice's elements
move_down 20
@ -135,7 +135,7 @@ class PDF::Invoice < Prawn::Document
invoice.invoice_items.each do |item|
price = item.amount.to_i / 100.00
details = invoice.is_a?(Avoir) ? I18n.t('invoices.cancellation') + ' - ' : ''
details = invoice.is_a?(Avoir) ? "#{I18n.t('invoices.cancellation')} - " : ''
if item.object_type == Subscription.name
subscription = item.object
@ -144,9 +144,10 @@ class PDF::Invoice < Prawn::Document
START: I18n.l(invoice.main_item.object.start_at.to_date),
END: I18n.l(invoice.main_item.object.end_at.to_date))
else
subscription_end_at = if subscription_expiration_date.is_a?(Time)
subscription_end_at = case subscription_expiration_date
when Time
subscription_expiration_date
elsif subscription_expiration_date.is_a?(String)
when String
DateTime.parse(subscription_expiration_date)
else
subscription.expiration_date
@ -173,12 +174,12 @@ class PDF::Invoice < Prawn::Document
details += I18n.t('invoices.event_reservation_DESCRIPTION', DESCRIPTION: item.description)
# details of the number of tickets
if invoice.main_item.object.nb_reserve_places.positive?
details += "\n " + I18n.t('invoices.full_price_ticket', count: invoice.main_item.object.nb_reserve_places)
details += "\n #{I18n.t('invoices.full_price_ticket', count: invoice.main_item.object.nb_reserve_places)}"
end
invoice.main_item.object.tickets.each do |t|
details += "\n " + I18n.t('invoices.other_rate_ticket',
count: t.booked,
NAME: t.event_price_category.price_category.name)
details += "\n #{I18n.t('invoices.other_rate_ticket',
count: t.booked,
NAME: t.event_price_category.price_category.name)}"
end
else
details += item.description
@ -196,22 +197,9 @@ class PDF::Invoice < Prawn::Document
## subtract the coupon, if any
unless invoice.coupon_id.nil?
cp = invoice.coupon
discount = 0
if cp.type == 'percent_off'
discount = total_calc * cp.percent_off / 100.00
elsif cp.type == 'amount_off'
# refunds of invoices with cash coupons: we need to ventilate coupons on paid items
if invoice.is_a?(Avoir)
paid_items = invoice.invoice.invoice_items.select { |ii| ii.amount.positive? }.length
refund_items = invoice.invoice_items.select { |ii| ii.amount.positive? }.length
discount = ((invoice.coupon.amount_off / paid_items) * refund_items) / 100.00
else
discount = cp.amount_off / 100.00
end
else
raise InvalidCouponError
end
coupon_service = CouponService.new
total_without_coupon = coupon_service.invoice_total_no_coupon(invoice)
discount = (total_without_coupon - invoice.total) / 100.00
total_calc -= discount
@ -286,7 +274,7 @@ class PDF::Invoice < Prawn::Document
# payment details
move_down 20
if invoice.is_a?(Avoir)
payment_verbose = I18n.t('invoices.refund_on_DATE', DATE: I18n.l(invoice.avoir_date.to_date)) + ' '
payment_verbose = "#{I18n.t('invoices.refund_on_DATE', DATE: I18n.l(invoice.avoir_date.to_date))} "
case invoice.payment_method
when 'stripe'
payment_verbose += I18n.t('invoices.by_card_online_payment')
@ -303,7 +291,7 @@ class PDF::Invoice < Prawn::Document
else
Rails.logger.error "specified refunding method (#{payment_verbose}) is unknown"
end
payment_verbose += ' ' + I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(total))
payment_verbose += " #{I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(total))}"
else
# subtract the wallet amount for this invoice from the total
if invoice.wallet_amount
@ -323,18 +311,18 @@ class PDF::Invoice < Prawn::Document
# if the invoice was 100% payed with the wallet ...
payment_verbose = I18n.t('invoices.settlement_by_wallet') if total.zero? && wallet_amount
payment_verbose += ' ' + I18n.t('invoices.on_DATE_at_TIME',
DATE: I18n.l(invoice.created_at.to_date),
TIME: I18n.l(invoice.created_at, format: :hour_minute))
payment_verbose += " #{I18n.t('invoices.on_DATE_at_TIME',
DATE: I18n.l(invoice.created_at.to_date),
TIME: I18n.l(invoice.created_at, format: :hour_minute))}"
if total.positive? || !invoice.wallet_amount
payment_verbose += ' ' + I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(total))
payment_verbose += " #{I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(total))}"
end
if invoice.wallet_amount
payment_verbose += if total.positive?
' ' + I18n.t('invoices.and') + ' ' + I18n.t('invoices.by_wallet') + ' ' +
I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(wallet_amount))
" #{I18n.t('invoices.and')} #{I18n.t('invoices.by_wallet')} " \
"#{I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(wallet_amount))}"
else
' ' + I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(wallet_amount))
" #{I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(wallet_amount))}"
end
end
end
@ -360,7 +348,7 @@ class PDF::Invoice < Prawn::Document
transparent(0.1) do
rotate(45, origin: [0, 0]) do
image "#{Rails.root}/app/pdfs/data/watermark-#{I18n.default_locale}.png", at: [90, 150]
image Rails.root.join("app/pdfs/data/watermark-#{I18n.default_locale}.png"), at: [90, 150]
end
end
end
@ -369,16 +357,16 @@ class PDF::Invoice < Prawn::Document
def reservation_dates_verbose(slot)
if slot.start_at.to_date == slot.end_at.to_date
'- ' + I18n.t('invoices.on_DATE_from_START_to_END',
DATE: I18n.l(slot.start_at.to_date),
START: I18n.l(slot.start_at, format: :hour_minute),
END: I18n.l(slot.end_at, format: :hour_minute)) + "\n"
"- #{I18n.t('invoices.on_DATE_from_START_to_END',
DATE: I18n.l(slot.start_at.to_date),
START: I18n.l(slot.start_at, format: :hour_minute),
END: I18n.l(slot.end_at, format: :hour_minute))}\n"
else
'- ' + I18n.t('invoices.from_STARTDATE_to_ENDDATE_from_STARTTIME_to_ENDTIME',
STARTDATE: I18n.l(slot.start_at.to_date),
ENDDATE: I18n.l(slot.start_at.to_date),
STARTTIME: I18n.l(slot.start_at, format: :hour_minute),
ENDTIME: I18n.l(slot.end_at, format: :hour_minute)) + "\n"
"- #{I18n.t('invoices.from_STARTDATE_to_ENDDATE_from_STARTTIME_to_ENDTIME',
STARTDATE: I18n.l(slot.start_at.to_date),
ENDDATE: I18n.l(slot.start_at.to_date),
STARTTIME: I18n.l(slot.start_at, format: :hour_minute),
ENDTIME: I18n.l(slot.end_at, format: :hour_minute))}\n"
end
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
# Pundit Additional context for authorizing a product offering
class CartContext
attr_reader :customer_id, :is_offered
def initialize(customer_id, is_offered)
@customer_id = customer_id
@is_offered = is_offered
end
def policy_class
CartPolicy
end
end

View File

@ -15,6 +15,6 @@ class CartPolicy < ApplicationPolicy
end
def set_offer?
user.privileged?
!record.is_offered || (user.privileged? && record.customer_id != user.id)
end
end

View File

@ -6,6 +6,6 @@ class LocalPaymentPolicy < ApplicationPolicy
# only admins and managers can offer free extensions of a subscription
has_free_days = record.shopping_cart.items.any? { |item| item.is_a? CartItem::FreeExtension }
user.admin? || (user.manager? && record.shopping_cart.customer.id != user.id) || (record.price.zero? && !has_free_days)
((user.admin? || user.manager?) && record.shopping_cart.customer.id != user.id) || (record.price.zero? && !has_free_days)
end
end

View File

@ -61,28 +61,21 @@ class AccountingExportService
# Generate the "subscription" and "reservation" rows associated with the provided invoice
def items_rows(invoice)
rows = invoice.subscription_invoice? ? "#{subscription_row(invoice)}\n" : ''
case invoice.main_item.object_type
when 'Reservation'
items = invoice.invoice_items.reject { |ii| ii.object_type == 'Subscription' }
rows = ''
{
subscription: 'Subscription', reservation: 'Reservation', wallet: 'WalletTransaction',
pack: 'StatisticProfilePrepaidPack', product: 'OrderItem', error: 'Error'
}.each do |type, object_type|
items = invoice.invoice_items.filter { |ii| ii.object_type == object_type }
items.each do |item|
rows << "#{reservation_row(invoice, item)}\n"
rows << "#{row(
invoice,
account(invoice, type),
account(invoice, type, type: :label),
item.net_amount / 100.00,
line_label: label(invoice)
)}\n"
end
when 'WalletTransaction'
rows << "#{wallet_row(invoice)}\n"
when 'StatisticProfilePrepaidPack'
rows << "#{pack_row(invoice)}\n"
when 'OrderItem'
rows << "#{product_row(invoice)}\n"
when 'Error'
items = invoice.invoice_items.reject { |ii| ii.object_type == 'Subscription' }
items.each do |item|
rows << "#{error_row(invoice, item)}\n"
end
when 'Subscription'
# do nothing, subscription was already handled by subscription_row
else
Rails.logger.warn { "Unknown main object type #{invoice.main_item.object_type}" }
end
rows
end
@ -93,8 +86,8 @@ class AccountingExportService
invoice.payment_means.each do |details|
rows << row(
invoice,
account(invoice, :projets, means: details[:means]),
account(invoice, :projets, means: details[:means], type: :label),
account(invoice, :client, means: details[:means]),
account(invoice, :client, means: details[:means], type: :label),
details[:amount] / 100.00,
line_label: label(invoice),
debit_method: :debit_client,
@ -105,61 +98,6 @@ class AccountingExportService
rows
end
# Generate the "reservation" row, which contains the credit to the reservation account, all taxes excluded
def reservation_row(invoice, item)
row(
invoice,
account(invoice, :reservation),
account(invoice, :reservation, type: :label),
item.net_amount / 100.00,
line_label: label(invoice)
)
end
# Generate the "subscription" row, which contains the credit to the subscription account, all taxes excluded
def subscription_row(invoice)
subscription_item = invoice.invoice_items.select { |ii| ii.object_type == 'Subscription' }.first
row(
invoice,
account(invoice, :subscription),
account(invoice, :subscription, type: :label),
subscription_item.net_amount / 100.00,
line_label: label(invoice)
)
end
# Generate the "wallet" row, which contains the credit to the wallet account, all taxes excluded
# This applies to wallet crediting, when an Avoir is generated at this time
def wallet_row(invoice)
row(
invoice,
account(invoice, :wallet),
account(invoice, :wallet, type: :label),
invoice.invoice_items.first.net_amount / 100.00,
line_label: label(invoice)
)
end
def pack_row(invoice)
row(
invoice,
account(invoice, :pack),
account(invoice, :pack, type: :label),
invoice.invoice_items.first.net_amount / 100.00,
line_label: label(invoice)
)
end
def product_row(invoice)
row(
invoice,
account(invoice, :product),
account(invoice, :product, type: :label),
invoice.invoice_items.first.net_amount / 100.00,
line_label: label(invoice)
)
end
# Generate the "VAT" row, which contains the credit to the VAT account, with VAT amount only
def vat_row(invoice)
total = invoice.invoice_items.map(&:net_amount).sum
@ -175,16 +113,6 @@ class AccountingExportService
)
end
def error_row(invoice, item)
row(
invoice,
account(invoice, :error),
account(invoice, :error, type: :label),
item.net_amount / 100.00,
line_label: label(invoice)
)
end
# Generate a row of the export, filling the configured columns with the provided values
def row(invoice, account_code, account_label, amount, line_label: '', debit_method: :debit, credit_method: :credit)
row = ''
@ -219,44 +147,12 @@ class AccountingExportService
# Get the account code (or label) for the given invoice and the specified line type (client, vat, subscription or reservation)
def account(invoice, account, type: :code, means: :other)
case account
when :projets
when :client
Setting.get("accounting_#{means}_client_#{type}")
when :vat
Setting.get("accounting_VAT_#{type}")
when :subscription
if invoice.subscription_invoice?
Setting.get("accounting_subscription_#{type}")
else
Rails.logger.debug { "WARN: Invoice #{invoice.id} has no subscription" }
end
when :reservation
if invoice.main_item.object_type == 'Reservation'
Setting.get("accounting_#{invoice.main_item.object.reservable_type}_#{type}")
else
Rails.logger.debug { "WARN: Invoice #{invoice.id} has no reservation" }
end
when :wallet
if invoice.main_item.object_type == 'WalletTransaction'
Setting.get("accounting_wallet_#{type}")
else
Rails.logger.debug { "WARN: Invoice #{invoice.id} is not a wallet credit" }
end
when :pack
if invoice.main_item.object_type == 'StatisticProfilePrepaidPack'
Setting.get("accounting_Pack_#{type}")
else
Rails.logger.debug { "WARN: Invoice #{invoice.id} has no prepaid-pack" }
end
when :product
if invoice.main_item.object_type == 'OrderItem'
Setting.get("accounting_Product_#{type}")
else
Rails.logger.debug { "WARN: Invoice #{invoice.id} has no prepaid-pack" }
end
when :error
Setting.get("accounting_Error_#{type}")
Setting.get("accounting_#{invoice.main_item.object.reservable_type}_#{type}") if invoice.main_item.object_type == 'Reservation'
else
Rails.logger.debug { "Unsupported account #{account}" }
Setting.get("accounting_#{account}_#{type}")
end || ''
end
@ -297,6 +193,7 @@ class AccountingExportService
items.push I18n.t("accounting_export.#{invoice.main_item.object.reservable_type}_reservation")
end
items.push I18n.t('accounting_export.wallet') if invoice.main_item.object_type == 'WalletTransaction'
items.push I18n.t('accounting_export.shop_order') if invoice.main_item.object_type == 'OrderItem'
summary = items.join(' + ')
res = "#{reference}, #{summary}"

View File

@ -18,16 +18,14 @@ class Checkout::PaymentService
CouponService.new.validate(coupon_code, order.statistic_profile.user.id)
amount = debit_amount(order)
if operator.privileged? || amount.zero?
if (operator.privileged? && operator != order.statistic_profile.user) || amount.zero?
Payments::LocalService.new.payment(order, coupon_code)
elsif operator.member?
if Stripe::Helper.enabled?
Payments::StripeService.new.payment(order, coupon_code, payment_id)
elsif PayZen::Helper.enabled?
Payments::PayzenService.new.payment(order, coupon_code)
else
raise Error('Bad gateway or online payment is disabled')
end
elsif Stripe::Helper.enabled? && payment_id.present?
Payments::StripeService.new.payment(order, coupon_code, payment_id)
elsif PayZen::Helper.enabled?
Payments::PayzenService.new.payment(order, coupon_code)
else
raise Error('Bad gateway or online payment is disabled')
end
end

View File

@ -26,17 +26,35 @@ class CouponService
return price if coupon_object.nil?
if coupon_object.status(user_id, total) == 'active'
if coupon_object.type == 'percent_off'
price -= (price * coupon_object.percent_off / 100.00).truncate
elsif coupon_object.type == 'amount_off'
case coupon_object.type
when 'percent_off'
price -= (Rational(price * coupon_object.percent_off) / Rational(100.0)).to_f.ceil
when 'amount_off'
# do not apply cash coupon unless it has a lower amount that the total price
price -= coupon_object.amount_off if coupon_object.amount_off <= price
else
raise InvalidCouponError("unsupported coupon type #{coupon_object.type}")
end
end
price
end
# Apply the provided coupon to the given amount, considering that this applies to a refund invoice (Avoir),
# potentially partial
def self.apply_on_refund(amount, coupon, paid_items = 1, refund_items = 1)
return amount if coupon.nil?
case coupon.type
when 'percent_off'
amount - (Rational(amount * coupon.percent_off) / Rational(100.0)).to_f.ceil
when 'amount_off'
amount - (Rational(coupon.amount_off / paid_items) * Rational(refund_items)).to_f.ceil
else
raise InvalidCouponError
end
end
##
# Find the coupon associated with the given code and check it is valid for the given user
# @param code {String} the literal code of the coupon
@ -61,14 +79,15 @@ class CouponService
def ventilate(total, amount, coupon)
price = amount
if !coupon.nil? && total != 0
if coupon.type == 'percent_off'
price = amount - (amount * coupon.percent_off / 100.00)
elsif coupon.type == 'amount_off'
ratio = (coupon.amount_off / 100.00) / total
discount = (amount * ratio.abs) * 100
price = amount - discount
case coupon.type
when 'percent_off'
price = amount - (Rational(amount * coupon.percent_off) / Rational(100.00)).to_f.round
when 'amount_off'
ratio = Rational(amount) / Rational(total)
discount = (coupon.amount_off * ratio.abs)
price = (amount - discount).to_f.round
else
raise InvalidCouponError
raise InvalidCouponError("unsupported coupon type #{coupon.type}")
end
end
price

View File

@ -31,14 +31,15 @@ class EventService
def date_range(starting, ending, all_day)
start_date = Time.zone.parse(starting[:date])
end_date = Time.zone.parse(ending[:date])
start_time = Time.parse(starting[:time]) if starting[:time]
end_time = Time.parse(ending[:time]) if ending[:time]
if all_day
start_time = starting[:time] ? Time.zone.parse(starting[:time]) : nil
end_time = ending[:time] ? Time.zone.parse(ending[:time]) : nil
if all_day || start_time.nil? || end_time.nil?
start_at = DateTime.new(start_date.year, start_date.month, start_date.day, 0, 0, 0, start_date.zone)
end_at = DateTime.new(end_date.year, end_date.month, end_date.day, 23, 59, 59, end_date.zone)
else
start_at = DateTime.new(start_date.year, start_date.month, start_date.day, start_time&.hour, start_time&.min, start_time&.sec, start_date.zone)
end_at = DateTime.new(end_date.year, end_date.month, end_date.day, end_time&.hour, end_time&.min, end_time&.sec, end_date.zone)
start_at = DateTime.new(start_date.year, start_date.month, start_date.day, start_time.hour, start_time.min, start_time.sec,
start_date.zone)
end_at = DateTime.new(end_date.year, end_date.month, end_date.day, end_time.hour, end_time.min, end_time.sec, end_date.zone)
end
{ start_at: start_at, end_at: end_at }
end
@ -59,16 +60,13 @@ class EventService
)
.references(:availabilities, :events)
when 'all'
Event.where(
'recurrence_id = ?',
event.recurrence_id
)
Event.where(recurrence_id: event.recurrence_id)
else
[]
end
events.each do |e|
# here we use double negation because safe_destroy can return either a boolean (false) or an Availability (in case of delete success)
# we use double negation because safe_destroy can return either a boolean (false) or an Availability (in case of delete success)
results.push status: !!e.safe_destroy, event: e # rubocop:disable Style/DoubleNegation
end
results
@ -89,99 +87,41 @@ class EventService
.references(:availabilities, :events)
when 'all'
Event.includes(:availability, :event_price_categories, :event_files)
.where(
'recurrence_id = ?',
event.recurrence_id
)
.where(recurrence_id: event.recurrence_id)
else
[]
end
update_events(event, events, event_params)
update_occurrences(event, events, event_params)
end
private
def update_events(event, events, event_params)
def update_occurrences(base_event, occurrences, event_params)
results = {
events: [],
slots: []
}
events.each do |e|
next unless e.id != event.id
original_slots_ids = base_event.availability.slots.map(&:id)
start_at = event_params['availability_attributes']['start_at']
end_at = event_params['availability_attributes']['end_at']
event_price_categories_attributes = event_params['event_price_categories_attributes']
event_files_attributes = event_params['event_files_attributes']
e_params = event_params.merge(
availability_id: e.availability_id,
availability_attributes: {
id: e.availability_id,
start_at: e.availability.start_at.change(hour: start_at.hour, min: start_at.min),
end_at: e.availability.end_at.change(hour: end_at.hour, min: end_at.min),
available_type: e.availability.available_type
}
)
epc_attributes = []
event_price_categories_attributes&.each do |epca|
epc = e.event_price_categories.find_by(price_category_id: epca['price_category_id'])
if epc
epc_attributes.push(
id: epc.id,
price_category_id: epc.price_category_id,
amount: epca['amount'],
_destroy: epca['_destroy']
)
elsif epca['id'].present?
event_price = event.event_price_categories.find(epca['id'])
epc_attributes.push(
price_category_id: epca['price_category_id'],
amount: event_price.amount,
_destroy: ''
)
end
end
unless epc_attributes.empty?
e_params = e_params.merge(
event_price_categories_attributes: epc_attributes
)
end
occurrences.each do |occurrence|
next unless occurrence.id != base_event.id
ef_attributes = []
event_files_attributes&.each do |efa|
if efa['id'].present?
event_file = event.event_files.find(efa['id'])
ef = e.event_files.find_by(attachment: event_file.attachment.file.filename)
if ef
ef_attributes.push(
id: ef.id,
attachment: efa['attachment'],
_destroy: efa['_destroy']
)
end
else
ef_attributes.push(efa)
end
end
e_params = e_params.merge(
event_files_attributes: ef_attributes
)
original_slots_ids = event.availability.slots.map(&:id)
e_params = occurrence_params(base_event, occurrence, event_params)
begin
results[:events].push status: !!e.update(e_params.permit!), event: e # rubocop:disable Style/DoubleNegation
rescue StandardError => err
results[:events].push status: false, event: e, error: err.try(:record).try(:class).try(:name), message: err.message
results[:events].push status: !!occurrence.update(e_params.permit!), event: occurrence # rubocop:disable Style/DoubleNegation
rescue StandardError => e
results[:events].push status: false, event: occurrence, error: e.try(:record).try(:class).try(:name), message: e.message
end
results[:slots].concat(update_slots(e.availability_id, original_slots_ids))
results[:slots].concat(update_slots(occurrence.availability_id, original_slots_ids))
end
original_slots_ids = event.availability.slots.map(&:id)
begin
event_params[:availability_attributes][:id] = event.availability_id
results[:events].push status: !!event.update(event_params), event: event # rubocop:disable Style/DoubleNegation
rescue StandardError => err
results[:events].push status: false, event: event, error: err.try(:record).try(:class).try(:name), message: err.message
event_params[:availability_attributes][:id] = base_event.availability_id
results[:events].push status: !!base_event.update(event_params), event: base_event # rubocop:disable Style/DoubleNegation
rescue StandardError => e
results[:events].push status: false, event: base_event, error: e.try(:record).try(:class).try(:name), message: e.message
end
results[:slots].concat(update_slots(event.availability_id, original_slots_ids))
results[:slots].concat(update_slots(base_event.availability_id, original_slots_ids))
results
end
@ -190,13 +130,81 @@ class EventService
avail = Availability.find(availability_id)
Slot.where(id: original_slots_ids).each do |slot|
results.push(
status: !!slot.update_attributes(availability_id: availability_id, start_at: avail.start_at, end_at: avail.end_at), # rubocop:disable Style/DoubleNegation
status: !!slot.update(availability_id: availability_id, start_at: avail.start_at, end_at: avail.end_at), # rubocop:disable Style/DoubleNegation
slot: slot
)
rescue StandardError => err
results.push status: false, slot: s, error: err.try(:record).try(:class).try(:name), message: err.message
rescue StandardError => e
results.push status: false, slot: s, error: e.try(:record).try(:class).try(:name), message: e.message
end
results
end
def occurrence_params(base_event, occurrence, event_params)
start_at = event_params['availability_attributes']['start_at']
end_at = event_params['availability_attributes']['end_at']
e_params = event_params.merge(
availability_id: occurrence.availability_id,
availability_attributes: {
id: occurrence.availability_id,
start_at: occurrence.availability.start_at.change(hour: start_at.hour, min: start_at.min),
end_at: occurrence.availability.end_at.change(hour: end_at.hour, min: end_at.min),
available_type: occurrence.availability.available_type
}
)
epc_attributes = price_categories_attributes(base_event, occurrence, event_params)
unless epc_attributes.empty?
e_params = e_params.merge(
event_price_categories_attributes: epc_attributes
)
end
ef_attributes = file_attributes(base_event, occurrence, event_params)
e_params.merge(
event_files_attributes: ef_attributes
)
end
def price_categories_attributes(base_event, occurrence, event_params)
epc_attributes = []
event_params['event_price_categories_attributes']&.each do |epca|
epc = occurrence.event_price_categories.find_by(price_category_id: epca['price_category_id'])
if epc
epc_attributes.push(
id: epc.id,
price_category_id: epc.price_category_id,
amount: epca['amount'],
_destroy: epca['_destroy']
)
elsif epca['id'].present?
event_price = base_event.event_price_categories.find(epca['id'])
epc_attributes.push(
price_category_id: epca['price_category_id'],
amount: event_price.amount,
_destroy: ''
)
end
end
epc_attributes
end
def file_attributes(base_event, occurrence, event_params)
ef_attributes = []
event_params['event_files_attributes']&.each do |efa|
if efa['id'].present?
event_file = base_event.event_files.find(efa['id'])
ef = occurrence.event_files.find_by(attachment: event_file.attachment.file.filename)
if ef
ef_attributes.push(
id: ef.id,
attachment: efa['attachment'],
_destroy: efa['_destroy']
)
end
else
ef_attributes.push(efa)
end
end
ef_attributes
end
end
end

View File

@ -2,20 +2,14 @@
# Provides methods for Groups
class GroupService
def self.list(operator, filters = {})
groups = if operator&.admin?
Group.where(nil)
else
Group.where.not(slug: 'admins')
end
def self.list(filters = {})
groups = Group.where(nil)
if filters[:disabled].present?
state = filters[:disabled] == 'false' ? [nil, false] : true
groups = groups.where(disabled: state)
end
groups = groups.where.not(slug: 'admins') if filters[:admins] == 'false'
groups
end
end

View File

@ -73,7 +73,7 @@ class InvoicesService
method = if payment_method
payment_method
else
operator&.admin? || (operator&.manager? && operator != user) ? nil : 'card'
(operator&.admin? || operator&.manager?) && operator != user ? nil : 'card'
end
invoice = Invoice.new(

View File

@ -28,7 +28,7 @@ class Members::ListService
if params[:search].size.positive?
@query = @query.where('users.username ILIKE :search OR ' \
'profiles.first_name ILIKE :search OR ' \
'profiles.last_name ILIKE :search OR ' \
'profiles.last_name ILIKE :search OR ' \
'profiles.phone ILIKE :search OR ' \
'email ILIKE :search OR ' \
'groups.name ILIKE :search OR ' \
@ -41,20 +41,21 @@ class Members::ListService
@query
end
def search(current_user, query, subscription, include_admins = 'false')
def search(current_user, query, subscription)
members = User.includes(:profile, :statistic_profile)
.joins(:profile,
:statistic_profile,
:roles,
'LEFT JOIN "subscriptions" ON "subscriptions"."statistic_profile_id" = "statistic_profiles"."id" AND ' \
'"subscriptions"."created_at" = ( ' \
'SELECT max("created_at") ' \
'FROM "subscriptions" ' \
'WHERE "statistic_profile_id" = "statistic_profiles"."id")')
'SELECT max("created_at") ' \
'FROM "subscriptions" ' \
'WHERE "statistic_profile_id" = "statistic_profiles"."id")')
.where("users.is_active = 'true'")
.limit(50)
query.downcase.split(' ').each do |word|
members = members.where('lower(f_unaccent(profiles.first_name)) ~ :search OR ' \
query.downcase.split.each do |word|
members = members.where('lower(f_unaccent(users.username)) ~ :search OR ' \
'lower(f_unaccent(profiles.first_name)) ~ :search OR ' \
'lower(f_unaccent(profiles.last_name)) ~ :search',
search: word)
end
@ -65,13 +66,11 @@ class Members::ListService
members = members.where("users.is_allow_contact = 'true'")
elsif subscription == 'true'
# only admins have the ability to filter by subscription
members = members.where('subscriptions.id IS NOT NULL AND subscriptions.expiration_date >= :now', now: Date.today.to_s)
members = members.where('subscriptions.id IS NOT NULL AND subscriptions.expiration_date >= :now', now: Time.zone.today.to_s)
elsif subscription == 'false'
members = members.where('subscriptions.id IS NULL OR subscriptions.expiration_date < :now', now: Date.today.to_s)
members = members.where('subscriptions.id IS NULL OR subscriptions.expiration_date < :now', now: Time.zone.today.to_s)
end
members = members.where("roles.name = 'member' OR roles.name = 'manager'") if include_admins == 'false' || include_admins.blank?
members.to_a.filter(&:valid?)
end

View File

@ -15,12 +15,6 @@ class Members::MembersService
return false
end
if admin_group_change?(params)
# an admin cannot change his group
@member.errors.add(:group_id, I18n.t('members.admins_cant_change_group'))
return false
end
group_changed = user_group_change?(params)
ex_group = @member.group
@ -130,9 +124,7 @@ class Members::MembersService
@member.remove_role ex_role
@member.add_role new_role
# if the new role is 'admin', then change the group to the admins group, otherwise to change to the provided group
group_id = new_role == 'admin' ? Group.find_by(slug: 'admins').id : new_group_id
@member.update(group_id: group_id)
@member.update(group_id: new_group_id)
# notify
NotificationCenter.call type: 'notify_user_role_update',
@ -176,10 +168,6 @@ class Members::MembersService
params[:group_id] && @member.group_id != params[:group_id].to_i && !@member.subscribed_plan.nil?
end
def admin_group_change?(params)
params[:group_id] && params[:group_id].to_i != Group.find_by(slug: 'admins').id && @member.admin?
end
def user_group_change?(params)
@member.group_id && params[:group_id] && @member.group_id != params[:group_id].to_i
end

View File

@ -17,35 +17,14 @@ class PaymentScheduleService
ps = PaymentSchedule.new(total: price + other_items, coupon: coupon)
deadlines = plan.duration / 1.month
per_month = (price / deadlines).truncate
adjustment = if per_month * deadlines + other_items.truncate != ps.total
ps.total - (per_month * deadlines + other_items.truncate)
else
adjustment = if (per_month * deadlines) + other_items.truncate == ps.total
0
else
ps.total - ((per_month * deadlines) + other_items.truncate)
end
items = []
(0..deadlines - 1).each do |i|
date = (start_at || DateTime.current) + i.months
details = { recurring: per_month }
amount = if i.zero?
details[:adjustment] = adjustment.truncate
details[:other_items] = other_items.truncate
per_month + adjustment.truncate + other_items.truncate
else
per_month
end
if coupon
cs = CouponService.new
if (coupon.validity_per_user == 'once' && i.zero?) || coupon.validity_per_user == 'forever'
details[:without_coupon] = amount
amount = cs.apply(amount, coupon)
end
end
items.push PaymentScheduleItem.new(
amount: amount,
due_date: date,
payment_schedule: ps,
details: details
)
items.push compute_deadline(i, ps, per_month, adjustment, other_items, coupon: coupon, schedule_start_at: start_at)
end
ps.start_at = start_at
ps.total = items.map(&:amount).reduce(:+)
@ -54,9 +33,35 @@ class PaymentScheduleService
{ payment_schedule: ps, items: items }
end
def compute_deadline(deadline_index, payment_schedule, price_per_month, adjustment_price, other_items_price,
coupon: nil, schedule_start_at: nil)
date = (schedule_start_at || DateTime.current) + deadline_index.months
details = { recurring: price_per_month }
amount = if deadline_index.zero?
details[:adjustment] = adjustment_price.truncate
details[:other_items] = other_items_price.truncate
price_per_month + adjustment_price.truncate + other_items_price.truncate
else
price_per_month
end
if coupon
cs = CouponService.new
if (coupon.validity_per_user == 'once' && deadline_index.zero?) || coupon.validity_per_user == 'forever'
details[:without_coupon] = amount
amount = cs.apply(amount, coupon)
end
end
PaymentScheduleItem.new(
amount: amount,
due_date: date,
payment_schedule: payment_schedule,
details: details
)
end
def create(objects, total, customer, coupon: nil, operator: nil, payment_method: nil,
payment_id: nil, payment_type: nil)
subscription = objects.find { |item| item.class == Subscription }
subscription = objects.find { |item| item.instance_of?(Subscription) }
schedule = compute(subscription.plan, total, customer, coupon: coupon, start_at: subscription.start_at)
ps = schedule[:payment_schedule]
@ -80,7 +85,7 @@ class PaymentScheduleService
def build_objects(objects)
res = []
res.push(PaymentScheduleObject.new(object: objects[0], main: true))
objects[1..-1].each do |object|
objects[1..].each do |object|
res.push(PaymentScheduleObject.new(object: object))
end
res
@ -117,7 +122,7 @@ class PaymentScheduleService
# save the results
invoice.save
payment_schedule_item.update_attributes(invoice_id: invoice.id)
payment_schedule_item.update(invoice_id: invoice.id)
end
##
@ -133,7 +138,6 @@ class PaymentScheduleService
.page(page)
.per(size)
unless filters[:reference].nil?
ps = ps.where(
'payment_schedules.reference LIKE :search',
@ -168,7 +172,7 @@ class PaymentScheduleService
payment_schedule.ordered_items.each do |item|
next if item.state == 'paid'
item.update_attributes(state: 'canceled')
item.update(state: 'canceled')
end
# cancel subscription
subscription = payment_schedule.payment_schedule_objects.find { |pso| pso.object_type == Subscription.name }.subscription
@ -192,7 +196,7 @@ class PaymentScheduleService
##
def reset_erroneous_payment_schedule_items(payment_schedule)
results = payment_schedule.payment_schedule_items.where(state: %w[error gateway_canceled]).map do |item|
item.update_attributes(state: item.due_date < DateTime.current ? 'pending' : 'new')
item.update(state: item.due_date < DateTime.current ? 'pending' : 'new')
end
results.reduce(true) { |acc, item| acc && item }
end
@ -208,7 +212,10 @@ class PaymentScheduleService
}
# the subscription and reservation items
subscription = payment_schedule_item.payment_schedule.payment_schedule_objects.find { |pso| pso.object_type == Subscription.name }.subscription
subscription = payment_schedule_item.payment_schedule
.payment_schedule_objects
.find { |pso| pso.object_type == Subscription.name }
.subscription
if payment_schedule_item.payment_schedule.main_object.object_type == Reservation.name
details[:reservation] = payment_schedule_item.details['other_items']
reservation = payment_schedule_item.payment_schedule.main_object.reservation
@ -227,7 +234,10 @@ class PaymentScheduleService
##
def complete_next_invoice(payment_schedule_item, invoice)
# the subscription item
subscription = payment_schedule_item.payment_schedule.payment_schedule_objects.find { |pso| pso.object_type == Subscription.name }.subscription
subscription = payment_schedule_item.payment_schedule
.payment_schedule_objects
.find { |pso| pso.object_type == Subscription.name }
.subscription
# sub-price for the subscription
details = { subscription: payment_schedule_item.details['recurring'] }
@ -244,7 +254,7 @@ class PaymentScheduleService
return unless subscription
generate_subscription_item(invoice, subscription, payment_details, reservation.nil?)
generate_subscription_item(invoice, subscription, payment_details, main: reservation.nil?)
end
##
@ -271,7 +281,7 @@ class PaymentScheduleService
# Generate an InvoiceItem for the given subscription and save it in invoice.invoice_items.
# This method must be called only with a valid subscription
##
def generate_subscription_item(invoice, subscription, payment_details, main = true)
def generate_subscription_item(invoice, subscription, payment_details, main: true)
raise TypeError unless subscription
invoice.invoice_items.push InvoiceItem.new(
@ -291,11 +301,9 @@ class PaymentScheduleService
total = invoice.invoice_items.map(&:amount).map(&:to_i).reduce(:+)
unless coupon.nil?
if (coupon.validity_per_user == 'once' && payment_schedule_item.first?) || coupon.validity_per_user == 'forever'
total = CouponService.new.apply(total, coupon, user.id)
invoice.coupon_id = coupon.id
end
if !coupon.nil? && ((coupon.validity_per_user == 'once' && payment_schedule_item.first?) || coupon.validity_per_user == 'forever')
total = CouponService.new.apply(total, coupon, user.id)
invoice.coupon_id = coupon.id
end
invoice.total = total

View File

@ -38,7 +38,7 @@ class PrepaidPackService
# total number of minutes in the reservation's slots
slots_minutes = reservation.slots.map do |slot|
(slot.end_at.to_time - slot.start_at.to_time) / SECONDS_PER_MINUTE
(slot.end_at.to_time - slot.start_at.to_time) / 60.0
end
reservation_minutes = slots_minutes.reduce(:+) || 0

View File

@ -4,31 +4,73 @@
# Due to the way the controller updates the settings, we cannot safely use ActiveRecord's callbacks (eg. after_update, after_commit...)
# so this service provides a wrapper around these operations.
class SettingService
def self.before_update(setting)
return false if Rails.application.secrets.locked_settings.include? setting.name
class << self
def before_update(setting)
return false if Rails.application.secrets.locked_settings.include? setting.name
true
end
true
end
def self.after_update(setting)
# update the stylesheet
Stylesheet.theme&.rebuild! if %w[main_color secondary_color].include? setting.name
Stylesheet.home_page&.rebuild! if setting.name == 'home_css'
def after_update(setting)
update_theme_stylesheet(setting)
update_home_stylesheet(setting)
notify_privacy_update(setting)
sync_stripe_objects(setting)
build_stats(setting)
export_projects_to_openlab(setting)
validate_admins(setting)
end
private
# rebuild the theme stylesheet
def update_theme_stylesheet(setting)
return unless %w[main_color secondary_color].include? setting.name
Stylesheet.theme&.rebuild!
end
# rebuild the home page stylesheet
def update_home_stylesheet(setting)
return unless setting.name == 'home_css'
Stylesheet.home_page&.rebuild!
end
# notify about a change in privacy policy
NotifyPrivacyUpdateWorker.perform_async(setting.id) if setting.name == 'privacy_body'
def notify_privacy_update(setting)
return unless setting.name == 'privacy_body'
NotifyPrivacyUpdateWorker.perform_async(setting.id)
end
# sync all objects on stripe
SyncObjectsOnStripeWorker.perform_async(setting.history_values.last&.invoicing_profile&.user&.id) if setting.name == 'stripe_secret_key'
def sync_stripe_objects(setting)
return unless setting.name == 'stripe_secret_key'
# generate statistics
PeriodStatisticsWorker.perform_async(setting.previous_update) if setting.name == 'statistics_module' && setting.value == 'true'
SyncObjectsOnStripeWorker.perform_async(setting.history_values.last&.invoicing_profile&.user&.id)
end
# generate the statistics since the last update
def build_stats(setting)
return unless setting.name == 'statistics_module' && setting.value == 'true'
PeriodStatisticsWorker.perform_async(setting.previous_update)
end
# export projects to openlab
if %w[openlab_app_id openlab_app_secret].include? setting.name
if Setting.get('openlab_app_id').present? && Setting.get('openlab_app_secret').present?
Project.all.each { |pr| pr.openlab_create }
end
def export_projects_to_openlab(setting)
return unless %w[openlab_app_id openlab_app_secret].include?(setting.name) &&
Setting.get('openlab_app_id').present? && Setting.get('openlab_app_secret').present?
Project.all.each(&:openlab_create)
end
# automatically validate the admins
def validate_admins(setting)
return unless setting.name == 'user_validation_required' && setting.value == 'true'
User.admins.each { |admin| admin.update(validated_at: DateTime.current) if admin.validated_at.nil? }
end
end
end

View File

@ -7,39 +7,30 @@ class Statistics::Builders::ReservationsBuilderService
class << self
def build(options = default_options)
# machine/space/training list
%w[machine space training].each do |category|
%w[machine space training event].each do |category|
Statistics::FetcherService.send("reservations_#{category}_list", options).each do |r|
%w[booking hour].each do |type|
stat = Stats::Machine.new({ date: format_date(r[:date]),
type: type,
subType: r["#{category}_type".to_sym],
ca: r[:ca],
machineId: r["#{category}_id".to_sym],
name: r["#{category}_name".to_sym],
reservationId: r[:reservation_id] }.merge(user_info_stat(r)))
stat.stat = (type == 'booking' ? 1 : r[:nb_hours])
stat = "Stats::#{category.capitalize}"
.constantize
.new({ date: format_date(r[:date]),
type: type,
subType: r["#{category}_type".to_sym],
ca: r[:ca],
name: r["#{category}_name".to_sym],
reservationId: r[:reservation_id] }.merge(user_info_stat(r)))
stat[:stat] = (type == 'booking' ? 1 : r[:nb_hours])
stat["#{category}Id".to_sym] = r["#{category}_id".to_sym]
if category == 'event'
stat[:eventDate] = r[:event_date]
stat[:eventTheme] = r[:event_theme]
stat[:ageRange] = r[:age_range]
end
stat.save
end
end
end
# event list
Statistics::FetcherService.reservations_event_list(options).each do |r|
%w[booking hour].each do |type|
stat = Stats::Event.new({ date: format_date(r[:date]),
type: type,
subType: r[:event_type],
ca: r[:ca],
eventId: r[:event_id],
name: r[:event_name],
eventDate: r[:event_date],
reservationId: r[:reservation_id],
eventTheme: r[:event_theme],
ageRange: r[:age_range] }.merge(user_info_stat(r)))
stat.stat = (type == 'booking' ? r[:nb_places] : r[:nb_hours])
stat.save
end
end
end
end
end

View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
# helpers to read data from a user
class UserGetterService
def initialize(user)
@user = user
end
def read_attribute(attribute)
parsed = /^(user|profile)\.(.+)$/.match(attribute)
case parsed[1]
when 'user'
@user[parsed[2].to_sym]
when 'profile'
case attribute
when 'profile.avatar'
@user.profile.user_avatar.remote_attachment_url
when 'profile.address'
@user.invoicing_profile.address&.address
when 'profile.organization_name'
@user.invoicing_profile.organization&.name
when 'profile.organization_address'
@user.invoicing_profile.organization&.address&.address
when 'profile.gender'
@user.statistic_profile.gender
when 'profile.birthday'
@user.statistic_profile.birthday
else
@user.profile[parsed[2].to_sym]
end
else
nil
end
end
end

View File

@ -2,67 +2,66 @@
# helpers for managing users with special roles
class UserService
def self.create_partner(params)
generated_password = SecurePassword.generate
group_id = Group.first.id
user = User.new(
email: params[:email],
username: "#{params[:first_name]}#{params[:last_name]}".parameterize,
password: generated_password,
password_confirmation: generated_password,
group_id: group_id
)
user.build_profile(
first_name: params[:first_name],
last_name: params[:last_name],
phone: '0000000000'
)
user.build_statistic_profile(
gender: true,
birthday: DateTime.current
)
class << self
def create_partner(params)
generated_password = SecurePassword.generate
group_id = Group.first.id
user = User.new(
email: params[:email],
username: "#{params[:first_name]}#{params[:last_name]}".parameterize,
password: generated_password,
password_confirmation: generated_password,
group_id: group_id
)
user.build_profile(
first_name: params[:first_name],
last_name: params[:last_name],
phone: '0000000000'
)
user.build_statistic_profile(
gender: true,
birthday: DateTime.current
)
saved = user.save
if saved
user.remove_role :member
user.add_role :partner
saved = user.save
if saved
user.remove_role :member
user.add_role :partner
end
{ saved: saved, user: user }
end
{ saved: saved, user: user }
end
def self.create_admin(params)
generated_password = SecurePassword.generate
admin = User.new(params.merge(password: generated_password))
admin.send :set_slug
def create_admin(params)
generated_password = SecurePassword.generate
admin = User.new(params.merge(password: generated_password, validated_at: DateTime.current))
admin.send :set_slug
# we associate the admin group to prevent linking any other 'normal' group (which won't be deletable afterwards)
admin.group = Group.find_by(slug: 'admins')
# if the authentication is made through an SSO, generate a migration token
admin.generate_auth_migration_token unless AuthProvider.active.providable_type == DatabaseProvider.name
# if the authentication is made through an SSO, generate a migration token
admin.generate_auth_migration_token unless AuthProvider.active.providable_type == DatabaseProvider.name
saved = admin.save
if saved
admin.send_confirmation_instructions
admin.add_role(:admin)
admin.remove_role(:member)
UsersMailer.notify_user_account_created(admin, generated_password).deliver_later
saved = admin.save
if saved
admin.send_confirmation_instructions
admin.add_role(:admin)
admin.remove_role(:member)
UsersMailer.notify_user_account_created(admin, generated_password).deliver_later
end
{ saved: saved, user: admin }
end
{ saved: saved, user: admin }
end
def self.create_manager(params)
generated_password = SecurePassword.generate
manager = User.new(params.merge(password: generated_password))
manager.send :set_slug
def create_manager(params)
generated_password = SecurePassword.generate
manager = User.new(params.merge(password: generated_password))
manager.send :set_slug
saved = manager.save
if saved
manager.send_confirmation_instructions
manager.add_role(:manager)
manager.remove_role(:member)
UsersMailer.notify_user_account_created(manager, generated_password).deliver_later
saved = manager.save
if saved
manager.send_confirmation_instructions
manager.add_role(:manager)
manager.remove_role(:member)
UsersMailer.notify_user_account_created(manager, generated_password).deliver_later
end
{ saved: saved, user: manager }
end
{ saved: saved, user: manager }
end
end

View File

@ -0,0 +1,73 @@
# frozen_string_literal: true
# helpers to assign data to a user
class UserSetterService
def initialize(user)
@user = user
end
def assign_avatar(data)
@user.profile.user_avatar ||= UserAvatar.new
@user.profile.user_avatar.remote_attachment_url = data
end
def assign_address(data)
@user.invoicing_profile ||= InvoicingProfile.new
@user.invoicing_profile.address ||= Address.new
@user.invoicing_profile.address.address = data
end
def assign_organization_name(data)
@user.invoicing_profile ||= InvoicingProfile.new
@user.invoicing_profile.organization ||= Organization.new
@user.invoicing_profile.organization.name = data
end
def assign_organization_address(data)
@user.invoicing_profile ||= InvoicingProfile.new
@user.invoicing_profile.organization ||= Organization.new
@user.invoicing_profile.organization.address ||= Address.new
@user.invoicing_profile.organization.address.address = data
end
def assign_gender(data)
@user.statistic_profile ||= StatisticProfile.new
@user.statistic_profile.gender = data
end
def assign_birthday(data)
@user.statistic_profile ||= StatisticProfile.new
@user.statistic_profile.birthday = data
end
def assign_profile_attribute(attribute, data)
@user.profile[attribute[8..].to_sym] = data
end
def assign_user_attribute(attribute, data)
@user[attribute[5..].to_sym] = data
end
def assign_attibute(attribute, data)
if attribute.to_s.start_with? 'user.'
assign_user_attribute(attribute, data)
elsif attribute.to_s.start_with? 'profile.'
case attribute.to_s
when 'profile.avatar'
assign_avatar(data)
when 'profile.address'
assign_address(data)
when 'profile.organization_name'
assign_organization_name(data)
when 'profile.organization_address'
assign_organization_address(data)
when 'profile.gender'
assign_gender(data)
when 'profile.birthday'
assign_birthday(data)
else
assign_profile_attribute(attribute, data)
end
end
end
end

View File

@ -37,7 +37,9 @@ class VatHistoryService
private
def vat_history(vat_rate_type)
# This method is really complex and cannot be simplified using the current data model
# As a futur improvement, we should save the VAT rate for each invoice_item in the DB
def vat_history(vat_rate_type) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
chronology = []
end_date = DateTime.current
Setting.find_by(name: 'invoice_VAT-active').history_values.order(created_at: 'DESC').each do |v|
@ -92,7 +94,7 @@ class VatHistoryService
# when the VAT rate was enabled, set the date it was enabled and the rate
range = chronology.find { |p| rate.created_at.to_i.between?(p[:start].to_i, p[:end].to_i) }
date = range[:enabled] ? rate.created_at : range[:end]
date_rates.push(date: date, rate: rate.value.to_i) unless date_rates.find { |d| d[:date] == date }
date_rates.push(date: date, rate: rate.value.to_f) unless date_rates.find { |d| d[:date].to_i == date.to_i }
end
chronology.reverse_each do |period|
# when the VAT rate was disabled, set the date it was disabled and rate=0

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
json.extract! machine, :id, :name, :slug, :disabled
if machine.machine_image
json.machine_image_attributes do
json.id machine.machine_image.id
json.attachment_name machine.machine_image.attachment_identifier
json.attachment_url machine.machine_image.attachment.url
end
end

View File

@ -1,7 +1,5 @@
# frozen_string_literal: true
json.array!(@machines) do |machine|
json.extract! machine, :id, :name, :slug, :disabled
json.machine_image machine.machine_image.attachment.medium.url if machine.machine_image
json.partial! 'api/machines/machine', machine: machine
end

View File

@ -1,10 +1,11 @@
# frozen_string_literal: true
json.extract! @machine, :id, :name, :description, :spec, :disabled, :slug
json.machine_image @machine.machine_image.attachment.large.url if @machine.machine_image
json.partial! 'api/machines/machine', machine: @machine
json.extract! @machine, :description, :spec
json.machine_files_attributes @machine.machine_files do |f|
json.id f.id
json.attachment f.attachment_identifier
json.attachment_name f.attachment_identifier
json.attachment_url f.attachment_url
end
json.trainings @machine.trainings.each, :id, :name, :disabled

View File

@ -1,27 +1,29 @@
# frozen_string_literal: true
# asynchronously export the statistics to an excel file and send the result by email
class StatisticsExportWorker
include Sidekiq::Worker
def perform(export_id)
export = Export.find(export_id)
unless export.user.admin?
raise SecurityError, 'Not allowed to export'
end
raise SecurityError, 'Not allowed to export' unless export.user.admin?
unless export.category == 'statistics'
raise KeyError, 'Wrong worker called'
end
raise KeyError, 'Wrong worker called' unless export.category == 'statistics'
service = StatisticsExportService.new
method_name = "export_#{export.export_type}"
if %w(account event machine project subscription training space global).include?(export.export_type) and service.respond_to?(method_name)
service.public_send(method_name, export)
NotificationCenter.call type: :notify_admin_export_complete,
receiver: export.user,
attached_object: export
unless %w[account event machine project subscription training space global].include?(export.export_type) &&
service.respond_to?(method_name)
return
end
service.public_send(method_name, export)
NotificationCenter.call type: :notify_admin_export_complete,
receiver: export.user,
attached_object: export
end
end

View File

@ -2,18 +2,16 @@
ActiveRecord::Base.class_eval do
def dump_fixture
fixture_file = "#{Rails.root}/test/fixtures/#{self.class.table_name}.yml"
fixture_file = Rails.root.join("test/fixtures/#{self.class.table_name}.yml")
File.open(fixture_file, 'a') do |f|
f.puts({ "#{self.class.table_name.singularize}_#{id}" => attributes }.
to_yaml.sub!(/---\s?/, "\n"))
f.puts({ "#{self.class.table_name.singularize}_#{id}" => attributes }.to_yaml.sub!(/---\s?/, "\n"))
end
end
def self.dump_fixtures
fixture_file = "#{Rails.root}/test/fixtures/#{table_name}.yml"
fixture_file = Rails.root.join("test/fixtures/#{table_name}.yml")
mode = (File.exist?(fixture_file) ? 'a' : 'w')
File.open(fixture_file, mode) do |f|
if attribute_names.include?('id')
all.each do |instance|
f.puts({ "#{table_name.singularize}_#{instance.id}" => instance.attributes }.to_yaml.sub!(/---\s?/, "\n"))

View File

@ -1,8 +0,0 @@
# Be sure to restart your server when you modify this file.
# ActiveSupport::Reloader.to_prepare do
# ApplicationController.renderer.defaults.merge!(
# http_host: 'example.org',
# https: false
# )
# end

View File

@ -1,39 +0,0 @@
# # frozen_string_literal: true
#
# # Be sure to restart your server when you modify this file.
#
# # Version of your assets, change this if you want to expire all your assets.
# Rails.application.config.assets.version = '1.0'
#
# # allow use rails helpers in angular templates
# Rails.application.config.assets.configure do |env|
# env.context_class.class_eval do
# include ActionView::Helpers
# include Rails.application.routes.url_helpers
# end
# end
#
# # Add additional assets to the asset load path.
# # Rails.application.config.assets.paths << Emoji.images_path
# # Add Yarn node_modules folder to the asset load path.
# Rails.application.config.assets.paths << Rails.root.join('node_modules')
#
# # Precompile additional assets.
# # application.js, application.css, and all non-JS/CSS in the app/assets
# # folder are already added.
# # Rails.application.config.assets.precompile += %w( admin.js admin.css )
#
# Rails.application.config.assets.precompile += %w[
# fontawesome-webfont.eot
# fontawesome-webfont.woff
# fontawesome-webfont.svg
# fontawesome-webfont.ttf
# ]
# Rails.application.config.assets.precompile += %w[app.printer.css]
#
# Rails.application.config.assets.precompile += %w[
# angular-i18n/angular-locale_*.js
# moment/locale/*.js
# summernote/lang/*.js
# fullcalendar/dist/lang/*.js
# ]

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