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

Merge branch 'ics' into dev

This commit is contained in:
Sylvain 2019-12-03 15:30:28 +01:00
commit 11e74c6859
46 changed files with 820 additions and 92 deletions

View File

@ -14,6 +14,7 @@
- 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

@ -152,3 +152,5 @@ gem 'sys-filesystem'
gem 'sha3'
gem 'repost'
gem 'icalendar'

View File

@ -191,6 +191,9 @@ GEM
multi_xml (>= 0.5.2)
i18n (0.9.5)
concurrent-ruby (~> 1.0)
icalendar (2.5.3)
ice_cube (~> 0.16)
ice_cube (0.16.3)
ice_nine (0.11.2)
jaro_winkler (1.5.1)
jbuilder (2.5.0)
@ -497,6 +500,7 @@ DEPENDENCIES
forgery
friendly_id (~> 5.1.0)
has_secure_token
icalendar
jbuilder (~> 2.5)
jbuilder_cache_multi
jquery-rails

View File

@ -755,3 +755,109 @@ Application.Controllers.controller('DeleteRecurrentAvailabilityController', ['$s
}
}
]);
/**
* Controller used in the iCalendar (ICS) imports management page
*/
Application.Controllers.controller('AdminICalendarController', ['$scope', 'iCalendars', 'ICalendar', 'dialogs', 'growl', '_t',
function ($scope, iCalendars, ICalendar, dialogs, growl, _t) {
// list of ICS sources
$scope.calendars = iCalendars;
// configuration of a new ICS source
$scope.newCalendar = {
color: undefined,
text_color: undefined,
url: undefined,
name: undefined,
text_hidden: 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.text_color = null;
$scope.newCalendar.text_hidden = 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.text_color,
'width': calendar.text_hidden ? '50px' : 'auto',
'height': calendar.text_hidden ? '21px' : 'auto'
};
}
/**
* Delete the given calendar from the database
* @param calendar
*/
$scope.delete = function (calendar) {
dialogs.confirm(
{
resolve: {
object () {
return {
title: _t('icalendar.confirmation_required'),
msg: _t('icalendar.confirm_delete_import')
};
}
}
},
function () {
ICalendar.delete(
{ id: calendar.id },
function () {
// success
const idx = $scope.calendars.indexOf(calendar);
$scope.calendars.splice(idx, 1);
growl.info(_t('icalendar.delete_success'));
}, 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',
function ($scope, $state, $aside, moment, Availability, Slot, Setting, growl, dialogs, bookingWindowStart, bookingWindowEnd, _t, uiCalendarConfig, CalendarConfig, trainingsPromise, machinesPromise, spacesPromise) {
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,6 +38,9 @@ Application.Controllers.controller('CalendarController', ['$scope', '$state', '$
// List of spaces
$scope.spaces = spacesPromise.filter(t => !t.disabled);
// List of external iCalendar sources
$scope.externals = iCalendarPromise.map(e => Object.assign(e, { checked: true }));
// add availabilities source to event sources
$scope.eventSources = [];
@ -48,10 +51,41 @@ 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();
// external iCalendar events sources
$scope.externals.forEach(e => {
if (e.checked) {
if (!$scope.eventSources.some(evt => evt.id === e.id)) {
$scope.eventSources.push({
id: e.id,
url: `/api/i_calendar/${e.id}/events`,
textColor: e.text_color || '#000',
color: e.color
});
}
} else {
if ($scope.eventSources.some(evt => evt.id === e.id)) {
const idx = $scope.eventSources.findIndex(evt => evt.id === e.id);
$scope.eventSources.splice(idx, 1);
}
}
});
uiCalendarConfig.calendars.calendar.fullCalendar('refetchEventSources');
};
/**
* Return a CSS-like style of the given calendar configuration
* @param calendar
*/
$scope.calendarStyle = function (calendar) {
return {
'border-color': calendar.color,
'color': calendar.text_color
};
};
// a variable for formation/machine/event/dispo checkbox is or not checked
@ -59,6 +93,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
};
@ -85,6 +120,9 @@ Application.Controllers.controller('CalendarController', ['$scope', '$state', '$
spaces () {
return $scope.spaces;
},
externals () {
return $scope.externals;
},
filter () {
return $scope.filter;
},
@ -95,10 +133,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);
@ -114,78 +153,11 @@ Application.Controllers.controller('CalendarController', ['$scope', '$state', '$
/* PRIVATE SCOPE */
const calendarEventClickCb = function (event, jsEvent, view) {
// current calendar object
const { calendar } = uiCalendarConfig.calendars;
if (event.available_type === 'machines') {
currentMachineEvent = event;
calendar.fullCalendar('changeView', 'agendaDay');
return calendar.fullCalendar('gotoDate', event.start);
} else if (event.available_type === 'space') {
calendar.fullCalendar('changeView', 'agendaDay');
return calendar.fullCalendar('gotoDate', event.start);
} else if (event.available_type === 'event') {
return $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 });
} else {
if (event.machine_id) {
return $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 });
}
}
};
// agendaDay view: disable slotEventOverlap
// agendaWeek view: enable slotEventOverlap
const toggleSlotEventOverlap = function (view) {
// set defaultView, because when we change slotEventOverlap
// ui-calendar will trigger rerender calendar
$scope.calendarConfig.defaultView = view.type;
const today = currentMachineEvent ? currentMachineEvent.start : moment().utc().startOf('day');
if ((today > view.intervalStart) && (today < view.intervalEnd) && (today !== view.intervalStart)) {
$scope.calendarConfig.defaultDate = today;
} else {
$scope.calendarConfig.defaultDate = view.intervalStart;
}
if (view.type === 'agendaDay') {
return $scope.calendarConfig.slotEventOverlap = false;
} else {
return $scope.calendarConfig.slotEventOverlap = true;
}
};
// function is called when calendar view is rendered or changed
const viewRenderCb = function (view, element) {
toggleSlotEventOverlap(view);
if (view.type === 'agendaDay') {
// get availabilties by 1 day for show machine slots
return uiCalendarConfig.calendars.calendar.fullCalendar('refetchEvents');
}
};
const eventRenderCb = function (event, element) {
if (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> `;
}
element.find('.fc-title').append(`<br/>${html}`);
}
};
const getFilter = function () {
const t = $scope.trainings.filter(t => t.checked).map(t => t.id);
const m = $scope.machines.filter(m => m.checked).map(m => m.id);
const s = $scope.spaces.filter(s => s.checked).map(s => s.id);
return { t, m, s, evt: $scope.filter.evt, dispo: $scope.filter.dispo };
};
var availabilitySourceUrl = () => `/api/availabilities/public?${$.param(getFilter())}`;
const initialize = () =>
// fullCalendar (v2) configuration
/**
* Kind of constructor: these actions will be realized first when the controller is loaded
*/
const initialize = () => {
// fullCalendar (v2) configuration
$scope.calendarConfig = CalendarConfig({
events: availabilitySourceUrl(),
slotEventOverlap: true,
@ -207,6 +179,97 @@ Application.Controllers.controller('CalendarController', ['$scope', '$state', '$
return eventRenderCb(event, element);
}
});
$scope.externals.forEach(e => {
if (e.checked) {
$scope.eventSources.push({
id: e.id,
url: `/api/i_calendar/${e.id}/events`,
textColor: e.text_color || '#000',
color: e.color
});
}
});
};
/**
* Callback triggered when an event object is clicked in the fullCalendar view
*/
const calendarEventClickCb = function (event, jsEvent, view) {
// current calendar object
const { calendar } = uiCalendarConfig.calendars;
if (event.available_type === 'machines') {
currentMachineEvent = event;
calendar.fullCalendar('changeView', 'agendaDay');
calendar.fullCalendar('gotoDate', event.start);
} else if (event.available_type === 'space') {
calendar.fullCalendar('changeView', 'agendaDay');
calendar.fullCalendar('gotoDate', event.start);
} else if (event.available_type === 'event') {
$state.go('app.public.events_show', { id: event.event_id });
} else if (event.available_type === 'training') {
$state.go('app.public.training_show', { id: event.training_id });
} else {
if (event.machine_id) {
$state.go('app.public.machines_show', { id: event.machine_id });
} else if (event.space_id) {
$state.go('app.public.space_show', { id: event.space_id });
}
}
};
// agendaDay view: disable slotEventOverlap
// agendaWeek view: enable slotEventOverlap
const toggleSlotEventOverlap = function (view) {
// set defaultView, because when we change slotEventOverlap
// ui-calendar will trigger rerender calendar
$scope.calendarConfig.defaultView = view.type;
const today = currentMachineEvent ? currentMachineEvent.start : moment().utc().startOf('day');
if ((today > view.intervalStart) && (today < view.intervalEnd) && (today !== view.intervalStart)) {
$scope.calendarConfig.defaultDate = today;
} else {
$scope.calendarConfig.defaultDate = view.intervalStart;
}
if (view.type === 'agendaDay') {
return $scope.calendarConfig.slotEventOverlap = false;
} else {
return $scope.calendarConfig.slotEventOverlap = true;
}
};
/**
* This function is called when calendar view is rendered or changed
* @see https://fullcalendar.io/docs/v3/viewRender#v2
*/
const viewRenderCb = function (view, element) {
toggleSlotEventOverlap(view);
if (view.type === 'agendaDay') {
// get availabilties by 1 day for show machine slots
return uiCalendarConfig.calendars.calendar.fullCalendar('refetchEvents');
}
};
/**
* Callback triggered by fullCalendar when it is about to render an event.
* @see https://fullcalendar.io/docs/v3/eventRender#v2
*/
const eventRenderCb = function (event, element) {
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> `;
}
element.find('.fc-title').append(`<br/>${html}`);
}
};
const getFilter = function () {
const t = $scope.trainings.filter(t => t.checked).map(t => t.id);
const m = $scope.machines.filter(m => m.checked).map(m => m.id);
const s = $scope.spaces.filter(s => s.checked).map(s => s.id);
return { t, m, s, evt: $scope.filter.evt, dispo: $scope.filter.dispo };
};
var availabilitySourceUrl = () => `/api/availabilities/public?${$.param(getFilter())}`;
// !!! MUST BE CALLED AT THE END of the controller
return initialize();

View File

@ -638,6 +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; }],
iCalendarPromise: ['ICalendar', function (ICalendar) { return ICalendar.query().$promise; }],
translations: ['Translations', function (Translations) { return Translations.query(['app.public.calendar']).$promise; }]
}
})
@ -659,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,5 @@
'use strict';
Application.Services.factory('Ical', ['$resource', function ($resource) {
return $resource('/api/ical/externals');
}]);

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

