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

Merge branch 'dev' for release 5.4.21

This commit is contained in:
Sylvain 2022-10-05 13:24:22 +02:00
commit 1533adeb2d
14 changed files with 301 additions and 73 deletions

View File

@ -1,5 +1,11 @@
# Changelog Fab-manager
## v5.4.21 2022 October 05
- Ability to dismiss a user to a lower privileged role
- Fix a bug: unable to generate statistics
- [TODO DEPLOY] `rails fablab:maintenance:regenerate_statistics[2022,08]`
## v5.4.20 2022 September 27
- Fix a bug: unable to show the daily view of the public agenda, if it contains trainings or events

View File

@ -18,15 +18,7 @@ class API::MembersController < API::ApiController
end
def last_subscribed
@query = User.active.with_role(:member)
.includes(:statistic_profile, profile: [:user_avatar])
.where('is_allow_contact = true AND confirmed_at IS NOT NULL')
.order('created_at desc')
.limit(params[:last])
# remove unmerged profiles from list
@members = @query.to_a
@members.delete_if(&:need_completion?)
@query, @members = Members::MembersService.last_registered(params[:last])
@requested_attributes = ['profile']
render :index
@ -74,9 +66,7 @@ class API::MembersController < API::ApiController
def export_subscriptions
authorize :export
export = Export.where(category: 'users', export_type: 'subscriptions')
.where('created_at > ?', Subscription.maximum('updated_at'))
.last
export = ExportService.last_export('users/subscription')
if export.nil? || !FileTest.exist?(export.file)
@export = Export.new(category: 'users', export_type: 'subscriptions', user: current_user)
if @export.save
@ -85,7 +75,7 @@ class API::MembersController < API::ApiController
render json: @export.errors, status: :unprocessable_entity
end
else
send_file File.join(Rails.root, export.file),
send_file Rails.root.join(export.file),
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
disposition: 'attachment'
end
@ -95,9 +85,7 @@ class API::MembersController < API::ApiController
def export_reservations
authorize :export
export = Export.where(category: 'users', export_type: 'reservations')
.where('created_at > ?', Reservation.maximum('updated_at'))
.last
export = ExportService.last_export('users/reservations')
if export.nil? || !FileTest.exist?(export.file)
@export = Export.new(category: 'users', export_type: 'reservations', user: current_user)
if @export.save
@ -106,7 +94,7 @@ class API::MembersController < API::ApiController
render json: @export.errors, status: :unprocessable_entity
end
else
send_file File.join(Rails.root, export.file),
send_file Rails.root.join(export.file),
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
disposition: 'attachment'
end
@ -115,17 +103,7 @@ class API::MembersController < API::ApiController
def export_members
authorize :export
last_update = [
User.members.maximum('updated_at'),
Profile.where(user_id: User.members).maximum('updated_at'),
InvoicingProfile.where(user_id: User.members).maximum('updated_at'),
StatisticProfile.where(user_id: User.members).maximum('updated_at'),
Subscription.maximum('updated_at') || DateTime.current
].max
export = Export.where(category: 'users', export_type: 'members')
.where('created_at > ?', last_update)
.last
export = ExportService.last_export('users/members')
if export.nil? || !FileTest.exist?(export.file)
@export = Export.new(category: 'users', export_type: 'members', user: current_user)
if @export.save
@ -134,7 +112,7 @@ class API::MembersController < API::ApiController
render json: @export.errors, status: :unprocessable_entity
end
else
send_file File.join(Rails.root, export.file),
send_file Rails.root.join(export.file),
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
disposition: 'attachment'
end
@ -158,8 +136,8 @@ class API::MembersController < API::ApiController
else
render json: @member.errors, status: :unprocessable_entity
end
rescue DuplicateIndexError => error
render json: { error: t('members.please_input_the_authentication_code_sent_to_the_address', EMAIL: error.message) },
rescue DuplicateIndexError => e
render json: { error: t('members.please_input_the_authentication_code_sent_to_the_address', EMAIL: e.message) },
status: :unprocessable_entity
end
else
@ -176,7 +154,6 @@ class API::MembersController < API::ApiController
query = Members::ListService.list(query_params)
@max_members = query.except(:offset, :limit, :order).count
@members = query.to_a
end
def search
@ -196,7 +173,7 @@ class API::MembersController < API::ApiController
render json: { tours: [params[:tour]] }
else
tours = "#{@member.profile.tours} #{params[:tour]}"
@member.profile.update_attributes(tours: tours.strip)
@member.profile.update(tours: tours.strip)
render json: { tours: @member.profile.tours.split }
end
@ -205,31 +182,8 @@ class API::MembersController < API::ApiController
def update_role
authorize @member
# we do not allow dismissing a user to a lower role
if params[:role] == 'member'
render 403 and return if @member.role == 'admin' || @member.role == 'manager'
elsif params[:role] == 'manager'
render 403 and return if @member.role == 'admin'
end
# do nothing if the role does not change
render json: @member and return if params[:role] == @member.role
ex_role = @member.role.to_sym
@member.remove_role ex_role
@member.add_role params[:role]
# if the new role is 'admin', then change the group to the admins group
@member.update_attributes(group_id: Group.find_by(slug: 'admins').id) if params[:role] == 'admin'
NotificationCenter.call type: 'notify_user_role_update',
receiver: @member,
attached_object: @member
NotificationCenter.call type: 'notify_admins_role_update',
receiver: User.admins_and_managers,
attached_object: @member,
meta_data: { ex_role: ex_role }
service = Members::MembersService.new(@member)
service.update_role(params[:role], params[:group_id])
render json: @member
end
@ -265,12 +219,14 @@ class API::MembersController < API::ApiController
profile_attributes: [:id, :first_name, :last_name, :phone, :interest, :software_mastered, :website, :job,
:facebook, :twitter, :google_plus, :viadeo, :linkedin, :instagram, :youtube, :vimeo,
:dailymotion, :github, :echosciences, :pinterest, :lastfm, :flickr,
user_avatar_attributes: %i[id attachment destroy]],
{ user_avatar_attributes: %i[id attachment destroy] }],
invoicing_profile_attributes: [
:id, :organization,
address_attributes: %i[id address],
organization_attributes: [:id, :name, address_attributes: %i[id address]],
user_profile_custom_fields_attributes: %i[id value invoicing_profile_id profile_custom_field_id]
{
address_attributes: %i[id address],
organization_attributes: [:id, :name, { address_attributes: %i[id address] }],
user_profile_custom_fields_attributes: %i[id value invoicing_profile_id profile_custom_field_id]
}
],
statistic_profile_attributes: %i[id gender birthday])
@ -280,14 +236,16 @@ class API::MembersController < API::ApiController
profile_attributes: [:id, :first_name, :last_name, :phone, :interest, :software_mastered, :website, :job,
:facebook, :twitter, :google_plus, :viadeo, :linkedin, :instagram, :youtube, :vimeo,
:dailymotion, :github, :echosciences, :pinterest, :lastfm, :flickr,
user_avatar_attributes: %i[id attachment destroy]],
{ user_avatar_attributes: %i[id attachment destroy] }],
invoicing_profile_attributes: [
:id, :organization,
address_attributes: %i[id address],
organization_attributes: [:id, :name, address_attributes: %i[id address]],
user_profile_custom_fields_attributes: %i[id value invoicing_profile_id profile_custom_field_id]
{
address_attributes: %i[id address],
organization_attributes: [:id, :name, { address_attributes: %i[id address] }],
user_profile_custom_fields_attributes: %i[id value invoicing_profile_id profile_custom_field_id]
}
],
statistic_profile_attributes: [:id, :gender, :birthday, training_ids: []])
statistic_profile_attributes: [:id, :gender, :birthday, { training_ids: [] }])
end
end

