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

Merge branch 'ics' of git.sleede.com:projets/fab-manager into ics

This commit is contained in:
Sylvain 2019-12-03 09:56:37 +01:00
commit 5215d0643d
42 changed files with 638 additions and 70 deletions

View File

@ -9,11 +9,12 @@
- Configuration of phone number in members registration forms: can be required or optional, depending on `PHONE_REQUIRED` configuration
- Improved user experience in defining slots in the calendar management
- Improved notification email to the member when a rolling subscription is taken
- Calendar management: improved legend style
- Calendar management: improved legend display and visual behavior
- Handle Ctrl^C in upgrade scripts
- Updated moment-timezone
- Added freeCAD files as default allowed extensions
- Fix a bug: unable to remove the picture from a training
- Fix a bug: report errors on admin creation
- Fix a security issue: updated loofah to fix [CVE-2019-15587](https://github.com/advisories/GHSA-c3gv-9cxf-6f57)
- Fix a security issue: updated angular to 1.7.9 to fix [CVE-2019-10768](https://github.com/advisories/GHSA-89mq-4x47-5v83)
- [TODO DEPLOY] add the `SLOT_DURATION` environment variable (see [doc/environment.md](doc/environment.md#SLOT_DURATION) for configuration details)

View File

@ -88,6 +88,8 @@ angular.module('application', ['ngCookies', 'ngResource', 'ngSanitize', 'ui.rout
$rootScope.fablabWithoutInvoices = Fablab.withoutInvoices;
// Global config: if true, the phone number is required to create an account
$rootScope.phoneRequired = Fablab.phoneRequired;
// Global config: if true, the events are shown in the admin calendar
$rootScope.eventsInCalendar = Fablab.eventsInCalendar;
// 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

@ -23,7 +23,6 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
/* PRIVATE STATIC CONSTANTS */
// The calendar is divided in slots of 30 minutes
let loadingCb;
const BASE_SLOT = '00:30:00';
// The bookings can be positioned every half hours
@ -40,6 +39,9 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
// currently selected availability
$scope.availability = null;
// corresponding fullCalendar item in the DOM
$scope.availabilityDom = null;
// bind the availabilities slots with full-Calendar events
$scope.eventSources = [];
$scope.eventSources.push({
@ -62,7 +64,10 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
return calendarEventClickCb(event, jsEvent, view);
},
eventRender (event, element, view) {
return eventRenderCb(event, element);
return eventRenderCb(event, element, view);
},
viewRender(view, element) {
return viewRenderCb(view, element);
},
loading (isLoading, view) {
return loadingCb(isLoading, view);
@ -328,6 +333,12 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
var calendarEventClickCb = function (event, jsEvent, view) {
$scope.availability = event;
if ($scope.availabilityDom) {
$scope.availabilityDom.classList.remove("fc-selected")
}
$scope.availabilityDom = jsEvent.target.closest('.fc-event');
$scope.availabilityDom.classList.add("fc-selected")
// if the user has clicked on the delete event button, delete the event
if ($(jsEvent.target).hasClass('remove-event')) {
return $scope.removeSlot();
@ -360,12 +371,23 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
* Triggered when resource fetching starts/stops.
* @see https://fullcalendar.io/docs/resource_data/loading/
*/
return loadingCb = function (isLoading, view) {
const loadingCb = function (isLoading, view) {
if (isLoading) {
// we remove existing events when fetching starts to prevent duplicates
return uiCalendarConfig.calendars.calendar.fullCalendar('removeEvents');
// we remove existing events when fetching starts to prevent duplicates
uiCalendarConfig.calendars.calendar.fullCalendar('removeEvents');
}
};
/**
* Triggered when the view is changed
* @see https://fullcalendar.io/docs/v3/viewRender#v2
*/
const viewRenderCb = function(view, element) {
// we unselect the current event to keep consistency
$scope.availability = null;
$scope.availabilityDom = null;
};
}
]);
@ -733,3 +755,93 @@ Application.Controllers.controller('DeleteRecurrentAvailabilityController', ['$s
}
}
]);
/**
* Controller used in the iCalendar (ICS) imports management page
*/
Application.Controllers.controller('AdminICalendarController', ['$scope', 'iCalendars', 'ICalendar', 'growl', '_t',
function ($scope, iCalendars, ICalendar, growl, _t) {
// list of ICS sources
$scope.calendars = iCalendars;
// configuration of a new ICS source
$scope.newCalendar = {
color: undefined,
textColor: undefined,
url: undefined,
name: undefined,
textHidden: false
};
/**
* Save the new iCalendar in database
*/
$scope.save = function () {
ICalendar.save({}, { i_calendar: $scope.newCalendar }, function (data) {
// success
$scope.calendars.push(data);
$scope.newCalendar.url = undefined;
$scope.newCalendar.name = undefined;
$scope.newCalendar.color = null;
$scope.newCalendar.textColor = null;
$scope.newCalendar.textHidden = false;
}, function (error) {
// failed
growl.error(_t('icalendar.create_error'));
console.error(error);
})
}
/**
* Return a CSS-like style of the given calendar configuration
* @param calendar
*/
$scope.calendarStyle = function (calendar) {
return {
'border-color': calendar.color,
'color': calendar.textColor,
'width': calendar.textHidden ? '50px' : 'auto',
'height': calendar.textHidden ? '21px' : 'auto'
};
}
/**
* Delete the given calendar from the database
* @param calendar
*/
$scope.delete = function (calendar) {
ICalendar.delete(
{ id: calendar.id },
function () {
// success
const idx = $scope.calendars.indexOf(calendar);
$scope.calendars.splice(idx, 1);
}, function (error) {
// failed
growl.error(_t('icalendar.delete_failed'));
console.error(error);
});
}
/**
* Asynchronously re-fetches the events from the given calendar
* @param calendar
*/
$scope.sync = function (calendar) {
ICalendar.sync(
{ id: calendar.id },
function () {
// success
growl.info(_t('icalendar.refresh'));
}, function (error) {
// failed
growl.error(_t('icalendar.sync_failed'));
console.error(error);
}
)
}
}
]);

View File

@ -754,7 +754,8 @@ Application.Controllers.controller('NewAdminController', ['$state', '$scope', 'A
return $state.go('app.admin.members');
}
, function (error) {
console.log(error);
growl.error(_t('failed_to_create_admin') + JSON.stringify(error.data ? error.data : error));
console.error(error);
}
);
};

View File

@ -16,8 +16,8 @@
* Controller used in the public calendar global
*/
Application.Controllers.controller('CalendarController', ['$scope', '$state', '$aside', 'moment', 'Availability', 'Slot', 'Setting', 'growl', 'dialogs', 'bookingWindowStart', 'bookingWindowEnd', '_t', 'uiCalendarConfig', 'CalendarConfig', 'trainingsPromise', 'machinesPromise', 'spacesPromise', 'externalsPromise',
function ($scope, $state, $aside, moment, Availability, Slot, Setting, growl, dialogs, bookingWindowStart, bookingWindowEnd, _t, uiCalendarConfig, CalendarConfig, trainingsPromise, machinesPromise, spacesPromise, externalsPromise) {
Application.Controllers.controller('CalendarController', ['$scope', '$state', '$aside', 'moment', 'Availability', 'Slot', 'Setting', 'growl', 'dialogs', 'bookingWindowStart', 'bookingWindowEnd', '_t', 'uiCalendarConfig', 'CalendarConfig', 'trainingsPromise', 'machinesPromise', 'spacesPromise', 'iCalendarPromise',
function ($scope, $state, $aside, moment, Availability, Slot, Setting, growl, dialogs, bookingWindowStart, bookingWindowEnd, _t, uiCalendarConfig, CalendarConfig, trainingsPromise, machinesPromise, spacesPromise, iCalendarPromise) {
/* PRIVATE STATIC CONSTANTS */
let currentMachineEvent = null;
machinesPromise.forEach(m => m.checked = true);
@ -38,8 +38,8 @@ Application.Controllers.controller('CalendarController', ['$scope', '$state', '$
// List of spaces
$scope.spaces = spacesPromise.filter(t => !t.disabled);
// External ICS calendars
$scope.externals = externalsPromise;
// List of external iCalendar sources
$scope.externals = iCalendarPromise;
// add availabilities source to event sources
$scope.eventSources = [];
@ -51,10 +51,19 @@ Application.Controllers.controller('CalendarController', ['$scope', '$state', '$
trainings: isSelectAll('trainings', scope),
machines: isSelectAll('machines', scope),
spaces: isSelectAll('spaces', scope),
externals: isSelectAll('externals', scope),
evt: filter.evt,
dispo: filter.dispo
});
return $scope.calendarConfig.events = availabilitySourceUrl();
$scope.calendarConfig.events = availabilitySourceUrl();
$scope.externals.filter(e => e.checked).forEach(e => {
$scope.eventSources.push({
url: `/api/i_calendar/${e.id}/events`,
textColor: e.textColor,
color: e.color
});
});
return uiCalendarConfig.calendars.calendar.fullCalendar('refetchEvents');
};
// a variable for formation/machine/event/dispo checkbox is or not checked
@ -62,6 +71,7 @@ Application.Controllers.controller('CalendarController', ['$scope', '$state', '$
trainings: isSelectAll('trainings', $scope),
machines: isSelectAll('machines', $scope),
spaces: isSelectAll('spaces', $scope),
externals: isSelectAll('externals', $scope),
evt: true,
dispo: true
};
@ -88,6 +98,9 @@ Application.Controllers.controller('CalendarController', ['$scope', '$state', '$
spaces () {
return $scope.spaces;
},
externals () {
return $scope.externals;
},
filter () {
return $scope.filter;
},
@ -98,10 +111,11 @@ Application.Controllers.controller('CalendarController', ['$scope', '$state', '$
return $scope.filterAvailabilities;
}
},
controller: ['$scope', '$uibModalInstance', 'trainings', 'machines', 'spaces', 'filter', 'toggleFilter', 'filterAvailabilities', function ($scope, $uibModalInstance, trainings, machines, spaces, filter, toggleFilter, filterAvailabilities) {
controller: ['$scope', '$uibModalInstance', 'trainings', 'machines', 'spaces', 'externals', 'filter', 'toggleFilter', 'filterAvailabilities', function ($scope, $uibModalInstance, trainings, machines, spaces, externals, filter, toggleFilter, filterAvailabilities) {
$scope.trainings = trainings;
$scope.machines = machines;
$scope.spaces = spaces;
$scope.externals = externals;
$scope.filter = filter;
$scope.toggleFilter = (type, filter) => toggleFilter(type, filter);
@ -123,19 +137,19 @@ Application.Controllers.controller('CalendarController', ['$scope', '$state', '$
if (event.available_type === 'machines') {
currentMachineEvent = event;
calendar.fullCalendar('changeView', 'agendaDay');
return calendar.fullCalendar('gotoDate', event.start);
calendar.fullCalendar('gotoDate', event.start);
} else if (event.available_type === 'space') {
calendar.fullCalendar('changeView', 'agendaDay');
return calendar.fullCalendar('gotoDate', event.start);
calendar.fullCalendar('gotoDate', event.start);
} else if (event.available_type === 'event') {
return $state.go('app.public.events_show', { id: event.event_id });
$state.go('app.public.events_show', { id: event.event_id });
} else if (event.available_type === 'training') {
return $state.go('app.public.training_show', { id: event.training_id });
$state.go('app.public.training_show', { id: event.training_id });
} else {
if (event.machine_id) {
return $state.go('app.public.machines_show', { id: event.machine_id });
$state.go('app.public.machines_show', { id: event.machine_id });
} else if (event.space_id) {
return $state.go('app.public.space_show', { id: event.space_id });
$state.go('app.public.space_show', { id: event.space_id });
}
}
};
@ -169,7 +183,7 @@ Application.Controllers.controller('CalendarController', ['$scope', '$state', '$
};
const eventRenderCb = function (event, element) {
if (event.tags.length > 0) {
if (event.tags && event.tags.length > 0) {
let html = '';
for (let tag of Array.from(event.tags)) {
html += `<span class='label label-success text-white'>${tag.name}</span> `;

View File

@ -638,7 +638,7 @@ angular.module('application.router', ['ui.router'])
trainingsPromise: ['Training', function (Training) { return Training.query().$promise; }],
machinesPromise: ['Machine', function (Machine) { return Machine.query().$promise; }],
spacesPromise: ['Space', function (Space) { return Space.query().$promise; }],
externalsPromise: ['Ical', function (Ical) { return Ical.get().$promise; }],
iCalendarPromise: ['ICalendar', function (ICalendar) { return ICalendar.query().$promise; }],
translations: ['Translations', function (Translations) { return Translations.query(['app.public.calendar']).$promise; }]
}
})
@ -660,6 +660,19 @@ angular.module('application.router', ['ui.router'])
translations: ['Translations', function (Translations) { return Translations.query('app.admin.calendar').$promise; }]
}
})
.state('app.admin.calendar.icalendar', {
url: '/admin/calendar/icalendar',
views: {
'main@': {
templateUrl: '<%= asset_path "admin/calendar/icalendar.html" %>',
controller: 'AdminICalendarController'
}
},
resolve: {
iCalendars: ['ICalendar', function (ICalendar) { return ICalendar.query().$promise; }],
translations: ['Translations', function (Translations) { return Translations.query('app.admin.icalendar').$promise; }]
}
})
// project's elements
.state('app.admin.project_elements', {

View File

@ -0,0 +1,17 @@
'use strict';
Application.Services.factory('ICalendar', ['$resource', function ($resource) {
return $resource('/api/i_calendar/:id',
{ id: '@id' }, {
events: {
method: 'GET',
url: '/api/i_calendar/:id/events'
},
sync: {
method: 'POST',
url: '/api/i_calendar/:id/sync',
params: { id: '@id' }
}
}
);
}]);

View File

@ -651,11 +651,25 @@ padding: 10px;
text-transform: uppercase;
}
}
.calendar-legend-block {
text-align: right;
padding-right: 2em;
.calendar-legend {
border: 1px solid;
border-left: 3px solid;
border-radius: 3px;
font-size: 10px;
padding: 2px;
}
h4 {
font-size: 12px;
font-style: italic;
}
.legends {
display: flex;
flex-direction: row-reverse;
}
.calendar-legend {
border: 1px solid;
border-left: 3px solid;
border-radius: 3px;
font-size: 10px;
padding: 2px;
margin-left: 10px;
display: inline-block;
}
}

View File

@ -101,22 +101,6 @@
}
}
.display-h {
display: flex;
flex-direction: row;
justify-content: space-evenly;
padding: 0;
}
.display-v {
display: flex;
flex-direction: column;
justify-content: space-around;
align-self: baseline;
height: inherit;
}
body.container{
padding: 0;
}
@ -651,4 +635,4 @@ body.container{
position: absolute;
left: -4px;
}
}
}

View File

@ -1,4 +1,4 @@
// medium editor placeholder
// medium editor placeholder
.medium-editor-placeholder {
min-height: 30px; // fix for firefox
}
@ -126,6 +126,10 @@
}
}
.fc-selected {
box-shadow: 0 6px 10px 0 rgba(0,0,0,0.14),0 1px 18px 0 rgba(0,0,0,0.12),0 3px 5px -1px rgba(0,0,0,0.2);
}

View File

@ -102,6 +102,7 @@ p, .widget p {
.text-italic { font-style: italic; }
.text-left { text-align: left !important; }
.text-center { text-align: center; }
.text-right { text-align: right; }

View File

@ -32,11 +32,7 @@
@import "app.buttons";
@import "app.components";
@import "app.plugins";
@import "modules/invoice";
@import "modules/signup";
@import "modules/abuses";
@import "modules/cookies";
@import "modules/stripe";
@import "modules/*";
@import "app.responsive";

View File

@ -0,0 +1,19 @@
.calendar-form {
margin : 2em;
border: 1px solid #ddd;
border-radius: 3px;
padding: 1em;
& > .input-group, & > .minicolors {
margin-top: 1em;
}
}
.calendar-name {
font-weight: 600;
font-style: italic;
}
.calendar-url {
overflow: hidden;
}

View File

@ -5,22 +5,17 @@
<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 b-r-md">
<div class="col-xs-10 b-l b-r-md">
<section class="heading-title">
<h1 translate>{{ 'admin_calendar.calendar_management' }}</h1>
</section>
</div>
<div class="col-xs-12 col-sm-12 col-md-3 b-t hide-b-md">
<section class="heading-actions wrapper display-h" ng-class="{'p-s': !fablabWithoutSpaces}">
<div class="display-v">
<span class="calendar-legend text-sm border-formation" ng-class="{'m-t-sm': fablabWithoutSpaces}" translate>{{ 'admin_calendar.trainings' }}</span><br>
<span class="calendar-legend text-sm border-machine" translate>{{ 'admin_calendar.machines' }}</span><br>
</div>
<div class="display-v">
<span class="calendar-legend text-sm border-space" ng-hide="fablabWithoutSpaces" translate>{{ 'admin_calendar.spaces' }}</span>
<span class="calendar-legend text-sm border-event" ng-hide="fablabWithoutSpaces" translate>{{ 'admin_calendar.events' }}</span>
</div>
<div class="col-xs-1">
<section class="heading-actions wrapper">
<a role="button" ui-sref="app.admin.calendar.icalendar" class="btn btn-default b-2x rounded pointer m-t-sm">
<i class="fa fa-exchange" aria-hidden="true"></i>
</a>
</section>
</div>
@ -32,6 +27,15 @@
<div class="col-sm-12 col-md-12 col-lg-9">
<div ui-calendar="calendarConfig" ng-model="eventSources" calendar="calendar" class="wrapper-lg"></div>
<div class="calendar-legend-block">
<h4 translate>{{ 'admin_calendar.legend' }}</h4>
<div class="legends">
<span class="calendar-legend text-sm border-formation" translate>{{ 'admin_calendar.trainings' }}</span><br>
<span class="calendar-legend text-sm border-machine" translate>{{ 'admin_calendar.machines' }}</span><br>
<span class="calendar-legend text-sm border-space" ng-hide="fablabWithoutSpaces" translate>{{ 'admin_calendar.spaces' }}</span>
<span class="calendar-legend text-sm border-event" ng-show="eventsInCalendar" translate>{{ 'admin_calendar.events' }}</span>
</div>
</div>
</div>
<div class="col-sm-12 col-md-12 col-lg-3">
@ -103,7 +107,7 @@
</button>
</div>
<div class="widget-content no-bg auto wrapper" ng-show="availability.available_type == 'event'">
<a class="btn btn-default m-t pointer" ui-sref="app.admin.events_edit({id: availability.event_id})">
<a class="btn btn-default pointer" ui-sref="app.admin.events_edit({id: availability.event_id})">
<span>
<i class="fa fa-edit"></i>
<span class="m-l-xs" translate>{{ 'admin_calendar.edit_event' }}</span>
@ -119,4 +123,4 @@
</div>
</div>
</section>
</section>

View File

@ -0,0 +1,96 @@
<section class="heading b-b">
<div class="row no-gutter">
<div class="col-xs-2 col-sm-2 col-md-1">
<section class="heading-btn">
<a role="button" ng-click="backPrevLocation($event)"><i class="fa fa-long-arrow-left "></i></a>
</section>
</div>
<div class="col-md-8 b-l b-r-md">
<section class="heading-title">
<h1 translate>{{ 'icalendar.icalendar_import' }}</h1>
</section>
</div>
<div class="col-md-3">
<section class="heading-actions wrapper">
</section>
</div>
</div>
</section>
<section class="row no-gutter">
<div class="col-sm-12 col-md-12 col-lg-9">
<div class="alert alert-info m-lg" translate>
{{ 'icalendar.intro' }}
</div>
<div class="wrapper-lg">
<table class="table" ng-show="calendars.length > 0">
<thead>
<tr>
<th style="width: 35%;" translate>{{ 'icalendar.name' }}</th>
<th style="width: 35%;" translate>{{ 'icalendar.url' }}</th>
<th translate>{{ 'icalendar.display' }}</th>
<th style="width: 10%;"></th>
</tr>
</thead>
<tbody>
<tr ng-repeat="calendar in calendars">
<td class="calendar-name">{{calendar.name}}</td>
<td class="calendar-url"><a href="{{calendar.url}}" target="_blank">{{calendar.url}}</a></td>
<td class="calendar-legend-block text-left"><span class="calendar-legend" ng-style="calendarStyle(calendar)" translate> {{ calendar.textHidden ? '' : 'icalendar.example' }}</span>
<td class="calendar-actions">
<button class="btn btn-info" ng-click="sync(calendar)"><i class="fa fa-refresh"></i></button>
<button class="btn btn-danger" ng-click="delete(calendar)"><i class="fa fa-trash"></i></button>
</td>
</tr>
</tbody>
</table>
<form class="calendar-form" name="newImportForm">
<h4 translate>{{ 'icalendar.new_import' }}</h4>
<div class="input-group">
<div class="input-group-addon">
<i class="fa fa-font"></i>
</div>
<input type="text" ng-model="newCalendar.name" class="form-control" placeholder="{{ 'icalendar.name' | translate }}" required>
</div>
<div class="input-group">
<div class="input-group-addon">
<i class="fa fa-link"></i>
</div>
<input type="url" ng-model="newCalendar.url" class="form-control" placeholder="{{ 'icalendar.url' | translate }}" required>
</div>
<div class="input-group">
<div class="input-group-addon">
<i class="fa fa-paint-brush"></i>
</div>
<input type="text" minicolors ng-model="newCalendar.color" class="form-control" placeholder="{{ 'icalendar.color' | translate}}" required/>
</div>
<div class="input-group">
<div class="input-group-addon">
<i class="fa fa-paint-brush"></i>
</div>
<input type="text" minicolors ng-model="newCalendar.textColor" class="form-control" placeholder="{{ 'icalendar.text_color' | translate}}" required/>
</div>
<div class="input-group">
<label for="hideText" class="control-label m-r" translate>{{ 'icalendar.hide_text' }}</label>
<input bs-switch
ng-model="newCalendar.textHidden"
id="hideText"
type="checkbox"
class="form-control"
switch-on-text="{{ 'icalendar.hidden' | translate }}"
switch-off-text="{{ 'icalendar.shown' | translate }}"
switch-animate="true"/>
</div>
<div class="m-t text-right">
<button role="button" class="btn btn-warning" ng-click="save()" ng-disabled="newImportForm.$invalid" translate>
{{ 'confirm' }}
</button>
</div>
</form>
</div>
</div>
</section>

View File

@ -341,7 +341,9 @@
</select>
<button name="button" class="btn btn-warning m-t" ng-click="save(machinesSortBy)" translate>{{ 'save' }}</button>
</div>
<div class="col-md-4">
<div class="col-md-4">
</div>
</div>
</div>
</div>
</div>

View File

@ -41,7 +41,7 @@
<h3 translate>{{ 'calendar.filter_calendar' }}</h3>
</div>
<div class="widget-content no-bg auto wrapper calendar-filter">
<ng-include src="'<%= asset_path 'calendar/filter.html' %>'"></ng-include>
<ng-include src="'<%= asset_path "calendar/filter.html" %>'"></ng-include>
</div>
</div>
</div>
@ -56,7 +56,7 @@
<h1 class="modal-title" translate>{{ 'calendar.filter_calendar' }}</h1>
</div>
<div class="modal-body widget-content calendar-filter calendar-filter-aside">
<ng-include src="'<%= asset_path 'calendar/filter.html' %>'"></ng-include>
<ng-include src="'<%= asset_path "calendar/filter.html" %>'"></ng-include>
</div>
</div>
</script>

View File

@ -36,3 +36,14 @@
<h3 class="col-md-11 col-sm-11 col-xs-11 text-black" translate>{{ 'calendar.show_unavailables' }}</h3>
<input class="col-md-1 col-sm-1 col-xs-1" type="checkbox" ng-model="filter.dispo" ng-change="filterAvailabilities(filter)">
</div>
<div class="m-t">
<div class="row">
<h3 class="col-md-11 col-sm-11 col-xs-11 text-black" translate>{{ 'calendar.externals' }}</h3>
<input class="col-md-1 col-sm-1 col-xs-1" type="checkbox" ng-model="filter.externals" ng-change="toggleFilter('externals', filter)">
</div>
<div class="row" ng-repeat="e in externals">
<span class="col-md-11 col-sm-11 col-xs-11">{{::e.name}}</span>
<input class="col-md-1 col-sm-1 col-xs-1" type="checkbox" ng-model="e.checked" ng-change="filterAvailabilities(filter)">
</div>
</div>

View File

@ -0,0 +1,53 @@
# frozen_string_literal: true
# API Controller for resources of type iCalendar
class API::ICalendarController < API::ApiController
before_action :authenticate_user!, except: %i[index events]
before_action :set_i_cal, only: [:destroy]
respond_to :json
def index
@i_cals = ICalendar.all
end
def create
authorize ICalendar
@i_cal = ICalendar.new(i_calendar_params)
if @i_cal.save
render :show, status: :created, location: @i_cal
else
render json: @i_cal.errors, status: :unprocessable_entity
end
end
def destroy
authorize ICalendar
@i_cal.destroy
head :no_content
end
def events
start_date = ActiveSupport::TimeZone[params[:timezone]]&.parse(params[:start])
end_date = ActiveSupport::TimeZone[params[:timezone]]&.parse(params[:end])&.end_of_day
@events = ICalendarEvent.where(i_calendar_id: params[:id])
.where('dtstart >= ? AND dtend <= ?', start_date, end_date)
.joins(:i_calendar)
end
def sync
worker = ICalendarImportWorker.new
worker.perform([params[:id]])
render json: { processing: [params[:id]] }, status: :created
end
private
def set_i_cal
@i_cal = ICalendar.find(params[:id])
end
def i_calendar_params
params.require(:i_calendar).permit(:name, :url, :color, :text_color, :text_hidden)
end
end

15
app/models/i_calendar.rb Normal file
View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
# iCalendar (RFC 5545) files, stored by URL and kept with their display configuration
class ICalendar < ActiveRecord::Base
has_many :i_calendar_events
after_create :sync_events
private
def sync_events
worker = ICalendarImportWorker.new
worker.perform([id])
end
end

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
# iCalendar (RFC 5545) event, belonging to an ICalendar object (its source)
class ICalendarEvent < ActiveRecord::Base
belongs_to :i_calendar
end

View File

@ -0,0 +1,12 @@
# frozen_string_literal: true
# Check the access policies for API::ICalendarController
class ICalendarPolicy < ApplicationPolicy
def create?
user.admin?
end
def destroy?
user.admin?
end
end

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
# Import all events from a given remote RFC 5545 iCalendar
class ICalendarImportService
def import(i_calendar_id)
require 'net/http'
require 'uri'
require 'icalendar'
events = []
i_cal = ICalendar.find(i_calendar_id)
ics = Net::HTTP.get(URI.parse(i_cal.url))
cals = Icalendar::Calendar.parse(ics)
cals.each do |cal|
cal.events.each do |evt|
events.push(
uid: evt.uid,
dtstart: evt.dtstart,
dtend: evt.dtend,
summary: evt.summary,
description: evt.description,
i_calendar_id: i_calendar_id
)
end
end
ICalendarEvent.create!(events)
end
end

View File

@ -41,7 +41,7 @@ class UserService
# if the authentication is made through an SSO, generate a migration token
admin.generate_auth_migration_token unless AuthProvider.active.providable_type == DatabaseProvider.name
saved = admin.save(validate: false)
saved = admin.save
if saved
admin.send_confirmation_instructions
admin.add_role(:admin)

View File

@ -0,0 +1,3 @@
# frozen_string_literal: true
json.extract! i_cal, :id, :name, :url, :color, :text_color, :text_hidden

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
json.array!(@events) do |event|
json.id event.uid
json.title event.i_calendar.text_hidden ? '' : event.summary
json.start event.dtstart.iso8601
json.end event.dtend.iso8601
json.backgroundColor 'white'
end

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
json.array!(@i_cals) do |i_cal|
json.partial! 'api/i_calendar/i_calendar', i_cal: i_cal
end

View File

@ -0,0 +1,3 @@
# frozen_string_literal: true
json.partial! 'api/i_calendar/i_calendar', i_cal: @i_cal

View File

@ -21,6 +21,7 @@
Fablab.withoutOnlinePayment = ('<%= Rails.application.secrets.fablab_without_online_payments %>' === 'true');
Fablab.withoutInvoices = ('<%= Rails.application.secrets.fablab_without_invoices %>' === 'true');
Fablab.phoneRequired = ('<%= Rails.application.secrets.phone_required %>' === 'true');
Fablab.eventsInCalendar = ('<%= Rails.application.secrets.events_in_calendar %>' === 'true');
Fablab.slotDuration = parseInt("<%= ApplicationHelper::SLOT_DURATION %>", 10);
Fablab.disqusShortname = "<%= Rails.application.secrets.disqus_shortname %>";
Fablab.defaultHost = "<%= Rails.application.secrets.default_host %>";

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
# Periodically import the iCalendar RFC 5545 events from the configured source
class ICalendarImportWorker
include Sidekiq::Worker
def perform(calendar_ids = ICalendar.all.map(&:id))
service = ICalendarImportService.new
calendar_ids.each do |id|
service.import(id)
end
end
end

View File

@ -86,6 +86,7 @@ en:
event_in_the_past: "Unable to create a slot in the past."
edit_event: "Edit the event"
view_reservations: "View reservations"
legend: "legend"
project_elements:
# management of the projects' components
@ -677,6 +678,7 @@ en:
# add a new administrator to the platform
add_an_administrator: "Add an administrator"
administrator_successfully_created_he_will_receive_his_connection_directives_by_email: "Administrator successfully created. {GENDER, select, female{She} other{He}} receive {GENDER, select, female{her} other{his}} connection directives by e-mail." # messageFormat interpolation
failed_to_create_admin: "Unable to create the administrator:"
authentication_new:
# add a new authentication provider (SSO)

View File

@ -86,6 +86,7 @@ es:
event_in_the_past: "Unable to create a slot in the past." # translation_missing
edit_event: "Edit the event" # translation_missing
view_reservations: "Ver reservas" # translation_missing
legend: "leyenda"
project_elements:
# management of the projects' components
@ -677,6 +678,7 @@ es:
# add a new administrator to the platform
add_an_administrator: "Agregar un administrador"
administrator_successfully_created_he_will_receive_his_connection_directives_by_email: "administrador creado correctamente. {GENDER, select, female{She} other{He}} receive {GENDER, select, female{her} other{his}} directivas de conexión por e-mail." # messageFormat interpolation
failed_to_create_admin: "No se puede crear el administrador :"
authentication_new:
# add a new authentication provider (SSO)

View File

@ -86,6 +86,26 @@ fr:
event_in_the_past: "Impossible de créer un créneau dans le passé."
edit_event: "Modifier l'évènement"
view_reservations: "Voir les réservations"
legend: "Légende"
icalendar:
icalendar:
icalendar_import: "Import iCalendar"
intro: "Fab-manager vous permet d'importer automatiquement des évènements de calendrier, au format iCalendar RFC 5545, depuis des URL externes. Ces URL seront synchronisée toutes les nuits et les évènements seront affichés dans le calendrier publique."
new_import: "Nouvel import ICS"
color: "Couleur"
text_color: "Couleur du texte"
url: "URL"
name: "Nom"
example: "Exemple"
display: "Affichage"
hide_text: "Cacher le texte"
hidden: "Caché"
shown: "Affiché"
create_error: "Impossible de créer l'import iCalendar. Veuillez réessayer ultérieurement"
delete_failed: "Impossible de supprimer l'import iCalendar. Veuillez réessayer ultérieurement"
refresh: "Mise à jour en cours..."
sync_failed: "Impossible de synchroniser l'URL. Veuillez réessayer ultérieurement"
project_elements:
# gestion des éléments constituant les projets
@ -677,6 +697,7 @@ fr:
# ajouter un nouvel administrateur à la plate-forme
add_an_administrator: "Ajouter un administrateur"
administrator_successfully_created_he_will_receive_his_connection_directives_by_email: "L'administrateur a bien été créé. {GENDER, select, female{Elle} other{Il}} recevra ses instructions de connexion par email." # messageFormat interpolation
failed_to_create_admin: "Impossible de créer l'administrateur :"
authentication_new:
# ajouter un nouveau fournisseur d'authentification (SSO)

View File

@ -86,6 +86,7 @@ pt:
event_in_the_past: "Unable to create a slot in the past." # translation_missing
edit_event: "Edit the event" # translation_missing
view_reservations: "Ver reservas" # translation_missing
legend: "legenda"
project_elements:
# management of the projects' components
@ -677,6 +678,7 @@ pt:
# add a new administrator to the platform
add_an_administrator: "Adicionar administrador"
administrator_successfully_created_he_will_receive_his_connection_directives_by_email: "Administrator criado com sucesso. {GENDER, select, female{Ela} other{Ele}} receberá {GENDER, select, female{sua} other{seu}} diretivas de conexão por e-mail." # messageFormat interpolation
failed_to_create_admin: "Não é possível criar administrador:"
authentication_new:
# add a new authentication provider (SSO)

View File

@ -306,6 +306,7 @@ fr:
machines: "Machines"
spaces: "Espaces"
events: "Évènements"
externals: "Autres calendriers"
spaces_list:
# liste des espaces

View File

@ -112,7 +112,10 @@ Rails.application.routes.draw do
get 'first', action: 'first', on: :collection
end
get 'ical/externals' => 'ical#externals'
resources :i_calendar, only: %i[index create destroy] do
get 'events', on: :member
post 'sync', on: :member
end
# for admin
resources :trainings do

View File

@ -15,6 +15,11 @@ generate_statistic:
class: "StatisticWorker"
queue: default
i_calendar_import:
cron: "0 2 * * *"
class: "ICalendarImportWorker"
queue: default
open_api_trace_calls_count:
cron: "0 4 * * 0" # every sunday at 4am
class: "OpenAPITraceCallsCountWorker"

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
# From this migration, we store URL to iCalendar files and a piece of configuration about them.
# This allows to display the events of these external calendars in fab-manager
class CreateICalendars < ActiveRecord::Migration
def change
create_table :i_calendars do |t|
t.string :url
t.string :name
t.string :color
t.string :text_color
t.boolean :text_hidden
t.timestamps null: false
end
end
end

View File

@ -0,0 +1,15 @@
class CreateICalendarEvents < ActiveRecord::Migration
def change
create_table :i_calendar_events do |t|
t.string :uid
t.datetime :dtstart
t.datetime :dtend
t.string :summary
t.string :description
t.string :attendee
t.belongs_to :i_calendar, index: true, foreign_key: true
t.timestamps null: false
end
end
end

View File

@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20191113103352) do
ActiveRecord::Schema.define(version: 20191202135507) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -251,6 +251,30 @@ ActiveRecord::Schema.define(version: 20191113103352) 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 "i_calendar_events", force: :cascade do |t|
t.string "uid"
t.datetime "dtstart"
t.datetime "dtend"
t.string "summary"
t.string "description"
t.string "attendee"
t.integer "i_calendar_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
add_index "i_calendar_events", ["i_calendar_id"], name: "index_i_calendar_events_on_i_calendar_id", using: :btree
create_table "i_calendars", force: :cascade do |t|
t.string "url"
t.string "name"
t.string "color"
t.string "text_color"
t.boolean "text_hidden"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "imports", force: :cascade do |t|
t.integer "user_id"
t.string "attachment"
@ -923,6 +947,7 @@ ActiveRecord::Schema.define(version: 20191113103352) do
add_foreign_key "exports", "users"
add_foreign_key "history_values", "invoicing_profiles"
add_foreign_key "history_values", "settings"
add_foreign_key "i_calendar_events", "i_calendars"
add_foreign_key "invoices", "coupons"
add_foreign_key "invoices", "invoicing_profiles"
add_foreign_key "invoices", "invoicing_profiles", column: "operator_profile_id"

19
test/fixtures/i_calendar_events.yml vendored Normal file
View File

@ -0,0 +1,19 @@
# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
uid: MyString
dtstart: 2019-12-02 14:55:07
dtend: 2019-12-02 14:55:07
summary: MyString
description: MyString
attendee: MyString
i_calendar_id:
two:
uid: MyString
dtstart: 2019-12-02 14:55:07
dtend: 2019-12-02 14:55:07
summary: MyString
description: MyString
attendee: MyString
i_calendar_id:

13
test/fixtures/i_calendars.yml vendored Normal file
View File

@ -0,0 +1,13 @@
# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
url: MyString
color: MyString
text_color: MyString
text_hidden: false
two:
url: MyString
color: MyString
text_color: MyString
text_hidden: false