1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2024-11-28 09:24:24 +01:00

Merge branch 'uvsq' into dev

This commit is contained in:
Sylvain 2019-09-30 15:34:21 +02:00
commit 193e81e711
77 changed files with 1218 additions and 96 deletions

View File

@ -29,6 +29,14 @@ tmp
# PDF invoices
invoices
# Excel exports
exports
# CSV imports
imports
.DS_Store
# Development files
.vagrant
Vagrantfile

3
.gitignore vendored
View File

@ -34,6 +34,9 @@
# XLSX exports
/exports/*
# CSV imports
/imports/*
# Archives of cLosed accounting periods
/accounting/*

View File

@ -1,12 +1,23 @@
# Changelog Fab Manager
- Optional reCaptcha checkbox in sign-up form
- Ability to configure and export the accounting data to the ACD accounting software
- Compute the VAT per item in each invoices, instead of globally
- Use Alpine Linux to build the Docker image (#147)
- Ability to set project's CAO attachement maximum upload size
- Ability to bulk-import members from a CSV file
- Ability to disable invoices generation and interfaces
- Fix a bug: invoices with total = 0, are marked as paid on site even if paid by card
- Fix a bug: after disabling a group, its associated plans are hidden from the interface
- Fix a bug: in case of unexpected server error during stripe payment process, the confirm button is not unlocked
- Fix a bug: create a plan does not set its name
- [TODO DEPLOY] `rake db:migrate`
- [TODO DEPLOY] -> (only dev) yarn install
- [TODO DEPLOY] add `RECAPTCHA_SITE_KEY` and `RECAPTCHA_SECRET_KEY` environment variables (see [doc/environment.md](doc/environment.md) for configuration details)
- [TODO DEPLOY] add `MAX_CAO_SIZE` environment variable (see [doc/environment.md](doc/environment.md) for configuration details)
- [TODO DEPLOY] add `MAX_IMPORT_SIZE` environment variable (see [doc/environment.md](doc/environment.md) for configuration details)
- [TODO DEPLOY] add `- ${PWD}/imports:/usr/src/app/imports` in the volumes list of your fabmanager service in [docker-compose.yml](docker/docker-compose.yml)
- [TODO DEPLOY] add the `FABLAB_WITHOUT_INVOICES` environment variable (see [doc/environment.md](doc/environment.md) for configuration details)
## v4.1.1 2019 september 20

View File

@ -56,6 +56,7 @@ RUN mkdir -p /usr/src/app && \
mkdir -p /usr/src/app/config && \
mkdir -p /usr/src/app/invoices && \
mkdir -p /usr/src/app/exports && \
mkdir -p /usr/src/app/imports && \
mkdir -p /usr/src/app/log && \
mkdir -p /usr/src/app/public/uploads && \
mkdir -p /usr/src/app/public/assets && \
@ -69,6 +70,7 @@ COPY . /usr/src/app
# Volumes
VOLUME /usr/src/app/invoices
VOLUME /usr/src/app/exports
VOLUME /usr/src/app/imports
VOLUME /usr/src/app/public
VOLUME /usr/src/app/public/uploads
VOLUME /usr/src/app/public/assets

View File

@ -20,7 +20,7 @@ angular.module('application', ['ngCookies', 'ngResource', 'ngSanitize', 'ui.rout
'ui.select', 'ui.calendar', 'angularMoment', 'Devise', 'DeviseModal', 'angular-growl', 'xeditable',
'checklist-model', 'unsavedChanges', 'angular-loading-bar', 'ngTouch', 'angular-google-analytics',
'angularUtils.directives.dirDisqus', 'summernote', 'elasticsearch', 'angular-medium-editor', 'naif.base64',
'minicolors', 'pascalprecht.translate', 'ngFitText', 'ngAside', 'ngCapsLock'])
'minicolors', 'pascalprecht.translate', 'ngFitText', 'ngAside', 'ngCapsLock', 'vcRecaptcha'])
.config(['$httpProvider', 'AuthProvider', 'growlProvider', 'unsavedWarningsConfigProvider', 'AnalyticsProvider', 'uibDatepickerPopupConfig', '$provide', '$translateProvider',
function ($httpProvider, AuthProvider, growlProvider, unsavedWarningsConfigProvider, AnalyticsProvider, uibDatepickerPopupConfig, $provide, $translateProvider) {
// Google analytics
@ -84,6 +84,8 @@ angular.module('application', ['ngCookies', 'ngResource', 'ngSanitize', 'ui.rout
$rootScope.fablabWithoutSpaces = Fablab.withoutSpaces;
// Global config: if true, all payments will be disabled in the application for the members (only admins will be able to proceed reservations)
$rootScope.fablabWithoutOnlinePayment = Fablab.withoutOnlinePayment;
// Global config: if true, no invoices will be generated
$rootScope.fablabWithoutInvoices = Fablab.withoutInvoices;
// Global function to allow the user to navigate to the previous screen (ie. $state).
// If no previous $state were recorded, navigate to the home page

View File

@ -64,6 +64,7 @@
//= require ng-fittext/dist/ng-FitText.min
//= require angular-aside/dist/js/angular-aside
//= require ng-caps-lock/ng-caps-lock
//= require angular-recaptcha
//= require_tree ./controllers
//= require_tree ./services
//= require_tree ./directives

View File

@ -609,6 +609,81 @@ Application.Controllers.controller('NewMemberController', ['$scope', '$state', '
}
]);
/**
* Controller used in the member's import page: import from CSV (admin view)
*/
Application.Controllers.controller('ImportMembersController', ['$scope', '$state', 'Group', 'Training', 'CSRF', 'tags', 'growl',
function($scope, $state, Group, Training, CSRF, tags, growl) {
CSRF.setMetaTags();
/* PUBLIC SCOPE */
// API URL where the form will be posted
$scope.actionUrl = '/api/imports/members';
// Form action on the above URL
$scope.method = 'post';
// List of all tags
$scope.tags = tags
/*
* Callback run after the form was submitted
* @param content {*} The result provided by the server, may be an Import object or an error message
*/
$scope.onImportResult = function(content) {
if (content.id) {
$state.go('app.admin.members_import_result', { id: content.id });
} else {
growl.error(JSON.stringify(content));
}
}
// Using the MembersController
return new MembersController($scope, $state, Group, Training);
}
]);
/**
* Controller used in the member's import results page (admin view)
*/
Application.Controllers.controller('ImportMembersResultController', ['$scope', '$state', 'Import', 'importItem',
function ($scope, $state, Import, importItem) {
/* PUBLIC SCOPE */
// Current import as saved in database
$scope.import = importItem;
// Current import results
$scope.results = null;
/**
* Changes the admin's view to the members import page
*/
$scope.cancel = function () { $state.go('app.admin.members_import'); };
/* PRIVATE SCOPE */
/**
* Kind of constructor: these actions will be realized first when the controller is loaded
*/
const initialize = function () {
$scope.results = JSON.parse($scope.import.results);
if (!$scope.results) {
setTimeout(function() {
Import.get({ id: $scope.import.id }, function(data) {
$scope.import = data;
initialize();
});
}, 5000);
}
};
// !!! MUST BE CALLED AT THE END of the controller
initialize();
}
]);
/**
* Controller used in the admin's creation page (admin view)
*/

View File