@ -670,5 +670,6 @@ padding: 10px;
font-size: 10px;
padding: 2px;
margin-left: 10px;
display: inline-block;
}
}

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,24 @@
.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;
}
.external-calendar-legend {
border-left: 3px solid;
border-radius: 3px;
}

View File

@ -5,15 +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" ng-class="{'p-s': !fablabWithoutSpaces}">
<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>
@ -28,7 +30,7 @@
<div class="calendar-legend-block">
<h4 translate>{{ 'admin_calendar.legend' }}</h4>
<div class="legends">
<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-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>

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: 20%;"></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.text_hidden ? '' : '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.text_color" class="form-control" placeholder="{{ 'icalendar.text_color' | translate}}" ng-required="!newCalendar.text_hidden"/>
</div>
<div class="input-group">
<label for="hideText" class="control-label m-r" translate>{{ 'icalendar.hide_text' }}</label>
<input bs-switch
ng-model="newCalendar.text_hidden"
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" ng-hide="externals.length == 0">
<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 ng-style="calendarStyle(e)" class="col-md-11 col-sm-11 col-xs-11 external-calendar-legend">{{::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,52 @@
# 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
ICalendarImportWorker.perform_async([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

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
# API Controller for resources of type iCalendar
class API::IcalController < API::ApiController
respond_to :json
def externals
require 'net/http'
require 'uri'
ics = Net::HTTP.get(URI.parse('https://calendar.google.com/calendar/ical/sylvain%40sleede.com/public/basic.ics'))
require 'icalendar'
require 'icalendar/tzinfo'
cals = Icalendar::Calendar.parse(ics)
@events = cals.first.events
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, dependent: :destroy
after_create :sync_events
private
def sync_events
worker = ICalendarImportWorker.new
worker.perform([id])
end
end

View File

@ -0,0 +1,12 @@
# frozen_string_literal: true
# iCalendar (RFC 5545) event, belonging to an ICalendar object (its source)
class ICalendarEvent < ActiveRecord::Base
belongs_to :i_calendar
def self.update_or_create_by(args, attributes)
obj = find_or_create_by(args)
obj.update(attributes)
obj
end
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,35 @@
# 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'
uids = []
i_cal = ICalendar.find(i_calendar_id)
ics = Net::HTTP.get(URI.parse(i_cal.url))
cals = Icalendar::Calendar.parse(ics)
# create new events and update existings
cals.each do |cal|
cal.events.each do |evt|
uids.push(evt.uid.to_s)
ICalendarEvent.update_or_create_by(
{ uid: evt.uid.to_s },
{
dtstart: evt.dtstart,
dtend: evt.dtend,
summary: evt.summary,
description: evt.description,
i_calendar_id: i_calendar_id
}
)
end
end
# remove deleted events
ICalendarEvent.where(i_calendar_id: i_calendar_id).where.not(uid: uids).destroy_all
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

@ -0,0 +1,8 @@
json.array!(@events) do |event|
json.id event.uid
json.title event.summary
json.start event.dtstart.iso8601
json.end event.dtend.iso8601
json.backgroundColor 'white'
json.borderColor '#214712'
end

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

@ -88,6 +88,28 @@ en:
view_reservations: "View reservations"
legend: "legend"
icalendar:
icalendar:
icalendar_import: "iCalendar import"
intro: "Fab-manager allows to automatically import calendar events, at RFC 5545 iCalendar format, from external URL. These URL are synchronized every nights and the events are shown in the public calendar."
new_import: "New ICS import"
color: "Colour"
text_color: "Text colour"
url: "URL"
name: "Name"
example: "Example"
display: "Display"
hide_text: "Hide the text"
hidden: "Hidden"
shown: "Shown"
create_error: "Unable to create iCalendar import. Please try again later"
delete_failed: "Unable to delete the iCalendar import. Please try again later"
refresh: "Updating..."
sync_failed: "Unable to synchronize the URL. Please try again later"
confirmation_required: "Confirmation required"
confirm_delete_import: "Do you really want to delete this iCalendar import?"
delete_success: "iCalendar import successfully deleted"
project_elements:
# management of the projects' components
project_elements:
@ -678,6 +700,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

@ -88,6 +88,28 @@ es:
view_reservations: "Ver reservas" # translation_missing
legend: "leyenda"
icalendar:
icalendar:
icalendar_import: "iCalendar import" # translation_missing
intro: "Fab-manager allows to automatically import calendar events, at RFC 5545 iCalendar format, from external URL. These URL are synchronized every nights and the events are shown in the public calendar." # translation_missing
new_import: "New ICS import" # translation_missing
color: "Colour" # translation_missing
text_color: "Text colour" # translation_missing
url: "URL" # translation_missing
name: "Name" # translation_missing
example: "Example" # translation_missing
display: "Display" # translation_missing
hide_text: "Hide the text" # translation_missing
hidden: "Hidden" # translation_missing
shown: "Shown" # translation_missing
create_error: "Unable to create iCalendar import. Please try again later" # translation_missing
delete_failed: "Unable to delete the iCalendar import. Please try again later" # translation_missing
refresh: "Updating..." # translation_missing
sync_failed: "Unable to synchronize the URL. Please try again later" # translation_missing
confirmation_required: "Confirmation required" # translation_missing
confirm_delete_import: "Do you really want to delete this iCalendar import?" # translation_missing
delete_success: "iCalendar import successfully deleted" # translation_missing
project_elements:
# management of the projects' components
project_elements:
@ -678,6 +700,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

@ -88,6 +88,28 @@ fr:
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"
confirmation_required: "Confirmation requise"
confirm_delete_import: "Êtes-vous sur de vouloir supprimer cet import iCalendar ?"
delete_success: "L'import iCalendar a bien été supprimé"
project_elements:
# gestion des éléments constituant les projets
project_elements:
@ -678,6 +700,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

@ -88,6 +88,28 @@ pt:
view_reservations: "Ver reservas" # translation_missing
legend: "legenda"
icalendar:
icalendar:
icalendar_import: "iCalendar import" # translation_missing
intro: "Fab-manager allows to automatically import calendar events, at RFC 5545 iCalendar format, from external URL. These URL are synchronized every nights and the events are shown in the public calendar." # translation_missing
new_import: "New ICS import" # translation_missing
color: "Colour" # translation_missing
text_color: "Text colour" # translation_missing
url: "URL" # translation_missing
name: "Name" # translation_missing
example: "Example" # translation_missing
display: "Display" # translation_missing
hide_text: "Hide the text" # translation_missing
hidden: "Hidden" # translation_missing
shown: "Shown" # translation_missing
create_error: "Unable to create iCalendar import. Please try again later" # translation_missing
delete_failed: "Unable to delete the iCalendar import. Please try again later" # translation_missing
refresh: "Updating..." # translation_missing
sync_failed: "Unable to synchronize the URL. Please try again later" # translation_missing
confirmation_required: "Confirmation required" # translation_missing
confirm_delete_import: "Do you really want to delete this iCalendar import?" # translation_missing
delete_success: "iCalendar import successfully deleted" # translation_missing
project_elements:
# management of the projects' components
project_elements:
@ -678,6 +700,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 @@ en:
machines: "Machines"
spaces: "Spaces"
events: "Events"
externals: "Other calendars"
spaces_list:
# list of spaces

View File

@ -306,6 +306,7 @@ es:
machines: "Máquinas"
spaces: "Espacios"
events: "Eventos"
externals: "Otros calendarios"
spaces_list:
# list of spaces

View File

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

View File

@ -306,6 +306,7 @@ pt:
machines: "Máquinas"
spaces: "Espaços"
events: "Eventos"
externals: "Outras agendas"
spaces_list:
# list of spaces

View File

@ -112,6 +112,11 @@ Rails.application.routes.draw do
get 'first', action: 'first', on: :collection
end
resources :i_calendar, only: %i[index create destroy] do
get 'events', on: :member
post 'sync', on: :member
end
# for admin
resources :trainings do
get :availabilities, on: :member

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