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:
commit
193e81e711
@ -29,6 +29,14 @@ tmp
|
||||
# PDF invoices
|
||||
invoices
|
||||
|
||||
# Excel exports
|
||||
exports
|
||||
|
||||
# CSV imports
|
||||
imports
|
||||
|
||||
.DS_Store
|
||||
|
||||
# Development files
|
||||
.vagrant
|
||||
Vagrantfile
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -34,6 +34,9 @@
|
||||
# XLSX exports
|
||||
/exports/*
|
||||
|
||||
# CSV imports
|
||||
/imports/*
|
||||
|
||||
# Archives of cLosed accounting periods
|
||||
/accounting/*
|
||||
|
||||
|
11
CHANGELOG.md
11
CHANGELOG.md
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
*/
|
||||
|
@ -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'),
|
||||
|
@ -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: {
|
||||
|
7
app/assets/javascripts/services/import.js
Normal file
7
app/assets/javascripts/services/import.js
Normal file
@ -0,0 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
Application.Services.factory('Import', ['$resource', function ($resource) {
|
||||
return $resource('/api/imports/:id',
|
||||
{ id: '@id' }
|
||||
);
|
||||
}]);
|
@ -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; }
|
||||
|
@ -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:/font:FontAwesome/icon" bs-holder ng-if="!invoice.logo" class="img-responsive">
|
||||
|
@ -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">
|
||||
|
||||
|
||||
|
177
app/assets/templates/admin/members/import.html.erb
Normal file
177
app/assets/templates/admin/members/import.html.erb
Normal 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>
|
66
app/assets/templates/admin/members/import_result.html
Normal file
66
app/assets/templates/admin/members/import_result.html
Normal 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>
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
34
app/controllers/api/imports_controller.rb
Normal file
34
app/controllers/api/imports_controller.rb
Normal 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
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
31
app/models/import.rb
Normal 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
|
@ -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})"
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -36,5 +36,4 @@ class StatisticProfile < ActiveRecord::Base
|
||||
''
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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')
|
||||
|
@ -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
|
||||
|
@ -1,3 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Check the access policies for API::GroupsController
|
||||
class GroupPolicy < ApplicationPolicy
|
||||
def create?
|
||||
user.admin?
|
||||
|
12
app/policies/import_policy.rb
Normal file
12
app/policies/import_policy.rb
Normal 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
|
@ -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?
|
||||
|
185
app/services/members/import_service.rb
Normal file
185
app/services/members/import_service.rb
Normal 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
|
31
app/services/recaptcha_service.rb
Normal file
31
app/services/recaptcha_service.rb
Normal 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
|
28
app/uploaders/import_uploader.rb
Normal file
28
app/uploaders/import_uploader.rb
Normal 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
|
@ -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
|
||||
|
7
app/views/api/imports/show.json.jbuilder
Normal file
7
app/views/api/imports/show.json.jbuilder
Normal 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
|
@ -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)
|
@ -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
|
@ -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
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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>
|
23
app/workers/members_import_worker.rb
Normal file
23
app/workers/members_import_worker.rb
Normal 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
|
@ -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
|
||||
|
@ -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'
|
||||
|
@ -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
|
@ -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:"
|
||||
|
@ -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:"
|
||||
|
@ -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 :"
|
||||
|
@ -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:"
|
||||
|
@ -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}"
|
||||
|
@ -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"
|
||||
|
@ -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}"
|
||||
|
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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}"
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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"] %>
|
||||
|
17
db/migrate/20190924140726_create_imports.rb
Normal file
17
db/migrate/20190924140726_create_imports.rb
Normal 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
|
14
db/schema.rb
14
db/schema.rb
@ -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"
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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'
|
||||
|
@ -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
3
public/example.csv
Normal 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
|
|
9
test/fixtures/imports.yml
vendored
Normal file
9
test/fixtures/imports.yml
vendored
Normal 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
|
||||
|
@ -1,7 +0,0 @@
|
||||
require 'test_helper'
|
||||
|
||||
class InvoicingProfileTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user