@ -96,6 +96,9 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
}
};
// reCaptcha v2 site key (or undefined)
$scope.recaptchaSiteKey = Fablab.recaptchaSiteKey;
// callback to open the date picker (account creation modal)
$scope.openDatePicker = function ($event) {
$event.preventDefault();
@ -117,7 +120,9 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
// default user's parameters
$scope.user = {
is_allow_contact: true,
is_allow_newsletter: false
is_allow_newsletter: false,
// reCaptcha response, received from Google (through AJAX) and sent to server for validation
recaptcha: undefined
};
// Errors display
@ -198,7 +203,7 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
return Auth.login().then(function (user) {
$scope.setCurrentUser(user);
}, function (error) {
console.error(`Authentication failed: ${error}`);
console.error(`Authentication failed: ${JSON.stringify(error)}`);
}
);
});
@ -362,7 +367,7 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
}
}
, function (error) {
console.error(`Authentication failed: ${error}`);
console.error(`Authentication failed: ${JSON.stringify(error)}`);
$scope.alerts = [];
return $scope.alerts.push({
msg: _t('wrong_email_or_password'),

View File

@ -959,6 +959,32 @@ angular.module('application.router', ['ui.router'])
translations: ['Translations', function (Translations) { return Translations.query(['app.admin.members_new', 'app.shared.user', 'app.shared.user_admin']).$promise; }]
}
})
.state('app.admin.members_import', {
url: '/admin/members/import',
views: {
'main@': {
templateUrl: '<%= asset_path "admin/members/import.html" %>',
controller: 'ImportMembersController'
}
},
resolve: {
translations: ['Translations', function (Translations) { return Translations.query(['app.admin.members_import', 'app.shared.user', 'app.shared.user_admin']).$promise; }],
tags: ['Tag', function(Tag) { return Tag.query().$promise }]
}
})
.state('app.admin.members_import_result', {
url: '/admin/members/import/:id/results',
views: {
'main@': {
templateUrl: '<%= asset_path "admin/members/import_result.html" %>',
controller: 'ImportMembersResultController'
}
},
resolve: {
translations: ['Translations', function (Translations) { return Translations.query(['app.admin.members_import_result', 'app.shared.user', 'app.shared.user_admin']).$promise; }],
importItem: ['Import', '$stateParams', function(Import, $stateParams) { return Import.get({ id: $stateParams.id }).$promise }]
}
})
.state('app.admin.members_edit', {
url: '/admin/members/:id/edit',
views: {

View File

@ -0,0 +1,7 @@
'use strict';
Application.Services.factory('Import', ['$resource', function ($resource) {
return $resource('/api/imports/:id',
{ id: '@id' }
);
}]);

View File

@ -180,6 +180,11 @@ p, .widget p {
.p-lg { padding: 30px; }
.p-l { padding: 16px; }
.p-h-xs { padding-left: 5px; padding-right: 5px; }
.p-h-s { padding-left: 10px; padding-right: 10px; }
.p-h-l { padding-left: 16px; padding-right: 16px; }
.p-h-lg { padding-left: 30px; padding-right: 30px; }
.m-xxs{margin: 2px 4px}
.m-xs{margin: 5px;}
.m-sm{margin: 10px;}
@ -256,6 +261,14 @@ p, .widget p {
.m-b-n-lg{margin-bottom: -30px}
.m-b-n-xl{margin-bottom: -40px}
.m-h-none{margin-left: 0; margin-right: 0;}
.m-h-xs{margin-left: 5px; margin-right: 5px;}
.m-h-sm{margin-left: 10px; margin-right: 10px;}
.m-h{margin-left: 15px; margin-right: 15px;}
.m-h-md{margin-left: 20px; margin-right: 20px;}
.m-h-lg{margin-left: 30px; margin-right: 30px;}
.m-h-xl{margin-left: 40px; margin-right: 40px;}
.media-xs{min-width: 50px}
.media-sm{min-width: 80px}
.media-md{min-width: 90px}
@ -306,6 +319,17 @@ p, .widget p {
width: auto;
vertical-align: sub;
}
// the two classes above are used in import results page for "import failed"
.fa-stack-inside {
font-size: 0.8em !important;
}
.fa-stack-outside {
font-size: 1.3em !important;
line-height: 1.7em !important;
}
.contrast-250 { -webkit-filter: contrast(250%); filter: contrast(250%); }
.clear{display:block;overflow: hidden;}
@ -350,6 +374,11 @@ p, .widget p {
cursor: help;
}
.flex-center {
display: flex;
justify-content: center;
}
@media screen and (min-width: $screen-lg-min) {
.b-r-lg {border-right: 1px solid $border-color; }
.hide-b-r-lg { border: none !important; }

View File

@ -24,7 +24,7 @@
<div class="row">
<div class="col-md-12">
<uib-tabset justified="true">
<uib-tab heading="{{ 'invoices.invoices_list' | translate }}">
<uib-tab heading="{{ 'invoices.invoices_list' | translate }}" ng-if="!fablabWithoutInvoices">
<h3 class="m-t-xs"><i class="fa fa-filter"></i> {{ 'invoices.filter_invoices' | translate }}</h3>
<div class="row">
@ -117,6 +117,10 @@
<uib-tab heading="{{ 'invoices.invoicing_settings' | translate }}">
<div class="alert alert-warning p-md m-t" role="alert" ng-show="fablabWithoutInvoices">
<i class="fa fa-warning m-r"></i>
<span translate>{{ 'invoices.warning_invoices_disabled' }}</span>
</div>
<form class="invoice-placeholder">
<div class="invoice-logo">
<img src="data:image/png;base64," data-src="holder.js/100%x100%/text:&#xf03e;/font:FontAwesome/icon" bs-holder ng-if="!invoice.logo" class="img-responsive">

View File

@ -187,7 +187,7 @@
</div>
</uib-tab>
<uib-tab heading="{{ 'invoices' | translate }}">
<uib-tab heading="{{ 'invoices' | translate }}" ng-hide="fablabWithoutInvoices">
<div class="col-md-12 m m-t-lg">

View File

@ -0,0 +1,177 @@
<div>
<section class="heading b-b">
<div class="row no-gutter">
<div class="col-md-1 hidden-xs">
<section class="heading-btn">
<a ng-click="cancel()"><i class="fa fa-long-arrow-left"></i></a>
</section>
</div>
<div class="col-md-8 b-l b-r">
<section class="heading-title">
<h1 translate>{{ 'members_import.import_members' }}</h1>
</section>
</div>
<div class="col-md-3">
<section class="heading-actions wrapper">
<a class="btn btn-lg btn-block btn-default m-t-xs" target="_blank" href="example.csv" translate>
{{ 'members_import.download_example' }}
</a>
</section>
</div>
</div>
</section>
<div class="row p-sm">
<div class="col-md-12">
<p class="alert alert-info" translate>
{{ 'members_import.info' }}
</p>
</div>
</div>
<div class="row m-h-sm">
<div class="col-md-6 p-h-s">
<h3 translate>{{ 'members_import.groups' }}</h3>
<table class="table">
<thead>
<tr>
<th translate>{{ 'members_import.group_name' }}</th>
<th translate>{{ 'members_import.group_identifier' }}</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="group in groups">
<td>
{{ group.name }}
</td>
<td>
{{ group.slug }}
</td>
</tr>
</tbody>
</table>
</div>
<div class="col-md-6 p-h-s">
<h3 translate>{{ 'members_import.trainings' }}</h3>
<table class="table">
<thead>
<tr>
<th translate>{{ 'members_import.training_name' }}</th>
<th translate>{{ 'members_import.training_identifier' }}</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="training in trainings | filterDisabled">
<td>
{{ training.name }}
</td>
<td>
{{ training.id }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="row m-h-sm">
<div class="col-md-6 p-h-s" ng-hide="tags.length == 0">
<h3 translate>{{ 'members_import.tags' }}</h3>
<table class="table">
<thead>
<tr>
<th translate>{{ 'members_import.tag_name' }}</th>
<th translate>{{ 'members_import.tag_identifier' }}</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="tag in tags">
<td>
{{ tag.name }}
</td>
<td>
{{ tag.id }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="row no-gutter">
<div class="col-sm-12 col-md-12 b-r nopadding">
<form role="form" name="importForm" class="form-horizontal" novalidate action="{{ actionUrl }}" ng-upload="onImportResult(content)" upload-options-enable-rails-csrf="true">
<section class="panel panel-default bg-light m-lg">
<div class="panel-body m-r">
<div class="m-t">
<p class="alert alert-warning m-h" translate>
{{ 'members_import.required_fields' }}
</p>
<p class="alert alert-warning m-h" translate>
{{ 'members_import.about_example' }}
</p>
</div>
<div class="fileinput input-group" data-provides="fileinput" ng-class="fileinputClass()">
<div class="form-control" data-trigger="fileinput">
<i class="glyphicon glyphicon-file fileinput-exists"></i> <span class="fileinput-filename">{{file.attachment}}</span>
</div>
<span class="input-group-addon btn btn-default btn-file"><span class="fileinput-new" translate>{{ 'members_import.select_file' }}</span>
<span class="fileinput-exists" translate>{{ 'change' }}</span>
<input type="file"
name="import_members"
accept="text/csv"
required></span>
<a class="input-group-addon btn btn-danger fileinput-exists" data-dismiss="fileinput" ng-click="deleteFile(file)"><i class="fa fa-trash-o"></i></a>
</div>
<div class="m-h">
<span translate>{{ 'members_import.update_field' }}</span>
<div class="radio m-l-md">
<label class="control-label">
<input type="radio" id="update_field" name="update_field" value="id" checked>
<span translate>{{ 'members_import.update_on_id' }}</span>
</label>
</div>
<div class="radio m-l-md">
<label class="control-label">
<input type="radio" id="update_field" name="update_field" value="username">
<span translate>{{ 'members_import.update_on_username' }}</span>
</label>
</div>
<div class="radio m-l-md">
<label class="control-label">
<input type="radio" id="update_field" name="update_field" value="email">
<span translate>{{ 'members_import.update_on_email' }}</span>
</label>
</div>
</div>
</div> <!-- ./panel-body -->
<div class="panel-footer no-padder">
<input type="submit" value="{{ 'members_import.import' | translate }}" class="r-b btn-valid btn btn-warning btn-block p-lg btn-lg text-u-c" ng-disabled="importForm.$invalid"/>
</div>
</section>
</form>
</div>
</div>
</div>

View File

@ -0,0 +1,66 @@
<div>
<section class="heading b-b">
<div class="row no-gutter">
<div class="col-md-1 hidden-xs">
<section class="heading-btn">
<a ng-click="cancel()"><i class="fa fa-long-arrow-left"></i></a>
</section>
</div>
<div class="col-md-8 b-l">
<section class="heading-title">
<h1 translate>{{ 'members_import_result.import_results' }}</h1>
</section>
</div>
</div>
</section>
<div class="row no-gutter">
<div class="col-sm-12 col-md-12">
<h2 class="m-l-lg">{{ 'members_import_result.import_details' | translate:{DATE:(import.created_at | amDateFormat:'L'), USER:import.user.full_name, ID:import.id} }}</h2>
<h3 class="m-l-lg" ng-hide="results"><i class="fa fa-spinner fa-pulse"></i> <span translate>{{ 'members_import_result.pending' }}</span></h3>
<div ng-show="results">
<h3 class="m-l-lg" translate>{{ 'members_import_result.results' }}</h3>
<div class="row p-h-lg" ng-repeat="resultRow in results track by $index">
<div class="scroll-x">
<table class="table table-bordered font-thin text-xs m-t-lg" ng-if="resultRow.row">
<tr>
<th ng-repeat="(key, value) in resultRow.row">{{key}}</th>
</tr>
<tr class="text-nowrap">
<td ng-repeat="(key, value) in resultRow.row">{{value}}</td>
</tr>
</table>
</div>
<div ng-if="resultRow.status">
<i class="fa fa-arrow-right m-l-lg m-r"></i>
<span class="m-r-md">{{ 'members_import_result.status_' + resultRow.status | translate:{ID:resultRow.user} }}</span>
<span ng-show="resultRow.result" class="green font-bold">
<i class="fa fa-check-square-o fa-stack-outside"></i>
<span class="m-l" translate>{{ 'members_import_result.success' }}</span>
</span>
<span ng-hide="resultRow.result" class="red font-bold">
<span class="fa-stack v-bottom">
<i class="fa fa-square-o fa-stack-1x fa-stack-outside"></i>
<i class="fa fa-times fa-stack-1x fa-stack-inside"></i>
</span>
<span class="m-l" translate>{{ 'members_import_result.failed' }}</span>
</span>
</div>
<div class="m-l-lg red" ng-if="!resultRow.row && !resultRow.status">
<span class="m-r" translate>{{ 'members_import_result.error_details' }}</span>{{resultRow}}
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -5,11 +5,18 @@
<a href="#" ng-click="backPrevLocation($event)"><i class="fa fa-long-arrow-left "></i></a>
</section>
</div>
<div class="col-xs-10 col-sm-10 col-md-8 b-l">
<div class="col-xs-8 col-sm-8 col-md-8 b-l">
<section class="heading-title">
<h1 translate>{{ 'users_management' }}</h1>
</section>
</div>
<div class="col-xs-1 col-xs-offset-1 col-md-offset-2 b-l">
<section class="heading-actions wrapper">
<a role="button" class="btn btn-default b-2x rounded m-t-sm" ui-sref="app.admin.members_import">
<i class="fa fa-cloud-upload"></i>
</a>
</section>
</div>
</div>
</section>

View File

@ -15,7 +15,7 @@
<li ui-sref-active="active"><a class="text-black" href="#" ui-sref="app.logged.dashboard.projects" translate>{{ 'my_projects' }}</a></li>
<li ui-sref-active="active"><a class="text-black" href="#" ui-sref="app.logged.dashboard.trainings" translate>{{ 'my_trainings' }}</a></li>
<li ui-sref-active="active"><a class="text-black" href="#" ui-sref="app.logged.dashboard.events" translate>{{ 'my_events' }}</a></li>
<li ui-sref-active="active"><a class="text-black" href="#" ui-sref="app.logged.dashboard.invoices" translate>{{ 'my_invoices' }}</a></li>
<li ui-sref-active="active" ng-hide="fablabWithoutInvoices"><a class="text-black" href="#" ui-sref="app.logged.dashboard.invoices" translate>{{ 'my_invoices' }}</a></li>
<li ui-sref-active="active"><a class="text-black" href="#" ui-sref="app.logged.dashboard.wallet" translate>{{ 'my_wallet' }}</a></li>
</ul>
</section>

View File

@ -39,7 +39,7 @@
<li><a href="#" ui-sref="app.logged.dashboard.projects" translate>{{ 'my_projects' }}</a></li>
<li><a href="#" ui-sref="app.logged.dashboard.trainings" translate>{{ 'my_trainings' }}</a></li>
<li><a href="#" ui-sref="app.logged.dashboard.events" translate>{{ 'my_events' }}</a></li>
<li><a href="#" ui-sref="app.logged.dashboard.invoices" translate>{{ 'my_invoices' }}</a></li>
<li><a href="#" ui-sref="app.logged.dashboard.invoices" ng-hide="fablabWithoutInvoices" translate>{{ 'my_invoices' }}</a></li>
<li><a href="#" ui-sref="app.logged.dashboard.wallet" translate>{{ 'my_wallet' }}</a></li>
<li class="divider"></li>

View File

@ -56,7 +56,7 @@
<i class="fa fa-calendar-o"></i> <span translate>{{ 'my_events' }}</span>
</a>
</li>
<li class="hidden-sm hidden-md hidden-lg" ng-if-end>
<li class="hidden-sm hidden-md hidden-lg" ng-hide="fablabWithoutInvoices" ng-if-end>
<a href="#" ui-sref="app.logged.dashboard.invoices">
<i class="fa fa-file-pdf-o"></i> <span translate>{{ 'my_invoices' }}</span>
</a>

View File

@ -247,6 +247,12 @@
<span class="exponent"><i class="fa fa-asterisk" aria-hidden="true"></i></span></label>
</div>
</div>
<div vc-recaptcha
key="recaptchaSiteKey"
class="flex-center"
ng-model="user.recaptcha"
ng-if="recaptchaSiteKey">
</div>
<span class="info-required">
<span class="exponent"><i class="fa fa-asterisk" aria-hidden="true"></i></span>
<span translate>{{ 'field_required' }}</span>

View File

@ -40,7 +40,7 @@
</div>
<hr/>
<div class="text-right m-t">
<div class="text-right m-t" ng-hide="fablabWithoutInvoices">
<label for="generate_avoir" translate>{{ 'generate_a_refund_invoice' }}</label>
<div class="inline m-l">
<input bs-switch

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
# API Controller for resources of type Import
class API::ImportsController < API::ApiController
before_action :authenticate_user!
def show
authorize Import
@import = Import.find(params[:id])
end
def members
authorize Import
@import = Import.new(
attachment: import_params,
user: current_user,
update_field: params[:update_field],
category: 'members'
)
if @import.save
render json: { id: @import.id }, status: :created
else
render json: @import.errors, status: :unprocessable_entity
end
end
private
def import_params
params.require(:import_members)
end
end

View File

@ -13,17 +13,14 @@ class API::UsersController < API::ApiController
end
def create
if current_user.admin?
res = UserService.create_partner(partner_params)
authorize User
res = UserService.create_partner(partner_params)
if res[:saved]
@user = res[:user]
render status: :created
else
render json: res[:user].errors.full_messages, status: :unprocessable_entity
end
if res[:saved]
@user = res[:user]
render status: :created
else
head 403
render json: res[:user].errors.full_messages, status: :unprocessable_entity
end
end

View File

@ -4,6 +4,11 @@
class RegistrationsController < Devise::RegistrationsController
# POST /users.json
def create
# first check the recaptcha
check = RecaptchaService.verify(params[:user][:recaptcha])
render json: check['error-codes'], status: :unprocessable_entity and return unless check['success']
# then create the user
build_resource(sign_up_params)
resource_saved = resource.save

View File

@ -1,5 +1,8 @@
# frozen_string_literal: true
require 'file_size_validator'
# Generic class, parent of uploadable items
class Asset < ActiveRecord::Base
belongs_to :viewable, polymorphic: true
end

View File

@ -1,3 +1,7 @@
# frozen_string_literal: true
# Validates uploaded images to check that it matches the env parameters
# You must `include ImageValidatorConcern` in your class to use it
module ImageValidatorConcern
extend ActiveSupport::Concern

31
app/models/import.rb Normal file
View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
require 'file_size_validator'
# An Import is a file uploaded by an user that provides some data to the database.
# Currently, this is used to import some users from a CSV file
class Import < ActiveRecord::Base
mount_uploader :attachment, ImportUploader
belongs_to :user
validates :attachment, file_size: { maximum: Rails.application.secrets.max_import_size&.to_i || 5.megabytes.to_i }
validates :attachment, file_mime_type: { content_type: %w[text/csv text/comma-separated-values application/vnd.ms-excel] }
after_commit :proceed_import, on: [:create]
def results_hash
YAML.safe_load(results, [Symbol]) if results
end
private
def proceed_import
case category
when 'members'
MembersImportWorker.perform_async(id)
else
raise NoMethodError, "Unknown import service for #{category}"
end
end
end

View File

@ -188,6 +188,8 @@ class Invoice < ActiveRecord::Base
private
def generate_and_send_invoice
return if Rails.application.secrets.fablab_without_invoices == 'true'
unless Rails.env.test?
puts "Creating an InvoiceWorker job to generate the following invoice: id(#{id}), invoiced_id(#{invoiced_id}), " \
"invoiced_type(#{invoiced_type}), user_id(#{invoicing_profile.user_id})"

View File

@ -46,6 +46,7 @@ class NotificationType
notify_admin_close_period_reminder
notify_admin_archive_complete
notify_privacy_policy_changed
notify_admin_import_complete
]
# deprecated:
# - notify_member_subscribed_plan_is_changed

View File

@ -19,6 +19,7 @@ class Plan < ActiveRecord::Base
after_create :create_machines_prices
after_create :create_spaces_prices
after_create :create_statistic_type
after_create :set_name
validates :amount, :group, :base_name, presence: true
@ -109,4 +110,8 @@ class Plan < ActiveRecord::Base
'Possible causes: the type or the subtype were not created successfully.'
end
end
def set_name
update_columns(name: human_readable_name)
end
end

View File

@ -1,6 +1,9 @@
# frozen_string_literal: true
# CAO file attached to a project documentation
class ProjectCao < Asset
mount_uploader :attachment, ProjectCaoUploader
validates :attachment, file_size: { maximum: 20.megabytes.to_i }
validates :attachment, :file_mime_type => { :content_type => ENV['ALLOWED_MIME_TYPES'].split(' ') }
validates :attachment, file_size: { maximum: Rails.application.secrets.max_cao_size&.to_i || 5.megabytes.to_i }
validates :attachment, file_mime_type: { content_type: ENV['ALLOWED_MIME_TYPES'].split(' ') }
end

View File

@ -36,5 +36,4 @@ class StatisticProfile < ActiveRecord::Base
''
end
end
end

View File

@ -43,6 +43,7 @@ class User < ActiveRecord::Base
accepts_nested_attributes_for :tags, allow_destroy: true
has_many :exports, dependent: :destroy
has_many :imports, dependent: :nullify
# fix for create admin user
before_save do

View File

@ -1,5 +1,8 @@
# frozen_string_literal: true
# Check the access policies for API::CouponsController
class CouponPolicy < ApplicationPolicy
%w(index show create update destroy send_to).each do |action|
%w[index show create update destroy send_to].each do |action|
define_method "#{action}?" do
user.admin?
end

View File

@ -1,7 +1,11 @@
# frozen_string_literal: true
# Check the access policies for API::EventsController
class EventPolicy < ApplicationPolicy
# Defines the scope of the events index, depending on the role of the current user
class Scope < Scope
def resolve
if user.nil? or (user and !user.admin?)
if user.nil? || (user && !user.admin?)
scope.includes(:event_image, :event_files, :availability, :category)
.where('availabilities.start_at >= ?', Time.now)
.order('availabilities.start_at ASC')

View File

@ -1,5 +1,8 @@
class ExportPolicy < Struct.new(:user, :export)
%w(export_reservations export_members export_subscriptions export_availabilities download status).each do |action|
# frozen_string_literal: true
# Check the access policies for API::ExportsController
class ExportPolicy < ApplicationPolicy
%w[export_reservations export_members export_subscriptions export_availabilities download status].each do |action|
define_method "#{action}?" do
user.admin?
end

View File

@ -1,3 +1,6 @@
# frozen_string_literal: true
# Check the access policies for API::GroupsController
class GroupPolicy < ApplicationPolicy
def create?
user.admin?

View File

@ -0,0 +1,12 @@
# frozen_string_literal: true
# Check the access policies for API::ImportsController
class ImportPolicy < ApplicationPolicy
def show?
user.admin?
end
def members?
user.admin?
end
end

View File

@ -1,4 +1,8 @@
# frozen_string_literal: true
# Check the access policies for API::MembersController and API::UsersController
class UserPolicy < ApplicationPolicy
# Defines the scope of the users index, depending on the role of the current user
class Scope < Scope
def resolve
if user.admin?

View File

@ -0,0 +1,185 @@
# frozen_string_literal: true
# Provides helper methods to bulk-import some users from a CSV file
class Members::ImportService
class << self
def import(import)
require 'csv'
log = []
CSV.foreach(import.attachment.url, headers: true, col_sep: ';') do |row|
begin
password = hide_password(row)
log << { row: row.to_hash }
# try to find member based on import.update_field
user = User.find_by(import.update_field.to_sym => row[import.update_field])
params = row_to_params(row, user, password)
if user
service = Members::MembersService.new(user)
res = service.update(params)
log << { user: user.id, status: 'update', result: res }
else
user = User.new(params)
service = Members::MembersService.new(user)
res = service.create(import.user, params)
log << { user: nil, status: 'create', result: res }
end
log << user.errors.to_hash unless user.errors.to_hash.empty?
rescue StandardError => e
log << e.to_s
puts e
puts e.backtrace
end
end
log
end
private
def hashify(row, property, value: row[property], key: property.to_sym)
res = {}
res[key] = value if row[property]
res
end
def row_to_params(row, user, password)
res = {}
res.merge! hashify(row, 'id')
res.merge! hashify(row, 'username')
res.merge! hashify(row, 'email')
res.merge! hashify(row, 'password', value: password)
res.merge! hashify(row, 'password', key: :password_confirmation, value: password)
res.merge! hashify(row, 'allow_contact', value: row['allow_contact'] == 'yes', key: :is_allow_contact)
res.merge! hashify(row, 'allow_newsletter', value: row['allow_newsletter'] == 'yes', key: :is_allow_newsletter)
res.merge! hashify(row, 'group', value: group_id(row), key: :group_id)
res.merge! hashify(row, 'tags', value: tag_ids(row), key: :tag_ids)
profile_attributes = profile(row, user)
res[:profile_attributes] = profile_attributes if profile_attributes
invoicing_profile_attributes = invoicing_profile(row, user)
res[:invoicing_profile_attributes] = invoicing_profile_attributes if invoicing_profile_attributes
statistic_profile_attributes = statistic_profile(row, user)
res[:statistic_profile_attributes] = statistic_profile_attributes if statistic_profile_attributes
res
end
def group_id(row)
return unless row['group']
Group.friendly.find(row['group'])&.id
end
def tag_ids(row)
return unless row['tags']
Tag.where(id: row['tags'].split(',')).map(&:id)
end
def profile(row, user)
res = {}
res.merge! hashify(row, 'first_name')
res.merge! hashify(row, 'last_name')
res.merge! hashify(row, 'phone')
res.merge! hashify(row, 'interests', key: :interest)
res.merge! hashify(row, 'softwares', key: :software_mastered)
res.merge! hashify(row, 'website')
res.merge! hashify(row, 'job')
res.merge! hashify(row, 'facebook')
res.merge! hashify(row, 'twitter')
res.merge! hashify(row, 'googleplus', key: :google_plus)
res.merge! hashify(row, 'viadeo')
res.merge! hashify(row, 'linkedin')
res.merge! hashify(row, 'instagram')
res.merge! hashify(row, 'youtube')
res.merge! hashify(row, 'vimeo')
res.merge! hashify(row, 'dailymotion')
res.merge! hashify(row, 'github')
res.merge! hashify(row, 'echosciences')
res.merge! hashify(row, 'pinterest')
res.merge! hashify(row, 'lastfm')
res.merge! hashify(row, 'flickr')
res[:id] = user.profile.id if user&.profile
res
end
def invoicing_profile(row, user)
res = {}
res[:id] = user.invoicing_profile.id if user&.invoicing_profile
address_attributes = address(row, user)
res[:address_attributes] = address_attributes if address_attributes
organization_attributes = organization(row, user)
res[:organization_attributes] = organization_attributes if organization_attributes
res
end
def statistic_profile(row, user)
res = {}
res.merge! hashify(row, 'gender', value: row['gender'] == 'male')
res.merge! hashify(row, 'birthdate', key: :birthday)
res[:id] = user.statistic_profile.id if user&.statistic_profile
training_ids = training_ids(row)
res[:training_ids] = training_ids if training_ids
res
end
def address(row, user)
return unless row['address']
res = { address: row['address'] }
res[:id] = user.invoicing_profile.address.id if user&.invoicing_profile&.address
res
end
def organization(row, user)
return unless row['organization_name']
res = { name: row['organization_name'] }
res[:id] = user.invoicing_profile.organization.id if user&.invoicing_profile&.organization
address_attributes = organization_address(row, user)
res[:address_attributes] = address_attributes if address_attributes
res
end
def organization_address(row, user)
return unless row['organization_address']
res = { address: row['organization_address'] }
res[:id] = user.invoicing_profile.organization.address.id if user&.invoicing_profile&.organization&.address
res
end
def training_ids(row)
return unless row['trainings']
Training.where(id: row['trainings'].split(',')).map(&:id)
end
def hide_password(row)
password = row['password']
row['password'] = '********' if row['password']
password
end
end
end

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
# Provides methods to verify the client captcha on Google's services
class RecaptchaService
class << self
def verify(client_response)
return { 'success' => true } unless recaptcha_enabled?
require 'uri'
require 'net/http'
data = { secret: secret_key, response: client_response }
url = URI.parse('https://www.google.com/recaptcha/api/siteverify')
res = Net::HTTP.post_form(url, data)
JSON.parse(res&.body)
end
def recaptcha_enabled?
secret_key.present? && site_key.present?
end
def secret_key
Rails.application.secrets.recaptcha_secret_key
end
def site_key
Rails.application.secrets.recaptcha_site_key
end
end
end

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
# CarrierWave uploader for import files.
# This file defines the parameters for these uploads
class ImportUploader < CarrierWave::Uploader::Base
include UploadHelper
# Choose what kind of storage to use for this uploader:
storage :file
after :remove, :delete_empty_dirs
# Override the directory where uploaded files will be stored.
# This is a sensible default for uploaders that are meant to be mounted:
def store_dir
"#{base_store_dir}/#{model.id}"
end
def base_store_dir
'../imports'
end
# Add a white list of extensions which are allowed to be uploaded.
# For images you might use something like this:
def extension_white_list
['csv']
end
end

View File

@ -1,13 +1,13 @@
# frozen_string_literal: true
# CarrierWave uploader for project CAO attachments.
# This file defines the parameters for these uploads
class ProjectCaoUploader < CarrierWave::Uploader::Base
# Include RMagick or MiniMagick support:
# include CarrierWave::RMagick
#include CarrierWave::MiniMagick
include UploadHelper
# Choose what kind of storage to use for this uploader:
storage :file
after :remove, :delete_empty_dirs
# storage :fog
# Override the directory where uploaded files will be stored.
# This is a sensible default for uploaders that are meant to be mounted:
@ -20,31 +20,9 @@ class ProjectCaoUploader < CarrierWave::Uploader::Base
"uploads/#{model.class.to_s.underscore}"
end
# Provide a default URL as a default if there hasn't been a file uploaded:
# def default_url
# # For Rails 3.1+ asset pipeline compatibility:
# # ActionController::Base.helpers.asset_path("fallback/" + [version_name, "default.png"].compact.join('_'))
#
# "/images/fallback/" + [version_name, "default.png"].compact.join('_')
# end
# Process files as they are uploaded:
# process :scale => [200, 300]
#
# def scale(width, height)
# # do something
# end
# Add a white list of extensions which are allowed to be uploaded.
# For images you might use something like this:
def extension_white_list
ENV['ALLOWED_EXTENSIONS'].split(' ')
end
# Override the filename of the uploaded files:
# Avoid using model.id or version_name here, see uploader/store.rb for details.
#def filename
#"avatar.#{file.extension}" if original_filename
#end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
json.extract! @import, :id, :category, :user_id, :update_field, :created_at, :updated_at
json.results @import.results_hash.to_json
json.user do
json.full_name @import.user&.profile&.full_name
end

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
json.title notification.notification_type
json.description t('.import_over', CATEGORY: t(".#{notification.attached_object.category}")) +
link_to(t('.view_results'), "#!/admin/members/import/#{notification.attached_object.id}/results")
json.url notification_url(notification, format: :json)

View File

@ -1,13 +1,20 @@
json.extract! plan, :id, :base_name, :name, :interval, :interval_count, :group_id, :training_credit_nb, :is_rolling, :description, :type, :ui_weight, :disabled
json.amount (plan.amount / 100.00)
json.prices plan.prices, partial: 'api/prices/price', as: :price
json.plan_file_attributes do
json.id plan.plan_file.id
json.attachment_identifier plan.plan_file.attachment_identifier
end if plan.plan_file
# frozen_string_literal: true
json.partners plan.partners do |partner|
json.first_name partner.first_name
json.last_name partner.last_name
json.email partner.email
end if plan.respond_to?(:partners)
json.extract! plan, :id, :base_name, :name, :interval, :interval_count, :group_id, :training_credit_nb, :is_rolling, :description, :type,
:ui_weight, :disabled
json.amount plan.amount / 100.00
json.prices plan.prices, partial: 'api/prices/price', as: :price
if plan.plan_file
json.plan_file_attributes do
json.id plan.plan_file.id
json.attachment_identifier plan.plan_file.attachment_identifier
end
end
if plan.respond_to?(:partners)
json.partners plan.partners do |partner|
json.first_name partner.first_name
json.last_name partner.last_name
json.email partner.email
end
end

View File

@ -1,5 +1,8 @@
# frozen_string_literal: true
json.array!(@plans) do |plan|
json.extract! plan, :id, :base_name, :name, :interval, :interval_count, :group_id, :training_credit_nb, :description, :type, :ui_weight, :disabled
json.amount (plan.amount / 100.00)
json.extract! plan, :id, :base_name, :name, :interval, :interval_count, :group_id, :training_credit_nb, :description, :type, :ui_weight,
:slug, :disabled
json.amount plan.amount / 100.00
json.plan_file_url plan.plan_file.attachment_url if plan.plan_file
end

View File

@ -19,6 +19,7 @@
Fablab.withoutPlans = ('<%= Rails.application.secrets.fablab_without_plans %>' == 'true');
Fablab.withoutSpaces = ('<%= Rails.application.secrets.fablab_without_spaces %>' != 'false');
Fablab.withoutOnlinePayment = ('<%= Rails.application.secrets.fablab_without_online_payments %>' == 'true');
Fablab.withoutInvoices = ('<%= Rails.application.secrets.fablab_without_invoices %>' == 'true');
Fablab.disqusShortname = "<%= Rails.application.secrets.disqus_shortname %>";
Fablab.defaultHost = "<%= Rails.application.secrets.default_host %>";
Fablab.gaId = "<%= Rails.application.secrets.google_analytics_id %>";
@ -44,8 +45,9 @@
Fablab.openlabProjectsActive = <%= Rails.application.secrets.openlab_app_secret.present? %>;
<% if Rails.application.secrets.openlab_app_id.present? %>
Fablab.openlabAppId = "<%= Rails.application.secrets.openlab_app_id %>";
<% else %>
Fablab.openlabAppId = null;
<% end %>
<% if RecaptchaService.recaptcha_enabled? %>
Fablab.recaptchaSiteKey = "<%= RecaptchaService.site_key %>";
<% end %>
</script>

View File

@ -0,0 +1,8 @@
<%= render 'notifications_mailer/shared/hello', recipient: @recipient %>
<p>
<%= t('.body.you_made_an_import', CATEGORY: t(".body.category_#{@attached_object.category}")) %>.
</p>
<p>
<%=link_to( t('.body.click_to_view_results'), "#{root_url}#!/admin/members/import/#{@attached_object.id}/results", target: "_blank" )%>
</p>

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
# Will parse the uploaded CSV file and save or update the members described in that file.
# This import will be asynchronously proceed by sidekiq and a notification will be sent to the requesting user when it's done.
class MembersImportWorker
include Sidekiq::Worker
def perform(import_id)
import = Import.find(import_id)
raise SecurityError, 'Not allowed to import' unless import.user.admin?
raise KeyError, 'Wrong worker called' unless import.category == 'members'
res = Members::ImportService.import(import)
import.results = res.to_yaml
import.save!
NotificationCenter.call type: :notify_admin_import_complete,
receiver: import.user,
attached_object: import
end
end

View File

@ -1,15 +1,9 @@
# frozen_string_literal: true
require File.expand_path('../boot', __FILE__)
# Pick the frameworks you want:
#require "active_model/railtie"
#require "active_record/railtie"
#require "action_controller/railtie"
#require "action_mailer/railtie"
#require "action_view/railtie"
#require "sprockets/railtie"
#require "rails/test_unit/railtie"
require 'csv'
require "rails/all"
require 'rails/all'
require 'elasticsearch/rails/instrumentation'
require 'elasticsearch/persistence/model'
@ -43,7 +37,7 @@ module Fablab
config.active_record.raise_in_transactional_callbacks = true
config.to_prepare do
Devise::Mailer.layout "notifications_mailer"
Devise::Mailer.layout 'notifications_mailer'
end
# allow use rails helpers in angular templates
@ -60,8 +54,8 @@ module Fablab
if Rails.env.development?
config.web_console.whitelisted_ips << '192.168.0.0/16'
config.web_console.whitelisted_ips << '192.168.99.0/16' #docker
config.web_console.whitelisted_ips << '10.0.2.2' #vagrant
config.web_console.whitelisted_ips << '192.168.99.0/16' # docker
config.web_console.whitelisted_ips << '10.0.2.2' # vagrant
end
# load locales for subdirectories
@ -78,9 +72,8 @@ module Fablab
FabManager.activate_plugins!
config.after_initialize do
if plugins = FabManager.plugins
plugins.each { |plugin| plugin.notify_after_initialize }
end
plugins = FabManager.plugins
plugins&.each(&:notify_after_initialize)
end
end
end

View File

@ -14,6 +14,7 @@ INVOICE_PREFIX: Demo-FabLab-facture
FABLAB_WITHOUT_PLANS: 'false'
FABLAB_WITHOUT_SPACES: 'true'
FABLAB_WITHOUT_ONLINE_PAYMENT: 'false'
FABLAB_WITHOUT_INVOICES: 'false'
DEFAULT_MAIL_FROM: Fab Manager Demo <noreply@fab-manager.com>
@ -29,6 +30,8 @@ SMTP_AUTHENTICATION: 'plain'
SMTP_ENABLE_STARTTLS_AUTO: 'true'
SMTP_OPENSSL_VERIFY_MODE: ''
GA_ID: ''
RECAPTCHA_SITE_KEY: ''
RECAPTCHA_SECRET_KEY: ''
##
DISQUS_SHORTNAME:
@ -68,5 +71,9 @@ SUPERADMIN_EMAIL: 'admin@sleede.com'
ALLOWED_EXTENSIONS: pdf ai eps cad math svg stl dxf dwg obj step iges igs 3dm 3dmf doc docx png ino scad fcad skp sldprt sldasm slddrw slddrt tex latex ps
ALLOWED_MIME_TYPES: application/pdf application/postscript application/illustrator image/x-eps image/svg+xml application/sla application/dxf application/acad application/dwg application/octet-stream application/step application/iges model/iges x-world/x-3dmf application/vnd.openxmlformats-officedocument.wordprocessingml.document image/png text/x-arduino text/plain application/scad application/vnd.sketchup.skp application/x-koan application/vnd-koan koan/x-skm application/vnd.koan application/x-tex application/x-latex
# 5242880 = 5 megabytes
MAX_IMPORT_SIZE: '5242880'
# 10485760 = 10 megabytes
MAX_IMAGE_SIZE: '10485760'
# 20971520 = 20 megabytes
MAX_CAO_SIZE: '20971520'

View File

@ -1,7 +1,9 @@
# frozen_string_literal: true
# Be sure to restart your server when you modify this file.
# Add new mime types for use in respond_to blocks:
# Mime::Type.register "text/richtext", :rtf
# Mime::Type.register_alias "text/html", :iphone
Mime::Type.register "application/vnd.ms-excel", :xls
Mime::Type.register 'application/vnd.ms-excel', :xls

View File

@ -569,6 +569,46 @@ en:
add_a_member: "Add a member"
user_is_an_organization: "User is an organization"
members_import:
# members bulk import
members_import:
import_members: "Import members"
info: "You can upload a CSV file to create new members or update existing ones. Your file must user the identifiers below to specify the group, the trainings and the tags of the members."
required_fields: "Your file must contain, at least, the following information for each user to create: email, name, first name and group. If the password is empty, it will be generated. On updates, the empty fields will be kept as is."
about_example: "Please refer to the provided example file to generate a correct CSV file. Be careful to use Unicode UTF-8 encoding."
groups: "Groups"
group_name: "Group name"
group_identifier: "Identifier to use"
trainings: "Trainings"
training_name: "Training name"
training_identifier: "Identifier to use"
plans: "Plans"
plan_name: "Plan name"
plan_identifier: "Identifier to use"
tags: "Tags"
tag_name: "Tag name"
tag_identifier: "Identifier to use"
download_example: "Download the exemple file"
select_file: "Choose a file"
import: "Import"
update_field: "Reference field for users to update"
update_on_id: "ID"
update_on_username: "Username"
update_on_email: "Email address"
members_import_result:
# import results
members_import_result:
import_results: "Import results"
import_details: "Import #{{ID}}, of {{DATE}}, initiated by {{USER}}" # angular interpolation
results: "Results"
pending: "Pending..."
status_create: "Creating a new user"
status_update: "Updating user {{ID}}" # angular interpolation
success: "Success"
failed: "Failed"
error_details: "Error's details:"
members_edit:
# edit a member
duration: "Duration:"

View File

@ -569,6 +569,46 @@ es:
add_a_member: "Agregar un miembro"
user_is_an_organization: "El usuario es una organización"
members_import:
# members bulk import
members_import:
import_members: "Import members" # translation_missing
info: "You can upload a CSV file to create new members or update existing ones. Your file must user the identifiers below to specify the group, the trainings and the tags of the members." # translation_missing
required_fields: "Your file must contain, at least, the following information for each user to create: email, name, first name and group. If the password is empty, it will be generated. On updates, the empty fields will be kept as is." # translation_missing
about_example: "Please refer to the provided example file to generate a correct CSV file. Be careful to use Unicode UTF-8 encoding." # translation_missing
groups: "Groups" # translation_missing
group_name: "Group name" # translation_missing
group_identifier: "Identifier to use" # translation_missing
trainings: "Trainings" # translation_missing
training_name: "Training name" # translation_missing
training_identifier: "Identifier to use" # translation_missing
plans: "Plans" # translation_missing
plan_name: "Plan name" # translation_missing
plan_identifier: "Identifier to use" # translation_missing
tags: "Tags" # translation_missing
tag_name: "Tag name" # translation_missing
tag_identifier: "Identifier to use" # translation_missing
download_example: "Download the exemple file" # translation_missing
select_file: "Choose a file" # translation_missing
import: "Import" # translation_missing
update_field: "Reference field for users to update" # translation_missing
update_on_id: "ID" # translation_missing
update_on_username: "Username" # translation_missing
update_on_email: "Email address" # translation_missing
members_import_result:
# import results
members_import_result:
import_results: "Import results" # translation_missing
import_details: "Import #{{ID}}, of {{DATE}}, initiated by {{USER}}" # angular interpolation # translation_missing
results: "Results" # translation_missing
pending: "Pending..." # translation_missing
status_create: "Creating a new user" # translation_missing
status_update: "Updating user {{ID}}" # angular interpolation # translation_missing
success: "Success" # translation_missing
failed: "Failed" # translation_missing
error_details: "Error's details:" # translation_missing
members_edit:
# edit a member
duration: "Duración:"

View File

@ -306,6 +306,7 @@ fr:
display_more_invoices: "Afficher plus de factures ..."
no_invoices_for_now: "Aucune facture pour le moment."
invoicing_settings: "Paramètres de facturation"
warning_invoices_disabled: "Attention : les factures ne sont pas activées. Aucune facture ne sera générée par Fab-manager. Vous devez néanmoins remplir correctement les informations ci-dessous, particulièrement la TVA."
change_logo: "Changer le logo"
john_smith: "Jean Dupont"
john_smith_at_example_com: "jean.dupont@example.com"
@ -569,6 +570,46 @@ fr:
add_a_member: "Ajouter un membre"
user_is_an_organization: "L'utilisateur est une structure"
members_import:
# import massif de members
members_import:
import_members: "Importer des membres"
info: "Vous pouvez téléverser un fichier CVS afin de créer des nouveaux membres ou de mettre à jour les existants. Votre fichier doit utiliser les identifiants ci-dessous pour spécifier le groupe, les formations et les étiquettes des membres."
required_fields: "Votre fichier doit obligatoirement comporter, au minimum, les informations suivantes pour chaque utilisateur à créer : courriel, nom, prénom et groupe. Si le mot passe n'est pas rempli, il sera généré automatiquement. Lors d'une mise à jour, les champs non remplis seront gardés tel quels."
about_example: "Merci de vous référer au fichier d'exemple fourni pour générer un fichier CSV au bon format. Attention à l'utiliser l'encodage Unicode UTF-8"
groups: "Groupes"
group_name: "Nom du groupe"
group_identifier: "Identifiant à utiliser"
trainings: "Formations"
training_name: "Nom de la formation"
training_identifier: "Identifiant à utiliser"
plans: "Abonnements"
plan_name: "Nom de l'abonnement"
plan_identifier: "Identifiant à utiliser"
tags: "Étiquettes"
tag_name: "Nom de l'étiquette"
tag_identifier: "Identifiant à utiliser"
download_example: "Télécharger le fichier d'exemple"
select_file: "Choisissez un fichier"
import: "Importer"
update_field: "Champ de référence pour les utilisateurs à mettre à jour"
update_on_id: "ID"
update_on_username: "Pseudonyme"
update_on_email: "Adresse de courriel"
members_import_result:
# résultats de l'import
members_import_result:
import_results: "Résultats de l'import"
import_details: "Import n°{{ID}}, du {{DATE}}, initié par {{USER}}" # angular interpolation
results: "Résultats"
pending: "En cours..."
status_create: "Création d'un nouvel utilisateur"
status_update: "Mise à jour de l'utilisateur {{ID}}" # angular interpolation
success: "Succès"
failed: "Échec"
error_details: "Détails de l'erreur :"
members_edit:
# modifier un membre
duration: "Durée :"

View File

@ -569,6 +569,46 @@ pt:
add_a_member: "Adicionar membro"
user_is_an_organization: "Usuário é uma organização"
members_import:
# members bulk import
members_import:
import_members: "Import members" # translation_missing
info: "You can upload a CSV file to create new members or update existing ones. Your file must user the identifiers below to specify the group, the trainings and the tags of the members." # translation_missing
required_fields: "Your file must contain, at least, the following information for each user to create: email, name, first name and group. If the password is empty, it will be generated. On updates, the empty fields will be kept as is." # translation_missing
about_example: "Please refer to the provided example file to generate a correct CSV file. Be careful to use Unicode UTF-8 encoding." # translation_missing
groups: "Groups" # translation_missing
group_name: "Group name" # translation_missing
group_identifier: "Identifier to use" # translation_missing
trainings: "Trainings" # translation_missing
training_name: "Training name" # translation_missing
training_identifier: "Identifier to use" # translation_missing
plans: "Plans" # translation_missing
plan_name: "Plan name" # translation_missing
plan_identifier: "Identifier to use" # translation_missing
tags: "Tags" # translation_missing
tag_name: "Tag name" # translation_missing
tag_identifier: "Identifier to use" # translation_missing
download_example: "Download the exemple file" # translation_missing
select_file: "Choose a file" # translation_missing
import: "Import" # translation_missing
update_field: "Reference field for users to update" # translation_missing
update_on_id: "ID" # translation_missing
update_on_username: "Username" # translation_missing
update_on_email: "Email address" # translation_missing
members_import_result:
# import results
members_import_result:
import_results: "Import results" # translation_missing
import_details: "Import #{{ID}}, of {{DATE}}, initiated by {{USER}}" # angular interpolation # translation_missing
results: "Results" # translation_missing
pending: "Pending..." # translation_missing
status_create: "Creating a new user" # translation_missing
status_update: "Updating user {{ID}}" # angular interpolation # translation_missing
success: "Success" # translation_missing
failed: "Failed" # translation_missing
error_details: "Error's details:" # translation_missing
members_edit:
# edit a member
duration: "Duração:"

View File

@ -330,6 +330,10 @@ en:
accounting_acd: "of the accounting data to ACD"
is_over: "is over."
download_here: "Download here"
notify_admin_import_complete:
import_over: "%{CATEGORY} import is over. "
members: "Members"
view_results: "View results."
notify_member_about_coupon:
enjoy_a_discount_of_PERCENT_with_code_CODE: "Enjoy a discount of %{PERCENT}% with code %{CODE}"
enjoy_a_discount_of_AMOUNT_with_code_CODE: "Enjoy a discount of %{AMOUNT} with code %{CODE}"

View File

@ -330,6 +330,10 @@ es:
accounting_acd: "de los datos contables para ACD"
is_over: "se ha acabado."
download_here: "Descargar aquí"
notify_admin_import_complete:
import_over: "%{CATEGORY} import is over. " # missing translation
members: "Members" # missing translation
view_results: "View results." # missing translation
notify_member_about_coupon:
enjoy_a_discount_of_PERCENT_with_code_CODE: "Disfruta de un descuento de %{PERCENT}% con el código %{CODE}"
enjoy_a_discount_of_AMOUNT_with_code_CODE: "Disfruta de un descuento de %{AMOUNT} con el código %{CODE}"
@ -341,8 +345,8 @@ es:
notify_admin_archive_complete: # missing translation
archive_complete: "Data archiving from %{START} to %{END} is done. <a href='api/accounting_periods/%{ID}/archive' target='_blank'>click here to download</a>. Remember to save it on an external secured media." # missing translation
notify_privacy_policy_changed:
policy_updated: "Privacy policy updated." # missing translation
click_to_show: "Click here to consult" # missing translation
policy_updated: "Privacy policy updated." # missing translation
click_to_show: "Click here to consult" # missing translation
statistics:
# statistics tools for admins
subscriptions: "Suscripciones"

View File

@ -330,6 +330,10 @@ fr:
accounting_acd: "des données comptables pour ACD"
is_over: "est terminé."
download_here: "Téléchargez ici"
notify_admin_import_complete:
import_over: "L'import %{CATEGORY} est terminé. "
members: "des membres"
view_results: "Voir les résultats."
notify_member_about_coupon:
enjoy_a_discount_of_PERCENT_with_code_CODE: "Bénéficiez d'une remise de %{PERCENT} % avec le code %{CODE}"
enjoy_a_discount_of_AMOUNT_with_code_CODE: "Bénéficiez d'une remise de %{AMOUNT} avec le code %{CODE}"

View File

@ -274,6 +274,13 @@ en:
xlsx: "Excel"
csv: "CSV"
notify_admin_import_complete:
subject: "Import completed"
body:
you_made_an_import: "You have initiated an import %{CATEGORY}"
category_members: "of the members"
click_to_view_results: "Click here to view results"
notify_member_about_coupon:
subject: "Coupon"
body:

View File

@ -273,6 +273,13 @@ es:
xlsx: "Excel"
csv: "CSV"
notify_admin_import_complete: #translation_missing
subject: "Import completed"
body:
you_made_an_import: "You have initiated an import %{CATEGORY}"
category_members: "of the members"
click_to_view_results: "Click here to view results"
notify_member_about_coupon:
subject: "Cupón"
body:

View File

@ -274,6 +274,13 @@ fr:
xlsx: "Excel"
csv: "CSV"
notify_admin_import_complete:
subject: "Import terminé"
body:
you_made_an_import: "Vous avez initié un import %{CATEGORY}"
category_members: "des membres"
click_to_view_results: "Cliquez ici pour voir les résultats"
notify_member_about_coupon:
subject: "Code promo"
body:

View File

@ -274,6 +274,13 @@ pt:
xlsx: "Excel"
csv: "CSV"
notify_admin_import_complete: #translation_missing
subject: "Import completed"
body:
you_made_an_import: "You have initiated an import %{CATEGORY}"
category_members: "of the members"
click_to_view_results: "Click here to view results"
notify_member_about_coupon:
subject: "Cupom"
body:

View File

@ -330,6 +330,10 @@ pt:
accounting_acd: "de dados contábeis para ACD"
is_over: "está finalizado."
download_here: "Baixe aqui"
notify_admin_import_complete:
import_over: "%{CATEGORY} import is over. " # missing translation
members: "Members" # missing translation
view_results: "View results." # missing translation
notify_member_about_coupon:
enjoy_a_discount_of_PERCENT_with_code_CODE: "Desfrute de um desconto de %{PERCENT}% com o código %{CODE}"
enjoy_a_discount_of_AMOUNT_with_code_CODE: "Desfrute de um desconto de %{AMOUNT} com o código %{CODE}"

View File

@ -149,6 +149,11 @@ Rails.application.routes.draw do
get 'exports/:id/download' => 'exports#download'
post 'exports/status' => 'exports#status'
# Members CSV import
resources :imports, only: [:show] do
post 'members', action: 'members', on: :collection
end
# Fab-manager's version
get 'version' => 'version#show'

View File

@ -19,6 +19,7 @@ development:
fablab_without_plans: <%= ENV["FABLAB_WITHOUT_PLANS"] %>
fablab_without_spaces: <%= ENV["FABLAB_WITHOUT_SPACES"] %>
fablab_without_online_payments: <%= ENV["FABLAB_WITHOUT_ONLINE_PAYMENT"] %>
fablab_without_invoices: <%= ENV["FABLAB_WITHOUT_INVOICES"] %>
default_host: <%= ENV["DEFAULT_HOST"] %>
default_protocol: <%= ENV["DEFAULT_PROTOCOL"] %>
time_zone: <%= ENV["TIME_ZONE"] %>
@ -43,8 +44,12 @@ development:
facebook_app_id: <%= ENV["FACEBOOK_APP_ID"] %>
elaticsearch_host: <%= ENV["ELASTICSEARCH_HOST"] %>
max_image_size: <%= ENV["MAX_IMAGE_SIZE"] %>
max_cao_size: <%= ENV["MAX_CAO_SIZE"] %>
max_import_size: <%= ENV["MAX_IMPORT_SIZE"] %>
disk_space_mb_alert: <%= ENV["DISK_SPACE_MB_ALERT"] %>
superadmin_email: <%= ENV["SUPERADMIN_EMAIL"] %>
recaptcha_site_key: <%= ENV["RECAPTCHA_SITE_KEY"] %>
recaptcha_secret_key: <%= ENV["RECAPTCHA_SECRET_KEY"] %>
test:
secret_key_base: 83daf5e7b80d990f037407bab78dff9904aaf3c195a50f84fa8695a22287e707dfbd9524b403b1dcf116ae1d8c06844c3d7ed942564e5b46be6ae3ead93a9d30
@ -55,6 +60,7 @@ test:
fablab_without_plans: false
fablab_without_spaces: false
fablab_without_online_payments: false
fablab_without_invoices: false
default_host: <%= ENV["DEFAULT_HOST"] %>
default_protocol: <%= ENV["DEFAULT_PROTOCOL"] %>
time_zone: Paris
@ -79,8 +85,12 @@ test:
facebook_app_id: <%= ENV["FACEBOOK_APP_ID"] %>
elaticsearch_host: <%= ENV["ELASTICSEARCH_HOST"] %>
max_image_size: <%= ENV["MAX_IMAGE_SIZE"] %>
max_cao_size: <%= ENV["MAX_CAO_SIZE"] %>
max_import_size: <%= ENV["MAX_IMPORT_SIZE"] %>
disk_space_mb_alert: <%= ENV["DISK_SPACE_MB_ALERT"] %>
superadmin_email: <%= ENV["SUPERADMIN_EMAIL"] %>
recaptcha_site_key: <%= ENV["RECAPTCHA_SITE_KEY"] %>
recaptcha_secret_key: <%= ENV["RECAPTCHA_SECRET_KEY"] %>
staging:
secret_key_base: <%= ENV["SECRET_KEY_BASE"] %>
@ -91,6 +101,7 @@ staging:
fablab_without_plans: <%= ENV["FABLAB_WITHOUT_PLANS"] %>
fablab_without_spaces: <%= ENV["FABLAB_WITHOUT_SPACES"] %>
fablab_without_online_payments: <%= ENV["FABLAB_WITHOUT_ONLINE_PAYMENT"] %>
fablab_without_invoices: <%= ENV["FABLAB_WITHOUT_INVOICES"] %>
default_host: <%= ENV["DEFAULT_HOST"] %>
default_protocol: <%= ENV["DEFAULT_PROTOCOL"] %>
delivery_method: <%= ENV['DELIVERY_METHOD'] %>
@ -124,8 +135,12 @@ staging:
facebook_app_id: <%= ENV["FACEBOOK_APP_ID"] %>
elaticsearch_host: <%= ENV["ELASTICSEARCH_HOST"] %>
max_image_size: <%= ENV["MAX_IMAGE_SIZE"] %>
max_cao_size: <%= ENV["MAX_CAO_SIZE"] %>
max_import_size: <%= ENV["MAX_IMPORT_SIZE"] %>
disk_space_mb_alert: <%= ENV["DISK_SPACE_MB_ALERT"] %>
superadmin_email: <%= ENV["SUPERADMIN_EMAIL"] %>
recaptcha_site_key: <%= ENV["RECAPTCHA_SITE_KEY"] %>
recaptcha_secret_key: <%= ENV["RECAPTCHA_SECRET_KEY"] %>
# Do not keep production secrets in the repository,
# instead read values from the environment.
@ -138,6 +153,7 @@ production:
fablab_without_plans: <%= ENV["FABLAB_WITHOUT_PLANS"] %>
fablab_without_spaces: <%= ENV["FABLAB_WITHOUT_SPACES"] %>
fablab_without_online_payments: <%= ENV["FABLAB_WITHOUT_ONLINE_PAYMENT"] %>
fablab_without_invoices: <%= ENV["FABLAB_WITHOUT_INVOICES"] %>
default_host: <%= ENV["DEFAULT_HOST"] %>
default_protocol: <%= ENV["DEFAULT_PROTOCOL"] %>
delivery_method: <%= ENV['DELIVERY_METHOD'] %>
@ -171,5 +187,9 @@ production:
facebook_app_id: <%= ENV["FACEBOOK_APP_ID"] %>
elaticsearch_host: <%= ENV["ELASTICSEARCH_HOST"] %>
max_image_size: <%= ENV["MAX_IMAGE_SIZE"] %>
max_cao_size: <%= ENV["MAX_CAO_SIZE"] %>
max_import_size: <%= ENV["MAX_IMPORT_SIZE"] %>
disk_space_mb_alert: <%= ENV["DISK_SPACE_MB_ALERT"] %>
superadmin_email: <%= ENV["SUPERADMIN_EMAIL"] %>
recaptcha_site_key: <%= ENV["RECAPTCHA_SITE_KEY"] %>
recaptcha_secret_key: <%= ENV["RECAPTCHA_SECRET_KEY"] %>

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
# From this migration, we save the file imports into the database.
# Currently, imports are limited to users import from a CSV file
class CreateImports < ActiveRecord::Migration
def change
create_table :imports do |t|
t.integer :user_id
t.string :attachment
t.string :update_field
t.string :category
t.text :results
t.timestamps null: false
end
end
end

View File

@ -11,12 +11,12 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20190917123631) do
ActiveRecord::Schema.define(version: 20190924140726) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
enable_extension "unaccent"
enable_extension "pg_trgm"
enable_extension "unaccent"
create_table "abuses", force: :cascade do |t|
t.integer "signaled_id"
@ -246,6 +246,16 @@ ActiveRecord::Schema.define(version: 20190917123631) do
add_index "history_values", ["invoicing_profile_id"], name: "index_history_values_on_invoicing_profile_id", using: :btree
add_index "history_values", ["setting_id"], name: "index_history_values_on_setting_id", using: :btree
create_table "imports", force: :cascade do |t|
t.integer "user_id"
t.string "attachment"
t.string "update_field"
t.string "category"
t.text "results"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "invoice_items", force: :cascade do |t|
t.integer "invoice_id"
t.string "stp_invoice_item_id"

View File

@ -82,6 +82,12 @@ It is not recommended to disable spaces if at least one space reservation was ma
If set to 'true', the online payment won't be available and the you'll be only able to process reservations when logged as admin.
Valid stripe API keys are still required, even if you don't require online payments.
FABLAB_WITHOUT_INVOICES
If set to 'true', the invoices will be disabled.
This is useful if you have your own invoicing system and you want to prevent Fab-manager from generating and sending invoices to members.
**Very important**: if you disable invoices, you still have to configure VAT in the interface to prevent errors in accounting and prices.
DEFAULT_MAIL_FROM
When sending notification mails, the platform will use this address to identify the sender.
@ -101,6 +107,11 @@ DEFAULT_HOST is also used to configure Google Analytics.
Identifier of your Google Analytics account.
RECAPTCHA_SITE_KEY, RECAPTCHA_SECRET_KEY
Configuration keys of Google ReCaptcha V2 (Checkbox).
This is optional, the captcha will be displayed on the sign-up form, only if these keys are filled.
DISQUS_SHORTNAME
Unique identifier of your [Disqus](http://www.disqus.com) forum.
@ -148,6 +159,17 @@ Maximum size (in bytes) allowed for image uploaded on the platform.
This parameter concerns events, plans, user's avatars, projects and steps of projects.
If this parameter is not specified the maximum size allowed will be 2MB.
MAX_CAO_SIZE
Maximum size (in bytes) allowed for CAO files uploaded on the platform, as project attachments.
If this parameter is not specified, the maximum size allowed will be 5MB.
MAX_IMPORT_SIZE
Maximum size (in bytes) allowed for import files uploaded on the platform.
Currently, this is only used to import users from a CSV file.
If this parameter is not specified, the maximum size allowed will be 5MB.
DISK_SPACE_MB_ALERT
Threshold in MB of the minimum free disk space available on the current mount point.

View File

@ -12,6 +12,7 @@ services:
- ${PWD}/public/uploads:/usr/src/app/public/uploads
- ${PWD}/invoices:/usr/src/app/invoices
- ${PWD}/exports:/usr/src/app/exports
- ${PWD}/imports:/usr/src/app/imports
- ${PWD}/log:/var/log/supervisor
- ${PWD}/plugins:/usr/src/app/plugins
- ${PWD}/accounting:/usr/src/app/accounting

View File

@ -12,6 +12,7 @@ INVOICE_PREFIX=Demo-FabLab-facture
FABLAB_WITHOUT_PLANS=false
FABLAB_WITHOUT_SPACES=true
FABLAB_WITHOUT_ONLINE_PAYMENT=true
FABLAB_WITHOUT_INVOICES=false
DEFAULT_MAIL_FROM=Fab Manager Demo <noreply@fab-manager.com>
DEFAULT_HOST=demo.fab-manager.com
@ -28,6 +29,9 @@ SMTP_OPENSSL_VERIFY_MODE=
GA_ID=
RECAPTCHA_SITE_KEY=
RECAPTCHA_SECRET_KEY=
DISQUS_SHORTNAME=
TWITTER_NAME=FablabGrenoble
@ -69,5 +73,9 @@ SUPERADMIN_EMAIL='admin@sleede.com'
ALLOWED_EXTENSIONS=pdf ai eps cad math svg stl dxf dwg obj step iges igs 3dm 3dmf doc docx png ino scad fcad skp sldprt sldasm slddrw slddrt tex latex ps
ALLOWED_MIME_TYPES=application/pdf application/postscript application/illustrator image/x-eps image/svg+xml application/sla application/dxf application/acad application/dwg application/octet-stream application/step application/iges model/iges x-world/x-3dmf application/ application/vnd.openxmlformats-officedocument.wordprocessingml.document image/png text/x-arduino text/plain application/scad application/vnd.sketchup.skp application/x-koan application/vnd-koan koan/x-skm application/vnd.koan application/x-tex application/x-latex
# 5242880 = 5 megabytes
MAX_IMPORT_SIZE = '5242880'
# 10485760 = 10 megabytes
MAX_IMAGE_SIZE=10485760
# 20971520 = 20 megabytes
MAX_CAO_SIZE = '20971520'

View File

@ -40,6 +40,7 @@
"angular-medium-editor": "https://github.com/thijsw/angular-medium-editor.git#0.1.1",
"angular-minicolors": "https://github.com/kaihenzler/angular-minicolors.git#0.0.5",
"angular-moment": "1.3",
"angular-recaptcha": "^4.2.0",
"angular-resource": "1.6",
"angular-sanitize": "1.6",
"angular-scroll": "0.6",

3
public/example.csv Normal file
View File

@ -0,0 +1,3 @@
id;gender;first_name;last_name;username;email;password;birthdate;address;phone;group;tags;trainings;website;job;interests;softwares;allow_contact;allow_newsletter;organization_name;organization_address;facebook;twitter;googleplus;viadeo;linkedin;instagram;youtube;vimeo;dailymotion;github;echosciences;pinterest;lastfm;flickr
;male;jean;dupont;jdupont;jean.dupont@gmail.com;;1970-01-01;12 bvd Libération - 75000 Paris;0123456789;standard;1,2;1;http://www.example.com;Charpentier;Ping-pong;AutoCAD;yes;no;;;http://www.facebook.com/jdupont;;;;;;;;;http://github.com/example;;;;
43;;;;;;newpassword
1 id;gender;first_name;last_name;username;email;password;birthdate;address;phone;group;tags;trainings;website;job;interests;softwares;allow_contact;allow_newsletter;organization_name;organization_address;facebook;twitter;googleplus;viadeo;linkedin;instagram;youtube;vimeo;dailymotion;github;echosciences;pinterest;lastfm;flickr
2 ;male;jean;dupont;jdupont;jean.dupont@gmail.com;;1970-01-01;12 bvd Libération - 75000 Paris;0123456789;standard;1,2;1;http://www.example.com;Charpentier;Ping-pong;AutoCAD;yes;no;;;http://www.facebook.com/jdupont;;;;;;;;;http://github.com/example;;;;
3 43;;;;;;newpassword

9
test/fixtures/imports.yml vendored Normal file
View File

@ -0,0 +1,9 @@
# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
author_id: 1
attachment: 'users.csv'
update_field: 'id'
created_at: 2019-09-24 15:06:22.151882000 Z
updated_at: 2019-09-24 15:06:22.151882000 Z

View File

@ -1,7 +0,0 @@
require 'test_helper'
class InvoicingProfileTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View File

@ -121,6 +121,11 @@ angular-moment@1.3:
dependencies:
moment ">=2.8.0 <3.0.0"
angular-recaptcha@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/angular-recaptcha/-/angular-recaptcha-4.2.0.tgz#3533802ae0d43ac9fedd5f6104e9dbf3c10588a7"
integrity sha512-PZ4VsUiNKNqaGqCpTAEwyAWUNF0w7xFulMf3ajJVym7bidxPEi5py1jQdxj4LFIWUxoA7XPan4sJMK4UeB8Srg==
angular-resource@1.6:
version "1.6.10"
resolved "https://registry.yarnpkg.com/angular-resource/-/angular-resource-1.6.10.tgz#28c1834e9fa623467d2f9894716a4e6c7e077459"