1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-02-21 15:54:22 +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 # 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 ## 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 - 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 end
def last_subscribed def last_subscribed
@query = User.active.with_role(:member) @query, @members = Members::MembersService.last_registered(params[:last])
.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?)
@requested_attributes = ['profile'] @requested_attributes = ['profile']
render :index render :index
@ -74,9 +66,7 @@ class API::MembersController < API::ApiController
def export_subscriptions def export_subscriptions
authorize :export authorize :export
export = Export.where(category: 'users', export_type: 'subscriptions') export = ExportService.last_export('users/subscription')
.where('created_at > ?', Subscription.maximum('updated_at'))
.last
if export.nil? || !FileTest.exist?(export.file) if export.nil? || !FileTest.exist?(export.file)
@export = Export.new(category: 'users', export_type: 'subscriptions', user: current_user) @export = Export.new(category: 'users', export_type: 'subscriptions', user: current_user)
if @export.save if @export.save
@ -85,7 +75,7 @@ class API::MembersController < API::ApiController
render json: @export.errors, status: :unprocessable_entity render json: @export.errors, status: :unprocessable_entity
end end
else else
send_file File.join(Rails.root, export.file), send_file Rails.root.join(export.file),
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
disposition: 'attachment' disposition: 'attachment'
end end
@ -95,9 +85,7 @@ class API::MembersController < API::ApiController
def export_reservations def export_reservations
authorize :export authorize :export
export = Export.where(category: 'users', export_type: 'reservations') export = ExportService.last_export('users/reservations')
.where('created_at > ?', Reservation.maximum('updated_at'))
.last
if export.nil? || !FileTest.exist?(export.file) if export.nil? || !FileTest.exist?(export.file)
@export = Export.new(category: 'users', export_type: 'reservations', user: current_user) @export = Export.new(category: 'users', export_type: 'reservations', user: current_user)
if @export.save if @export.save
@ -106,7 +94,7 @@ class API::MembersController < API::ApiController
render json: @export.errors, status: :unprocessable_entity render json: @export.errors, status: :unprocessable_entity
end end
else else
send_file File.join(Rails.root, export.file), send_file Rails.root.join(export.file),
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
disposition: 'attachment' disposition: 'attachment'
end end
@ -115,17 +103,7 @@ class API::MembersController < API::ApiController
def export_members def export_members
authorize :export authorize :export
last_update = [ export = ExportService.last_export('users/members')
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
if export.nil? || !FileTest.exist?(export.file) if export.nil? || !FileTest.exist?(export.file)
@export = Export.new(category: 'users', export_type: 'members', user: current_user) @export = Export.new(category: 'users', export_type: 'members', user: current_user)
if @export.save if @export.save
@ -134,7 +112,7 @@ class API::MembersController < API::ApiController
render json: @export.errors, status: :unprocessable_entity render json: @export.errors, status: :unprocessable_entity
end end
else else
send_file File.join(Rails.root, export.file), send_file Rails.root.join(export.file),
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
disposition: 'attachment' disposition: 'attachment'
end end
@ -158,8 +136,8 @@ class API::MembersController < API::ApiController
else else
render json: @member.errors, status: :unprocessable_entity render json: @member.errors, status: :unprocessable_entity
end end
rescue DuplicateIndexError => error rescue DuplicateIndexError => e
render json: { error: t('members.please_input_the_authentication_code_sent_to_the_address', EMAIL: error.message) }, render json: { error: t('members.please_input_the_authentication_code_sent_to_the_address', EMAIL: e.message) },
status: :unprocessable_entity status: :unprocessable_entity
end end
else else
@ -176,7 +154,6 @@ class API::MembersController < API::ApiController
query = Members::ListService.list(query_params) query = Members::ListService.list(query_params)
@max_members = query.except(:offset, :limit, :order).count @max_members = query.except(:offset, :limit, :order).count
@members = query.to_a @members = query.to_a
end end
def search def search
@ -196,7 +173,7 @@ class API::MembersController < API::ApiController
render json: { tours: [params[:tour]] } render json: { tours: [params[:tour]] }
else else
tours = "#{@member.profile.tours} #{params[:tour]}" 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 } render json: { tours: @member.profile.tours.split }
end end
@ -205,31 +182,8 @@ class API::MembersController < API::ApiController
def update_role def update_role
authorize @member authorize @member
# we do not allow dismissing a user to a lower role service = Members::MembersService.new(@member)
if params[:role] == 'member' service.update_role(params[:role], params[:group_id])
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 }
render json: @member render json: @member
end end
@ -265,12 +219,14 @@ class API::MembersController < API::ApiController
profile_attributes: [:id, :first_name, :last_name, :phone, :interest, :software_mastered, :website, :job, profile_attributes: [:id, :first_name, :last_name, :phone, :interest, :software_mastered, :website, :job,
:facebook, :twitter, :google_plus, :viadeo, :linkedin, :instagram, :youtube, :vimeo, :facebook, :twitter, :google_plus, :viadeo, :linkedin, :instagram, :youtube, :vimeo,
:dailymotion, :github, :echosciences, :pinterest, :lastfm, :flickr, :dailymotion, :github, :echosciences, :pinterest, :lastfm, :flickr,
user_avatar_attributes: %i[id attachment destroy]], { user_avatar_attributes: %i[id attachment destroy] }],
invoicing_profile_attributes: [ invoicing_profile_attributes: [
:id, :organization, :id, :organization,
address_attributes: %i[id address], {
organization_attributes: [:id, :name, address_attributes: %i[id address]], address_attributes: %i[id address],
user_profile_custom_fields_attributes: %i[id value invoicing_profile_id profile_custom_field_id] 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]) 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, profile_attributes: [:id, :first_name, :last_name, :phone, :interest, :software_mastered, :website, :job,
:facebook, :twitter, :google_plus, :viadeo, :linkedin, :instagram, :youtube, :vimeo, :facebook, :twitter, :google_plus, :viadeo, :linkedin, :instagram, :youtube, :vimeo,
:dailymotion, :github, :echosciences, :pinterest, :lastfm, :flickr, :dailymotion, :github, :echosciences, :pinterest, :lastfm, :flickr,
user_avatar_attributes: %i[id attachment destroy]], { user_avatar_attributes: %i[id attachment destroy] }],
invoicing_profile_attributes: [ invoicing_profile_attributes: [
:id, :organization, :id, :organization,
address_attributes: %i[id address], {
organization_attributes: [:id, :name, address_attributes: %i[id address]], address_attributes: %i[id address],
user_profile_custom_fields_attributes: %i[id value invoicing_profile_id profile_custom_field_id] 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
end end

View File

@ -1,7 +1,7 @@
import apiClient from './clients/api-client'; import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios'; import { AxiosResponse } from 'axios';
import { serialize } from 'object-to-formdata'; import { serialize } from 'object-to-formdata';
import { User, UserIndexFilter } from '../models/user'; import { User, UserIndexFilter, UserRole } from '../models/user';
export default class MemberAPI { export default class MemberAPI {
static async list (filters: UserIndexFilter): Promise<Array<User>> { static async list (filters: UserIndexFilter): Promise<Array<User>> {
@ -35,6 +35,11 @@ export default class MemberAPI {
return res?.data; 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> { static async current (): Promise<User> {
const res: AxiosResponse<User> = await apiClient.get('/api/members/current'); const res: AxiosResponse<User> = await apiClient.get('/api/members/current');
return res?.data; 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 // modal dialog to take a new subscription
$scope.isOpenSubscribeModal = false; $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 * Open a modal dialog asking for confirmation to change the role of the given user
* @returns {*} * @returns {*}
@ -800,6 +803,17 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
$scope.$apply(); $scope.$apply();
}, 50); }, 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 * Callback triggered if the subscription was successfully extended
*/ */

View File

@ -37,6 +37,7 @@
@import "modules/form/abstract-form-item"; @import "modules/form/abstract-form-item";
@import "modules/form/form-input"; @import "modules/form/form-input";
@import "modules/form/form-rich-text"; @import "modules/form/form-rich-text";
@import "modules/form/form-select";
@import "modules/form/form-switch"; @import "modules/form/form-switch";
@import "modules/group/change-group"; @import "modules/group/change-group";
@import "modules/machines/machine-card"; @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"> <div class="col-md-3">
<section class="heading-actions wrapper"> <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> <img src="/rank-icon.svg" alt="role icon" /><span class="m-l" translate>{{ 'app.admin.members_edit.change_role' }}</span>
</div> </div>
<change-role-modal is-open="isOpenChangeRoleModal" toggle-modal="toggleChangeRoleModal" user="user" on-success="onSuccess" onError="onError" />
</section> </section>
</div> </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 params
end 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 private
def notify_user_profile_complete(previous_state) def notify_user_profile_complete(previous_state)

View File

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

View File

@ -974,15 +974,20 @@ en:
to_complete: "To complete" to_complete: "To complete"
refuse_documents: "Refusing the documents" 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." 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 change_role_modal:
members_edit:
change_role: "Change role" 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" admin: "Administrator"
manager: "Manager" manager: "Manager"
member: "Member" 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}." role_changed: "Role successfully changed from {OLD} to {NEW}."
error_while_changing_role: "An error occurred while changing the role. Please try again later." error_while_changing_role: "An error occurred while changing the role. Please try again later."
#edit a member
members_edit:
subscription: "Subscription" subscription: "Subscription"
duration: "Duration:" duration: "Duration:"
expires_at: "Expires at:" expires_at: "Expires at:"

View File

@ -1,6 +1,6 @@
{ {
"name": "fab-manager", "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.", "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": [ "keywords": [
"fablab", "fablab",

View File

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