View File

@ -1,7 +1,7 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { serialize } from 'object-to-formdata';
import { User, UserIndexFilter } from '../models/user';
import { User, UserIndexFilter, UserRole } from '../models/user';
export default class MemberAPI {
static async list (filters: UserIndexFilter): Promise<Array<User>> {
@ -35,6 +35,11 @@ export default class MemberAPI {
return res?.data;
}
static async updateRole (user: User, role: UserRole, groupId?: number): Promise<User> {
const res: AxiosResponse<User> = await apiClient.patch(`/api/members/${user.id}/update_role`, { role, group_id: groupId });
return res?.data;
}
static async current (): Promise<User> {
const res: AxiosResponse<User> = await apiClient.get('/api/members/current');
return res?.data;

View File

@ -0,0 +1,129 @@
import React, { useEffect, useState } from 'react';
import { FabModal, ModalSize } from '../base/fab-modal';
import { User, UserRole } from '../../models/user';
import { IApplication } from '../../models/application';
import { Loader } from '../base/loader';
import { react2angular } from 'react2angular';
import { useTranslation } from 'react-i18next';
import { HtmlTranslate } from '../base/html-translate';
import { useForm } from 'react-hook-form';
import MemberAPI from '../../api/member';
import { FormSelect } from '../form/form-select';
import { Group } from '../../models/group';
import GroupAPI from '../../api/group';
declare const Application: IApplication;
interface ChangeRoleModalProps {
isOpen: boolean,
toggleModal: () => void,
user: User,
onError: (message: string) => void,
onSuccess: (message: string) => void,
}
interface RoleFormData {
role: UserRole,
groupId?: number
}
/**
* Option format, expected by react-select
* @see https://github.com/JedWatson/react-select
*/
type selectRoleOption = { value: UserRole, label: string, isDisabled: boolean };
type selectGroupOption = { value: number, label: string };
/**
* This modal dialog allows to change the current role of the given user
*/
export const ChangeRoleModal: React.FC<ChangeRoleModalProps> = ({ isOpen, toggleModal, user, onSuccess, onError }) => {
const { t } = useTranslation('admin');
const { control, handleSubmit } = useForm<RoleFormData>({ defaultValues: { groupId: user.group_id } });
const [groups, setGroups] = useState<Array<Group>>([]);
const [selectedRole, setSelectedRole] = useState<UserRole>(user.role);
useEffect(() => {
GroupAPI.index({ disabled: false, admins: false }).then(setGroups).catch(onError);
}, []);
/**
* Handle the form submission: update the role on the API
*/
const onSubmit = (data: RoleFormData) => {
MemberAPI.updateRole(user, data.role, data.groupId).then(res => {
onSuccess(
t(
'app.admin.change_role_modal.role_changed',
{ OLD: t(`app.admin.change_role_modal.${user.role}`), NEW: t(`app.admin.change_role_modal.${res.role}`) }
)
);
toggleModal();
}).catch(err => onError(t('app.admin.change_role_modal.error_while_changing_role') + err));
};
/**
* Callback triggered when the user changes the selected role in the dropdown selection list
*/
const onRoleSelect = (data: UserRole) => {
setSelectedRole(data);
};
/**
* Return the various available roles for the select input
*/
const buildRolesOptions = (): Array<selectRoleOption> => {
return [
{ value: 'admin' as UserRole, label: t('app.admin.change_role_modal.admin'), isDisabled: user.role === 'admin' },
{ value: 'manager' as UserRole, label: t('app.admin.change_role_modal.manager'), isDisabled: user.role === 'manager' },
{ value: 'member' as UserRole, label: t('app.admin.change_role_modal.member'), isDisabled: user.role === 'member' }
];
};
/**
* Return the various available groups for the select input
*/
const buildGroupsOptions = (): Array<selectGroupOption> => {
return groups.map(group => {
return { value: group.id, label: group.name };
});
};
return (
<FabModal isOpen={isOpen}
toggleModal={toggleModal}
title={t('app.admin.change_role_modal.change_role')}
width={ModalSize.medium}
onConfirmSendFormId="user-role-form"
confirmButton={t('app.admin.change_role_modal.confirm')}
closeButton>
<HtmlTranslate trKey={'app.admin.change_role_modal.warning_role_change'} />
<form onSubmit={handleSubmit(onSubmit)} id="user-role-form">
<FormSelect options={buildRolesOptions()}
control={control}
id="role"
label={t('app.admin.change_role_modal.new_role')}
rules={{ required: true }}
onChange={onRoleSelect} />
{selectedRole !== 'admin' &&
<FormSelect options={buildGroupsOptions()}
control={control}
id="groupId"
label={t('app.admin.change_role_modal.new_group')}
tooltip={t('app.admin.change_role_modal.new_group_help')}
rules={{ required: true }} />}
</form>
</FabModal>
);
};
const ChangeRoleModalWrapper: React.FC<ChangeRoleModalProps> = (props) => {
return (
<Loader>
<ChangeRoleModal {...props} />
</Loader>
);
};
Application.Components.component('changeRoleModal', react2angular(ChangeRoleModalWrapper, ['isOpen', 'toggleModal', 'user', 'onError', 'onSuccess']));

View File

@ -724,6 +724,9 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
// modal dialog to take a new subscription
$scope.isOpenSubscribeModal = false;
// modal dialog to change the user's role
$scope.isOpenChangeRoleModal = false;
/**
* Open a modal dialog asking for confirmation to change the role of the given user
* @returns {*}
@ -800,6 +803,17 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
$scope.$apply();
}, 50);
};
/**
* Opens/closes the modal dialog to change the user's role
*/
$scope.toggleChangeRoleModal = () => {
setTimeout(() => {
$scope.isOpenChangeRoleModal = !$scope.isOpenChangeRoleModal;
$scope.$apply();
}, 0);
};
/**
* Callback triggered if the subscription was successfully extended
*/

View File

@ -37,6 +37,7 @@
@import "modules/form/abstract-form-item";
@import "modules/form/form-input";
@import "modules/form/form-rich-text";
@import "modules/form/form-select";
@import "modules/form/form-switch";
@import "modules/group/change-group";
@import "modules/machines/machine-card";

View File

@ -0,0 +1,9 @@
.form-select {
.rs__menu .rs__menu-list {
.rs__option {
&--is-disabled {
color: var(--gray-hard-lightest);
}
}
}
}

View File

@ -21,9 +21,10 @@
<div class="col-md-3">
<section class="heading-actions wrapper">
<div class="btn btn-lg btn-block btn-default promote-member m-t-xs" ng-click="changeUserRole()" ng-show="isAuthorized('admin')">
<div class="btn btn-lg btn-block btn-default promote-member m-t-xs" ng-click="toggleChangeRoleModal()" ng-show="isAuthorized('admin')">
<img src="/rank-icon.svg" alt="role icon" /><span class="m-l" translate>{{ 'app.admin.members_edit.change_role' }}</span>
</div>
<change-role-modal is-open="isOpenChangeRoleModal" toggle-modal="toggleChangeRoleModal" user="user" on-success="onSuccess" onError="onError" />
</section>
</div>

View File

@ -0,0 +1,48 @@
# frozen_string_literal: true
# Provides helper methods for Exports resources and properties
class ExportService
class << self
# Check if the last export of the provided type is still accurate or if it must be regenerated
def last_export(type)
case type
when 'users/members'
last_export_members
when 'users/reservations'
last_export_reservations
when 'users/subscription'
last_export_subscriptions
else
raise TypeError "unknown export type: #{type}"
end
end
private
def last_export_subscriptions
Export.where(category: 'users', export_type: 'subscriptions')
.where('created_at > ?', Subscription.maximum('updated_at'))
.last
end
def last_export_reservations
Export.where(category: 'users', export_type: 'reservations')
.where('created_at > ?', Reservation.maximum('updated_at'))
.last
end
def last_export_members
last_update = [
User.members.maximum('updated_at'),
Profile.where(user_id: User.members).maximum('updated_at'),
InvoicingProfile.where(user_id: User.members).maximum('updated_at'),
StatisticProfile.where(user_id: User.members).maximum('updated_at'),
Subscription.maximum('updated_at') || DateTime.current
].max
Export.where(category: 'users', export_type: 'members')
.where('created_at > ?', last_update)
.last
end
end
end

View File

@ -107,6 +107,44 @@ class Members::MembersService
params
end
def self.last_registered(limit)
query = User.active.with_role(:member)
.includes(:statistic_profile, profile: [:user_avatar])
.where('is_allow_contact = true AND confirmed_at IS NOT NULL')
.order('created_at desc')
.limit(limit)
# remove unmerged profiles from list
members = query.to_a
members.delete_if(&:need_completion?)
[query, members]
end
def update_role(new_role, new_group_id = Group.first.id)
# do nothing if the role does not change
return if new_role == @member.role
# update role
ex_role = @member.role.to_sym
@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)
# notify
NotificationCenter.call type: 'notify_user_role_update',
receiver: @member,
attached_object: @member
NotificationCenter.call type: 'notify_admins_role_update',
receiver: User.admins_and_managers,
attached_object: @member,
meta_data: { ex_role: ex_role }
end
private
def notify_user_profile_complete(previous_state)

View File

@ -12,5 +12,15 @@ class Statistics::BuilderService
Statistics::Builders::MembersBuilderService.build(options)
Statistics::Builders::ProjectsBuilderService.build(options)
end
private
def default_options
yesterday = 1.day.ago
{
start_date: yesterday.beginning_of_day,
end_date: yesterday.end_of_day
}
end
end
end

View File

@ -974,15 +974,20 @@ en:
to_complete: "To complete"
refuse_documents: "Refusing the documents"
refuse_documents_info: "After verification, you may notify the member that the evidence submitted is not acceptable. You can specify the reasons for your refusal and indicate the actions to be taken. The member will be notified by e-mail."
#edit a member
members_edit:
change_role_modal:
change_role: "Change role"
warning_role_change: "<p><strong>Warning:</strong> changing the role of a user is not a harmless operation. Is not currently possible to dismiss a user to a lower privileged role.</p><ul><li><strong>Members</strong> can only book reservations for themselves, paying by card or wallet.</li><li><strong>Managers</strong> can book reservations for themselves, paying by card or wallet, and for other members and managers, by collecting payments at the checkout.</li><li><strong>Administrators</strong> can only book reservations for members and managers, by collecting payments at the checkout. Moreover, they can change every settings of the application.</li></ul>"
warning_role_change: "<p><strong>Warning:</strong> changing the role of a user is not a harmless operation.</p><ul><li><strong>Members</strong> can only book reservations for themselves, paying by card or wallet.</li><li><strong>Managers</strong> can book reservations for themselves, paying by card or wallet, and for other members and managers, by collecting payments at the checkout.</li><li><strong>Administrators</strong> can only book reservations for members and managers, by collecting payments at the checkout. Moreover, they can change every settings of the application.</li></ul>"
new_role: "New role"
admin: "Administrator"
manager: "Manager"
member: "Member"
new_group: "New group"
new_group_help: "Members and managers must be placed in a group."
confirm: "Change role"
role_changed: "Role successfully changed from {OLD} to {NEW}."
error_while_changing_role: "An error occurred while changing the role. Please try again later."
#edit a member
members_edit:
subscription: "Subscription"
duration: "Duration:"
expires_at: "Expires at:"

View File

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

View File

@ -9,6 +9,10 @@ class StatisticServiceTest < ActionDispatch::IntegrationTest
login_as(@admin, scope: :user)
end
test 'build default stats' do
::Statistics::BuilderService.generate_statistic
end
test 'build stats' do
# Create a reservation to generate an invoice
machine = Machine.find(1)