add ants_hub application (#76286)
gitea/chrono/pipeline/head This commit looks good Details

It manages synchronization of meetings agendas with the ANTS hub.
This commit is contained in:
Benjamin Dauvergne 2023-04-05 15:48:01 +02:00
parent 05bce1763e
commit e09da74527
33 changed files with 2302 additions and 3 deletions

0
chrono/apps/__init__.py Normal file
View File

View File

View File

@ -0,0 +1,73 @@
# chrono - agendas system
# Copyright (C) 2023 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import requests
from django.conf import settings
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
class AntsHubException(Exception):
pass
def make_http_session(retries=3):
session = requests.Session()
retry = Retry(
total=retries,
read=retries,
connect=retries,
backoff_factor=0.5,
status_forcelist=(502, 503),
)
adapter = HTTPAdapter(max_retries=retry)
session.mount('http://', adapter)
session.mount('https://', adapter)
return session
def make_url(path):
return f'{settings.CHRONO_ANTS_HUB_URL}{path}'
def ping(timeout=1):
session = make_http_session()
try:
response = session.get(make_url('ping/'), timeout=timeout)
response.raise_for_status()
err = response.json()['err']
if err != 0:
raise AntsHubException(err)
except requests.Timeout:
pass
except (TypeError, KeyError, requests.RequestException) as e:
raise AntsHubException(str(e))
def push_rendez_vous_disponibles(payload):
session = make_http_session()
try:
response = session.post(make_url('rendez-vous-disponibles/'), json=payload)
response.raise_for_status()
data = response.json()
err = data['err']
if err != 0:
raise AntsHubException(err)
return data
except requests.Timeout:
return True
except (TypeError, KeyError, requests.RequestException) as e:
raise AntsHubException(str(e))

View File

@ -0,0 +1,26 @@
# chrono - agendas system
# Copyright (C) 2023 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.core.management.base import BaseCommand
from chrono.apps.ants_hub import models
class Command(BaseCommand):
help = 'Synchronize agendas with the ANTS hub.'
def handle(self, **options):
models.City.push()

View File

@ -0,0 +1,159 @@
# Generated by Django 3.2.18 on 2023-04-06 00:34
import django.db.models.deletion
from django.db import migrations, models
import chrono.apps.ants_hub.models
class Migration(migrations.Migration):
initial = True
dependencies = [
('agendas', '0152_auto_20230331_0834'),
]
operations = [
migrations.CreateModel(
name='City',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('name', chrono.apps.ants_hub.models.CharField(unique=True, verbose_name='Name')),
(
'url',
chrono.apps.ants_hub.models.URLField(
blank=True,
default=chrono.apps.ants_hub.models.get_portal_url,
verbose_name='Portal URL',
),
),
('logo_url', chrono.apps.ants_hub.models.URLField(blank=True, verbose_name='Logo URL')),
(
'meeting_url',
chrono.apps.ants_hub.models.URLField(
blank=True,
help_text='URL of the web form to make a booking.',
verbose_name='Booking URL',
),
),
(
'management_url',
chrono.apps.ants_hub.models.URLField(
blank=True,
help_text='Generic URL to find and manage an existing booking.',
verbose_name='Booking management URL',
),
),
(
'cancel_url',
chrono.apps.ants_hub.models.URLField(
blank=True,
help_text='Generic URL to find and cancel an existing booking.',
verbose_name='Booking cancellation URL',
),
),
('created', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
('last_update', models.DateTimeField(auto_now=True, verbose_name='Last update')),
('full_sync', models.BooleanField(default=False, verbose_name='Full sync')),
('last_sync', models.DateTimeField(editable=False, null=True, verbose_name='Last sync')),
],
options={
'verbose_name': 'City',
'verbose_name_plural': 'Cities',
},
),
migrations.CreateModel(
name='Place',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('name', chrono.apps.ants_hub.models.CharField(verbose_name='Name')),
('address', chrono.apps.ants_hub.models.CharField(verbose_name='Address')),
('zipcode', chrono.apps.ants_hub.models.CharField(verbose_name='Code postal')),
('city_name', chrono.apps.ants_hub.models.CharField(verbose_name='City name')),
('longitude', models.FloatField(default=2.476, verbose_name='Longitude')),
('latitude', models.FloatField(default=46.596, verbose_name='Latitude')),
('url', chrono.apps.ants_hub.models.URLField(blank=True, verbose_name='Portal URL')),
('logo_url', chrono.apps.ants_hub.models.URLField(blank=True, verbose_name='Logo URL')),
(
'meeting_url',
chrono.apps.ants_hub.models.URLField(
blank=True,
help_text='URL of the web form to make a booking.',
verbose_name='Booking URL',
),
),
(
'management_url',
chrono.apps.ants_hub.models.URLField(
blank=True,
help_text='Generic URL to find and manage an existing booking.',
verbose_name='Booking management URL',
),
),
(
'cancel_url',
chrono.apps.ants_hub.models.URLField(
blank=True,
help_text='Generic URL to find and cancel an existing booking.',
verbose_name='Booking cancellation URL',
),
),
('created', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
('last_update', models.DateTimeField(auto_now=True, verbose_name='Last update')),
('full_sync', models.BooleanField(default=False, verbose_name='Full sync')),
('last_sync', models.DateTimeField(editable=False, null=True, verbose_name='Last sync')),
(
'city',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='places',
to='ants_hub.city',
verbose_name='City',
),
),
],
options={
'verbose_name': 'place',
'verbose_name_plural': 'places',
'unique_together': {('city', 'name')},
},
),
migrations.CreateModel(
name='PlaceAgenda',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('setting', models.JSONField(blank=True, default=dict, verbose_name='Setting')),
(
'agenda',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='+',
related_query_name='ants_place',
to='agendas.agenda',
verbose_name='Agenda',
),
),
(
'place',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='agendas',
to='ants_hub.place',
verbose_name='Place',
),
),
],
options={
'unique_together': {('place', 'agenda')},
},
),
]

View File

@ -0,0 +1,355 @@
# chrono - agendas system
# Copyright (C) 2023 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import collections
import datetime
from django import forms
from django.conf import settings
from django.db import models, transaction
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from chrono.agendas.models import Agenda, Booking
from chrono.utils.timezone import localtime, now
from .hub import push_rendez_vous_disponibles
# fields without max_length problems
class CharField(models.TextField):
'''TextField using forms.TextInput as widget'''
def formfield(self, **kwargs):
defaults = {'widget': forms.TextInput}
defaults.update(**kwargs)
return super().formfield(**defaults)
class URLField(models.URLField):
'''URLField using a TEXT column for storage'''
def get_internal_type(self):
return 'TextField'
class CityManager(models.Manager):
def get_by_natural_key(self, name):
return self.get(name=name)
def get_portal_url():
template_vars = getattr(settings, 'TEMPLATE_VARS', {})
return template_vars.get('portal_url', '')
class City(models.Model):
name = CharField(verbose_name=_('Name'), unique=True)
url = URLField(verbose_name=_('Portal URL'), default=get_portal_url, blank=True)
logo_url = URLField(verbose_name=_('Logo URL'), blank=True)
meeting_url = URLField(
verbose_name=_('Booking URL'), help_text=_('URL of the web form to make a booking.'), blank=True
)
management_url = URLField(
verbose_name=_('Booking management URL'),
help_text=_('Generic URL to find and manage an existing booking.'),
blank=True,
)
cancel_url = URLField(
verbose_name=_('Booking cancellation URL'),
help_text=_('Generic URL to find and cancel an existing booking.'),
blank=True,
)
created = models.DateTimeField(verbose_name=_('Created'), auto_now_add=True)
last_update = models.DateTimeField(verbose_name=_('Last update'), auto_now=True)
full_sync = models.BooleanField(
verbose_name=_('Full synchronization next time'), default=False, editable=False
)
last_sync = models.DateTimeField(verbose_name=_('Last synchronization'), null=True, editable=False)
objects = CityManager()
def __str__(self):
return f'{self.name}'
def natural_key(self):
return (self.name,)
def get_absolute_url(self):
return reverse('chrono-manager-ants-hub')
def details(self):
details = []
for key in ['url', 'logo_url', 'management_url', 'cancel_url', 'full_sync']:
value = getattr(self, key, None)
if value:
verbose_name = type(self)._meta.get_field(key).verbose_name
details.append((verbose_name, value))
return details
@classmethod
@transaction.atomic(savepoint=False)
def push(cls):
reference = now()
# prevent concurrent pushes with locks
cities = list(cls.objects.select_for_update())
push_rendez_vous_disponibles(
{
'collectivites': [city.export_to_push() for city in cities],
}
)
City.objects.update(last_sync=reference)
Place.objects.update(last_sync=reference)
def export_to_push(self):
payload = {
'full': True,
'id': str(self.pk),
'nom': self.name,
'url': self.url,
'logo_url': self.logo_url,
'rdv_url': self.meeting_url,
'gestion_url': self.management_url,
'annulation_url': self.cancel_url,
'lieux': [place.export_to_push() for place in self.places.all()],
}
return payload
class Meta:
verbose_name = _('City')
verbose_name_plural = _('Cities')
class PlaceManager(models.Manager):
def get_by_natural_key(self, name, *args):
return self.get(name=name, city=City.objects.get_by_natural_key(*args))
class Place(models.Model):
city = models.ForeignKey(
verbose_name=_('City'), to=City, related_name='places', on_delete=models.CASCADE, editable=False
)
name = CharField(verbose_name=_('Name'))
address = CharField(verbose_name=_('Address'))
zipcode = CharField(verbose_name=_('Code postal'))
city_name = CharField(verbose_name=_('City name'))
longitude = models.FloatField(verbose_name=_('Longitude'), default=2.476)
latitude = models.FloatField(verbose_name=_('Latitude'), default=46.596)
url = URLField(verbose_name=_('Portal URL'), blank=True)
logo_url = URLField(verbose_name=_('Logo URL'), blank=True)
meeting_url = URLField(
verbose_name=_('Booking URL'), help_text=_('URL of the web form to make a booking.'), blank=True
)
management_url = URLField(
verbose_name=_('Booking management URL'),
help_text=_('Generic URL to find and manage an existing booking.'),
blank=True,
)
cancel_url = URLField(
verbose_name=_('Booking cancellation URL'),
help_text=_('Generic URL to find and cancel an existing booking.'),
blank=True,
)
created = models.DateTimeField(verbose_name=_('Created'), auto_now_add=True)
last_update = models.DateTimeField(verbose_name=_('Last update'), auto_now=True)
full_sync = models.BooleanField(verbose_name=_('Full synchronization'), default=False)
last_sync = models.DateTimeField(verbose_name=_('Last synchronization'), null=True, editable=False)
objects = PlaceManager()
def __str__(self):
return f'{self.name}'
def natural_key(self):
return (self.name,) + self.city.natural_key()
natural_key.dependencies = ['ants_hub.city']
def get_absolute_url(self):
return reverse('chrono-manager-ants-hub-place', kwargs={'city_pk': self.city_id, 'pk': self.pk})
def url_details(self):
details = []
for key in ['url', 'logo_url', 'management_url', 'cancel_url', 'full_sync']:
value = getattr(self, key, None)
if value:
verbose_name = type(self)._meta.get_field(key).verbose_name
details.append((verbose_name, value))
return details
def export_to_push(self):
payload = {
'full': True,
'id': str(self.pk),
'nom': self.name,
'numero_rue': self.address,
'code_postal': self.zipcode,
'ville': self.city_name,
'longitude': self.longitude,
'latitude': self.latitude,
'url': self.url,
'rdv_url': self.meeting_url,
'gestion_url': self.management_url,
'annulation_url': self.cancel_url,
'plages': list(self.iter_open_dates()),
'rdvs': list(self.iter_predemandes()),
}
return payload
def iter_open_dates(self):
for place_agenda in self.agendas.all():
yield from place_agenda.iter_open_dates()
def iter_predemandes(self):
agendas = Agenda.objects.filter(ants_place__place=self)
agendas |= Agenda.objects.filter(virtual_agendas__ants_place__place=self)
agendas = set(agendas)
bookings = (
Booking.objects.filter(
event__desk__agenda__in=agendas,
event__start_datetime__gt=now(),
extra_data__ants_identifiant_predemande__isnull=False,
)
.values_list(
'extra_data__ants_identifiant_predemande', 'event__start_datetime', 'cancellation_datetime'
)
.order_by('event__state_datetime')
)
for identifiant_predemande, start_datetime, cancellation_datetime in bookings:
if not isinstance(identifiant_predemande, str):
continue
rdv = {
'id': identifiant_predemande,
'date': start_datetime.isoformat(),
}
if cancellation_datetime is not None:
rdv['annule'] = True
yield rdv
class Meta:
verbose_name = _('place')
verbose_name_plural = _('places')
unique_together = [
('city', 'name'),
]
class ANTSMeetingType(models.IntegerChoices):
CNI = 1, _('CNI')
PASSPORT = 2, _('Passport')
CNI_PASSPORT = 3, _('CNI and passport')
@property
def ants_name(self):
return super().name.replace('_', '-')
class ANTSPersonsNumber(models.IntegerChoices):
ONE = 1, _('1 person')
TWO = 2, _('2 persons')
THREE = 3, _('3 persons')
FOUR = 4, _('4 persons')
class PlaceAgenda(models.Model):
place = models.ForeignKey(
verbose_name=_('Place'),
to=Place,
on_delete=models.CASCADE,
related_name='agendas',
)
agenda = models.ForeignKey(
verbose_name=_('Agenda'),
to='agendas.Agenda',
on_delete=models.CASCADE,
related_name='+',
related_query_name='ants_place',
)
setting = models.JSONField(verbose_name=_('Setting'), default=dict, blank=True)
def set_meeting_type_setting(self, meeting_type, key, value):
assert key in ['ants_meeting_type', 'ants_persons_number']
meeting_types = self.setting.setdefault('meeting-types', {})
value = map(int, value)
if key == 'ants_meeting_type':
value = list(map(ANTSMeetingType, value))
else:
value = list(map(ANTSPersonsNumber, value))
meeting_types.setdefault(str(meeting_type.slug), {})[key] = value
def get_meeting_type_setting(self, meeting_type, key):
assert key in ['ants_meeting_type', 'ants_persons_number']
meeting_types = self.setting.setdefault('meeting-types', {})
value = meeting_types.get(str(meeting_type.slug), {}).get(key, [])
value = map(int, value)
if key == 'ants_meeting_type':
value = list(map(ANTSMeetingType, value))
else:
value = list(map(ANTSPersonsNumber, value))
return value
def iter_ants_meeting_types_and_persons(self):
d = collections.defaultdict(set)
for meeting_type in self.agenda.iter_meetingtypes():
for ants_meeting_type in self.get_meeting_type_setting(meeting_type, 'ants_meeting_type'):
for ants_persons_number in self.get_meeting_type_setting(meeting_type, 'ants_persons_number'):
meeting_type.id = meeting_type.slug
d[(meeting_type, ants_persons_number)].add(ants_meeting_type)
for (meeting_type, ants_persons_number), ants_meeting_types in d.items():
yield meeting_type, ants_persons_number, ants_meeting_types
@property
def ants_properties(self):
rdv = set()
persons = set()
for meeting_type in self.agenda.meetingtype_set.all():
rdv.update(self.get_meeting_type_setting(meeting_type, 'ants_meeting_type'))
persons.update(self.get_meeting_type_setting(meeting_type, 'ants_persons_number'))
rdv = sorted(list(rdv))
persons = sorted(list(persons))
return [x.label for x in rdv] + [x.label for x in persons]
def __str__(self):
return str(self.agenda)
def get_absolute_url(self):
return self.place.get_absolute_url()
def iter_open_dates(self):
settings = list(self.iter_ants_meeting_types_and_persons())
if not settings:
return
intervals = self.agenda.get_free_time(end_datetime=now() + datetime.timedelta(days=6 * 30))
for start, end in intervals:
for meeting_type, ants_persons_number, ants_meeting_types in settings:
duration = datetime.timedelta(minutes=meeting_type.duration)
if end - start < duration:
continue
yield {
'date': localtime(start).date().isoformat(),
# use naive local time representation
'heure_debut': localtime(start).time().replace(tzinfo=None).isoformat(),
'heure_fin': localtime(end).time().replace(tzinfo=None).isoformat(),
'duree': meeting_type.duration,
'personnes': int(ants_persons_number),
'types_rdv': [x.ants_name for x in ants_meeting_types],
}
class Meta:
unique_together = [
('place', 'agenda'),
]

View File

@ -0,0 +1,57 @@
{% extends "chrono/manager_ants_hub_base.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans 'ANTS Hub' %}</h2>
<span class="actions">
<a class="extra-actions-menu-opener"></a>
<ul class="extra-actions-menu">
<li><a rel="popup" href="{% url 'chrono-manager-ants-hub-synchronize' %}">{% trans 'Synchronize agendas' %}</a></li>
</ul>
<a rel="popup" href="{% url 'chrono-manager-ants-hub-city-add' %}">{% trans 'New city' %}</a>
</span>
{% endblock %}
{% block content %}
{% if object_list %}
{% for object in object_list %}
<div class="section">
<h3>
<span>
{{ object }}
<a class="icon-edit" rel="popup" href="{% url 'chrono-manager-ants-hub-city-edit' pk=object.pk %}"></a>
</span>
<a class="button delete-button" rel="popup" href="{% url 'chrono-manager-ants-hub-city-delete' pk=object.pk %}">{% trans "Remove" %}</a>
</h3>
<div>
<ul class="objects-list single-links">
<p>
{% if not object.last_sync %}{% trans "Never synchronized" %}{% else %}{% blocktrans with date=object.last_sync|date:"SHORT_DATETIME_FORMAT" %}Last synchronization on {{ date }}{% endblocktrans %}{% endif %}.
</p>
{% for label, value in object.details %}
<p>{{ label }}: {{ value }}</p>
{% endfor %}
{% for place in object.places.all %}
<li>
<a href="{% url 'chrono-manager-ants-hub-place' city_pk=object.pk pk=place.pk %}">{{ place }}
{% if place.agendas.count %}<span class="identifier">({% blocktrans count counter=place.agendas.count %}1 agenda{% plural %}{{ counter }} agendas{% endblocktrans %})</span>{% endif %}</a>
<a class="delete" rel="popup" href="{% url 'chrono-manager-ants-hub-place-delete' city_pk=object.pk pk=place.pk %}">{% trans "remove" %}</a>
</li>
{% endfor %}
</ul>
<p>
<a class="button" rel="popup" href="{% url 'chrono-manager-ants-hub-place-add' pk=object.pk %}">{% trans "Add place" %}</a>
</p>
</div>
</div>
{% endfor %}
{% else %}
<div class="big-msg-info">
{% blocktrans trimmed %}
This site doesn't have any city yet. Click on the "New city" button in the top
right of the page to add a first one.
{% endblocktrans %}
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,19 @@
{% extends "chrono/manager_home.html" %}
{% load i18n gadjo %}
{% block appbar %}
<h2>{% if object %}{{ object }}{% else %}{{ view.name }}{% endif %}</h2>
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form|with_template }}
<div class="buttons">
<button class="submit-button">{% trans "Save" %}</button>
<a class="cancel" href="{% url 'chrono-manager-ants-hub' %}">{% trans 'Cancel' %}</a>
</div>
{% block after-form %}
{% endblock %}
</form>
{% endblock %}

View File

@ -0,0 +1,57 @@
{% extends "chrono/manager_home.html" %}
{% load i18n gadjo %}
{% block appbar %}
<h2><a href="{{ object.agenda.get_absolute_url }}">{{ object.agenda }}</a></h2>
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<p>{% blocktrans with url=object.agenda.get_absolute_url name=object.agenda %}Configure the mapping between meeting types and ANTS meeting types for agenda <a href="{{ url }}">{{ name }}</a>.{% endblocktrans %}</p>
{% if form.meeting_types %}
<table class="main ants-setting">
<tbody>
{% for label, fields in form.field_by_labels %}
<tr>
<td class="meeting-type">{{ label }}</td>
<td>
{% for field in fields %}
{% if field.errors %}
<div class="error"><p>
{% for error in field.errors %}
{{ error }}{% if not forloop.last %}<br>{% endif %}
{% endfor %}
</p></div>
{% endif %}
{{ field }}
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="big-msg-info">{% blocktrans trimmed %}
This agenda doesn't have any meeting type yet.
{% endblocktrans %}</div>
{% endif %}
<script>
$('form').on('click', 'label', function (event) {
console.log(event);
});
$('form').on('change', 'input', function (event) {
$(event.target).parent().toggleClass('checked');
});
$('form input:checked').each(function (i, elt) {
$(elt).parent().toggleClass('checked');
});
</script>
<div class="buttons">
{% if form.meeting_types %}
<button class="submit-button">{% trans "Save" %}</button>
{% endif %}
<a class="cancel" href="{% url 'chrono-manager-ants-hub' %}">{% trans 'Cancel' %}</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,7 @@
{% extends "chrono/manager_base.html" %}
{% load i18n %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url "chrono-manager-ants-hub" %}">{% trans "ANTS Hub" %}</a>
{% endblock %}

View File

@ -0,0 +1,78 @@
{% extends "chrono/manager_ants_hub_base.html" %}
{% load i18n %}
{% block breadcrumb %}
{{ block.super }}
<a href=".">{{ view.place }}</a>
{% endblock %}
{% block appbar %}
<h2>{{ view.place }}</h2>
<a rel="popup" class="delete-button" href="{% url 'chrono-manager-ants-hub-place-delete' city_pk=view.place.city_id pk=view.place.pk %}">{% trans "Remove" %}</a>
{% endblock %}
{% block content %}
<p>
{% if not view.place.last_sync %}{% trans "Never synchronized" %}{% else %}{% blocktrans with date=view.place.last_sync|date:"SHORT_DATETIME_FORMAT" %}Last synchronization on {{ date }}{% endblocktrans %}{% endif %}.
</p>
<div class="section">
<h3>
{% trans "Address" %}
<a class="button" rel="popup" href="{% url 'chrono-manager-ants-hub-place-edit' city_pk=view.place.city_id pk=view.place.pk %}">{% trans "Edit" %}</a>
</h3>
<div>
<p>
{{ view.place.address }}</br>
{{ view.place.zipcode }} {{ view.place.city_name }}
</p>
<p>
{% trans "Geolocation:" %} {{ view.place.longitude }} {{ view.place.latitude }}
</p>
</div>
</div>
<div class="section">
<h3>
{% trans "URLs" %}
<a class="button" rel="popup" href="{% url 'chrono-manager-ants-hub-place-url' city_pk=view.place.city_id pk=view.place.pk %}">{% trans "Edit" %}</a>
</h3>
<table class="main">
<tbody>
{% for label, value in view.place.url_details %}
<tr><td>{{ label }}</td><td>{{ value }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="section">
<h3>
{% trans "Agendas" %}
<a rel="popup" class="button" href="{% url 'chrono-manager-ants-hub-agenda-add' city_pk=view.place.city_id pk=view.place.pk %}">{% trans 'Add' %}</a>
</h3>
{% if object_list %}
<ul class="objects-list single-links">
{% for object in object_list %}
<li {% if not object.ants_properties %}class="ants-setting-not-configured"{% endif %}>
<a rel="popup" id="open-place-agenda-{{ object.pk }}" class="edit" href="{% url 'chrono-manager-ants-hub-agenda-edit' pk=object.pk city_pk=view.place.city_id place_pk=view.place.pk %}">
<span class="label">{{ object }}</span>
<span class="properties">({% if object.ants_properties %}{{ object.ants_properties|join:", " }}{% else %}{% trans "not configured" %}{% endif %})</span></a>
<a class="delete" rel="popup" href="{% url 'chrono-manager-ants-hub-agenda-delete' pk=object.pk city_pk=view.place.city_id place_pk=view.place.pk %}">{% trans "remove" %}</a>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% if not object_list %}
<div class="big-msg-info">
{% blocktrans trimmed %}
This site doesn't have any agenda yet. Click on the "New place" button in the top
right of the page to add a first one.
{% endblocktrans %}
</div>
{% endif %}
<script>
setTimeout(function () {
$(window.location.hash).click();
}, 100);
</script>
{% endblock %}

View File

@ -0,0 +1,17 @@
{% extends "chrono/manager_ants_hub_add_form.html" %}
{% load i18n gadjo %}
{% block after-form %}
<script>
$('form').on('change keyup', '#id_address, #id_zipcode, #id_city_name', function (event) {
var q = $('#id_address').val() + ' ' + $('#id_zipcode').val() + ' ' + $('#id_city_name').val();
console.log('q', q)
var url = "https://api-adresse.data.gouv.fr/search/?q=" + encodeURIComponent(q);
$.ajax(url).done(function (data) {
var coords = data.features[0].geometry.coordinates;
$('#id_longitude').val(coords[0]);
$('#id_latitude').val(coords[1]);
})
});
</script>
{% endblock %}

View File

@ -0,0 +1,19 @@
{% extends "chrono/manager_home.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans "Synchronize agendas" %}</h2>
{% endblock %}
{% block content %}
<form method="post">
{% csrf_token %}
<p>
{% blocktrans %}Are you sure you want to synchronize your agendas with the ANTS now?{% endblocktrans %}
</p>
<div class="buttons">
<button class="button" >{% trans 'Synchronize' %}</button>
<a class="cancel" href="{% url 'chrono-manager-ants-hub' %}">{% trans 'Cancel' %}</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,87 @@
# chrono - agendas system
# Copyright (C) 2023 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import functools
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.shortcuts import redirect
from django.urls import path
from django.utils.translation import gettext as _
from . import views
def view_decorator(func):
@functools.wraps(func)
def wrapper(request, *args, **kwargs):
if not request.user.is_staff:
raise PermissionDenied
if not settings.CHRONO_ANTS_HUB_URL:
messages.info(
request, _('Configure CHRONO_ANTS_HUB_URL to get access to ANTS-Hub configuration panel.')
)
return redirect('chrono-manager-homepage')
return func(request, *args, **kwargs)
return wrapper
urlpatterns = [
path('', views.Homepage.as_view(), name='chrono-manager-ants-hub'),
path('synchronize/', views.Synchronize.as_view(), name='chrono-manager-ants-hub-synchronize'),
path('city/add/', views.CityAddView.as_view(), name='chrono-manager-ants-hub-city-add'),
path('city/<int:pk>/edit/', views.CityEditView.as_view(), name='chrono-manager-ants-hub-city-edit'),
path('city/<int:pk>/delete/', views.CityDeleteView.as_view(), name='chrono-manager-ants-hub-city-delete'),
path('city/<int:pk>/place/add/', views.PlaceAddView.as_view(), name='chrono-manager-ants-hub-place-add'),
path(
'city/<int:city_pk>/place/<int:pk>/', views.PlaceView.as_view(), name='chrono-manager-ants-hub-place'
),
path(
'city/<int:city_pk>/place/<int:pk>/edit/',
views.PlaceEditView.as_view(),
name='chrono-manager-ants-hub-place-edit',
),
path(
'city/<int:city_pk>/place/<int:pk>/url/',
views.PlaceUrlEditView.as_view(),
name='chrono-manager-ants-hub-place-url',
),
path(
'city/<int:city_pk>/place/<int:pk>/delete/',
views.PlaceDeleteView.as_view(),
name='chrono-manager-ants-hub-place-delete',
),
path(
'city/<int:city_pk>/place/<int:pk>/agenda/add/',
views.PlaceAgendaAddView.as_view(),
name='chrono-manager-ants-hub-agenda-add',
),
path(
'city/<int:city_pk>/place/<int:place_pk>/agenda/<int:pk>/edit/',
views.PlaceAgendaEditView.as_view(),
name='chrono-manager-ants-hub-agenda-edit',
),
path(
'city/<int:city_pk>/place/<int:place_pk>/agenda/<int:pk>/delete/',
views.PlaceAgendaDeleteView.as_view(),
name='chrono-manager-ants-hub-agenda-delete',
),
]
for pattern in urlpatterns:
pattern.callback = view_decorator(pattern.callback)

View File

@ -0,0 +1,241 @@
# chrono - agendas system
# Copyright (C) 2023 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django import forms
from django.contrib import messages
from django.core.cache import cache
from django.shortcuts import get_object_or_404, redirect
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DeleteView, ListView, TemplateView, UpdateView
from . import hub, models
class Homepage(ListView):
template_name = 'chrono/manager_ants_hub.html'
model = models.City
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ok = cache.get('ants-hub-ok')
if not ok:
try:
hub.ping()
except hub.AntsHubException as e:
messages.warning(self.request, _('ANTS Hub is down: "%s".') % e)
else:
messages.info(self.request, _('ANTS Hub is responding.'))
cache.set('ants-hub-ok', True, 600)
return ctx
class CityAddView(CreateView):
template_name = 'chrono/manager_ants_hub_add_form.html'
model = models.City
name = _('New city')
fields = '__all__'
class CityMixin:
def dispatch(self, request, pk):
self.city = get_object_or_404(models.City, pk=pk)
return super().dispatch(request, pk=pk)
class CityView(CityMixin, ListView):
template_name = 'chrono/manager_ants_hub_city.html'
model = models.Place
def get_queryset(self):
return super().get_queryset().filter(city=self.city)
class CityEditView(UpdateView):
template_name = 'chrono/manager_ants_hub_add_form.html'
model = models.City
fields = '__all__'
class CityDeleteView(DeleteView):
template_name = 'chrono/manager_confirm_delete.html'
model = models.City
success_url = '../../../'
class PlaceForm(forms.ModelForm):
class Meta:
model = models.Place
exclude = ['city']
class PlaceAddView(CityMixin, CreateView):
template_name = 'chrono/manager_ants_hub_add_form.html'
model = models.Place
form_class = PlaceForm
@property
def name(self):
return _('New place in %s') % self.city
def dispatch(self, request, pk):
self.city = get_object_or_404(models.City, pk=pk)
return super().dispatch(request, pk)
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['instance'] = models.Place(city=self.city)
return kwargs
class PlaceMixin:
def dispatch(self, request, city_pk, pk):
self.place = get_object_or_404(models.Place, pk=pk, city_id=city_pk)
self.city = self.place.city
return super().dispatch(request, pk=pk)
class PlaceView(PlaceMixin, ListView):
template_name = 'chrono/manager_ants_hub_place.html'
model = models.PlaceAgenda
def get_queryset(self):
return super().get_queryset().filter(place=self.place)
class PlaceEditForm(PlaceForm):
class Meta:
model = models.Place
fields = ['name', 'address', 'zipcode', 'city_name', 'longitude', 'latitude']
class PlaceEditView(UpdateView):
template_name = 'chrono/manager_ants_hub_place_edit_form.html'
model = models.Place
fields = ['zipcode', 'city_name', 'address', 'longitude', 'latitude']
class PlaceDeleteView(DeleteView):
template_name = 'chrono/manager_confirm_delete.html'
model = models.Place
success_url = '../../../../../'
class PlaceUrlEditView(UpdateView):
template_name = 'chrono/manager_ants_hub_add_form.html'
model = models.Place
fields = ['url', 'logo_url', 'meeting_url', 'management_url', 'cancel_url']
class PlaceAgendaAddForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# hide agendas already linked to the place
self.fields['agenda'].queryset = self.fields['agenda'].queryset.exclude(
ants_place__place=self.instance.place
)
class Meta:
model = models.PlaceAgenda
exclude = ['place', 'setting']
class PlaceAgendaAddView(PlaceMixin, CreateView):
template_name = 'chrono/manager_ants_hub_add_form.html'
model = models.PlaceAgenda
form_class = PlaceAgendaAddForm
@property
def name(self):
return _('New agenda for %s') % self.place
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['instance'] = models.PlaceAgenda(place=self.place)
return kwargs
def get_success_url(self):
return f'../../#open-place-agenda-{self.object.pk}'
class PlaceAgendaEditForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.meeting_types = self.instance.agenda.meetingtype_set.order_by('label')
for meeting_type in self.instance.agenda.iter_meetingtypes():
field_meeting_type = forms.TypedMultipleChoiceField(
label=_('%s') % meeting_type.label,
choices=models.ANTSMeetingType.choices,
widget=forms.CheckboxSelectMultiple(attrs={'class': 'inline'}),
required=False,
initial=self.instance.get_meeting_type_setting(meeting_type, 'ants_meeting_type'),
)
field_meeting_type.meeting_type = meeting_type
field_meeting_type.key = 'ants_meeting_type'
field_persons_number = forms.TypedMultipleChoiceField(
label=_('%s') % meeting_type.label,
choices=models.ANTSPersonsNumber.choices,
widget=forms.CheckboxSelectMultiple(attrs={'class': 'inline'}),
required=False,
initial=self.instance.get_meeting_type_setting(meeting_type, 'ants_persons_number'),
)
field_persons_number.meeting_type = meeting_type
field_persons_number.key = 'ants_persons_number'
self.fields[f'mt_{meeting_type.slug}_1'] = field_meeting_type
self.fields[f'mt_{meeting_type.slug}_2'] = field_persons_number
def field_by_labels(self):
d = {}
for bound_field in self:
d.setdefault(bound_field.label, []).append(bound_field)
return list(d.items())
def clean(self):
for key, field in self.fields.items():
value = self.cleaned_data.get(key, [])
self.instance.set_meeting_type_setting(field.meeting_type, field.key, value)
return self.cleaned_data
class Meta:
model = models.PlaceAgenda
fields = []
class PlaceAgendaEditView(UpdateView):
template_name = 'chrono/manager_ants_hub_agenda_edit_form.html'
model = models.PlaceAgenda
form_class = PlaceAgendaEditForm
success_url = '../../../'
class PlaceAgendaDeleteView(DeleteView):
template_name = 'chrono/manager_confirm_delete.html'
model = models.PlaceAgenda
success_url = '../../../'
class Synchronize(TemplateView):
template_name = 'chrono/manager_ants_hub_synchronize.html'
def post(self, request):
self.synchronize()
messages.info(request, _('Synchronization has been launched.'))
return redirect('chrono-manager-ants-hub')
@classmethod
def synchronize(cls):
from chrono.utils.spooler import ants_hub_city_push
ants_hub_city_push.spool()

View File

@ -604,3 +604,46 @@ table.partial-bookings {
outline: solid 1px;
}
}
/* ants-hub */
ul.objects-list.single-links li.ants-setting-not-configured a.edit {
color: red;
}
.ants-setting {
.meeting-type {
vertical-align: top;
text-align: center;
}
ul.inline {
display: flex;
width: 40em;
margin: 1ex;
margin-block-start: 0em;
padding-inline-start: 0em;
}
ul.inline li {
flex: 1;
text-align: center;
border: 1px solid grey;
border-width: 1px 0px 1px 1px;
list-style: none;
}
ul.inline li:first-child {
border-radius: 5px 0px 0px 5px;
}
li label {
width: 100%;
display: inline-block;
}
ul.inline li:last-child {
border-radius: 0px 5px 5px 0px;
border-width: 1px 1px 1px 1px;
}
ul.inline input {
display: none;
}
label.checked {
background: lightblue;
}
}

View File

@ -2,7 +2,7 @@
{% load i18n %}
{% block appbar %}
<h2>{{ view.model.get_verbose_name }}</h2>
<h2>{% firstof object view.model.get_verbose_name %}</h2>
{% endblock %}
{% block content %}

View File

@ -21,6 +21,9 @@
{% if user.is_staff %}
<li><a href="{% url 'chrono-manager-resource-list' %}">{% trans 'Resources' %}</a></li>
{% endif %}
{% if ants_hub_enabled and user.is_staff %}
<li><a href="{% url 'chrono-manager-ants-hub' %}">{% trans 'ANTS Hub' %}</a></li>
{% endif %}
</ul>
{% endif %}
{% if user.is_staff %}

View File

@ -14,7 +14,7 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.urls import path, re_path
from django.urls import include, path, re_path
from . import views
@ -510,4 +510,5 @@ urlpatterns = [
name='chrono-manager-shared-custody-agenda-delete-period',
),
re_path(r'^menu.json$', views.menu_json),
path('ants/', include('chrono.apps.ants_hub.urls')),
]

View File

@ -174,6 +174,7 @@ class HomepageView(ListView):
context = super().get_context_data(**kwargs)
context['has_access_to_unavailability_calendars'] = self.has_access_to_unavailability_calendars()
context['shared_custody_enabled'] = settings.SHARED_CUSTODY_ENABLED
context['ants_hub_enabled'] = bool(settings.CHRONO_ANTS_HUB_URL)
return context
def get(self, request, *args, **kwargs):

View File

@ -57,6 +57,7 @@ INSTALLED_APPS = (
'chrono.agendas',
'chrono.api',
'chrono.manager',
'chrono.apps.ants_hub',
'rest_framework',
'django_filters',
)
@ -201,6 +202,8 @@ SHARED_CUSTODY_ENABLED = False
LEGACY_FILLSLOTS_ENABLED = False
PARTIAL_BOOKINGS_ENABLED = False
CHRONO_ANTS_HUB_URL = None
local_settings_file = os.environ.get(
'CHRONO_SETTINGS_FILE', os.path.join(os.path.dirname(__file__), 'local_settings.py')
)

View File

@ -69,3 +69,17 @@ def event_notify_checked(args):
return
event.notify_checked()
@spool
def ants_hub_city_push(args):
if args.get('domain'):
# multitenant installation
set_connection(args['domain'])
from chrono.apps.ants_hub.models import City
try:
City.push()
except Exception: # noqa pylint: disable=broad-except
pass

2
debian/uwsgi.ini vendored
View File

@ -21,6 +21,8 @@ spooler-max-tasks = 20
cron2 = minute=-5,unique=1 /usr/bin/chrono-manage tenant_command cancel_events --all-tenants -v0
cron2 = minute=-5,unique=1 /usr/bin/chrono-manage tenant_command send_email_notifications --all-tenants -v0
cron2 = minute=-5,unique=1 /usr/bin/chrono-manage tenant_command update_event_recurrences --all-tenants -v0
# every fifteen minutes
cron2 = minute=-15,unique=1 /usr/bin/chrono-manage tenant_command sync-ants-hub --all-tenants
# hourly
cron2 = minute=3,unique=1 /usr/bin/chrono-manage tenant_command clearsessions --all-tenants
cron2 = minute=13,unique=1 /usr/bin/chrono-manage tenant_command send_booking_reminders --all-tenants

View File

@ -0,0 +1,86 @@
# chrono - agendas system
# Copyright (C) 2023 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import pytest
import responses
from django.core.cache import cache
from chrono.apps.ants_hub.models import City, Place, PlaceAgenda
from tests.manager.conftest import admin_user, simple_user # noqa pylint: disabled=unused-import
from tests.utils import build_agendas
@pytest.fixture
def ants_settings(settings):
settings.CHRONO_ANTS_HUB_URL = 'https://toto:@ants-hub.example.com/api/chrono/'
@pytest.fixture
def hub(ants_settings):
cache.delete('ants-hub-ok')
with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
rsps.add(responses.GET, 'https://toto:@ants-hub.example.com/api/chrono/ping/', json={'err': 0})
yield rsps
@pytest.fixture
def city():
return City.objects.create(
id=1, name='Newcity', logo_url='https://newcity.com/logo.png', url='https://newcity.com/rdv/'
)
@pytest.fixture
def place(city):
return Place.objects.create(
id=1,
city=city,
name='Townhall',
address='221B Baker Street',
zipcode='13260',
city_name='Newcity',
longitude='2.3',
latitude='-40.3',
)
@pytest.fixture
def agenda():
return build_agendas(
'''
meetings CNI
desk "Desk1"
timeperiod monday-friday 08:00-17:00
meeting-type 15
'''
)
@pytest.fixture
def place_agenda(place, agenda):
return PlaceAgenda.objects.create(
id=1,
place=place,
agenda=agenda,
setting={
'meeting-types': {
'mt-15': {
'ants_meeting_type': [1],
'ants_persons_number': [1],
}
}
},
)

View File

@ -0,0 +1,70 @@
# chrono - agendas system
# Copyright (C) 2023 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import json
import responses
from django.core.management import call_command
def test_sync_ants_hub(db, hub, place_agenda, freezer):
freezer.move_to('2023-06-01T17:12:00+02:00')
response = hub.add(
responses.POST,
'https://toto:@ants-hub.example.com/api/chrono/rendez-vous-disponibles/',
json={'err': 0},
)
call_command('sync-ants-hub')
assert response.call_count == 1
payload = json.loads(hub.calls[-1].request.body)
assert len(payload['collectivites']) == 1
assert {**payload['collectivites'][0], 'lieux': None} == {
'annulation_url': '',
'full': True,
'gestion_url': '',
'id': '1',
'lieux': None,
'logo_url': 'https://newcity.com/logo.png',
'nom': 'Newcity',
'rdv_url': '',
'url': 'https://newcity.com/rdv/',
}
assert len(payload['collectivites'][0]['lieux']) == 1
assert {**payload['collectivites'][0]['lieux'][0], 'plages': None} == {
'annulation_url': '',
'code_postal': '13260',
'full': True,
'gestion_url': '',
'id': '1',
'latitude': -40.3,
'longitude': 2.3,
'nom': 'Townhall',
'numero_rue': '221B Baker Street',
'plages': None,
'rdv_url': '',
'rdvs': [],
'url': '',
'ville': 'Newcity',
}
assert len(payload['collectivites'][0]['lieux'][0]['plages']) == 39
assert payload['collectivites'][0]['lieux'][0]['plages'][0] == {
'date': '2023-06-02',
'duree': 15,
'heure_debut': '08:00:00',
'heure_fin': '17:00:00',
'personnes': 1,
'types_rdv': ['CNI'],
}

View File

@ -0,0 +1,69 @@
# chrono - agendas system
# Copyright (C) 2023 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import pytest
import requests
import responses
from chrono.apps.ants_hub.hub import AntsHubException, ping, push_rendez_vous_disponibles
def test_ping_timeout(hub):
hub.replace(
responses.GET, 'https://toto:@ants-hub.example.com/api/chrono/ping/', body=requests.Timeout('boom!')
)
ping()
def test_push_rendez_vous_disponibles_timeout(hub):
hub.add(
responses.POST,
'https://toto:@ants-hub.example.com/api/chrono/rendez-vous-disponibles/',
body=requests.Timeout('boom!'),
)
push_rendez_vous_disponibles({})
def test_ping_internal_server_error(hub):
hub.replace(responses.GET, 'https://toto:@ants-hub.example.com/api/chrono/ping/', status=500)
with pytest.raises(AntsHubException):
ping()
def test_push_rendez_vous_disponibles_internal_server_error(hub):
hub.add(
responses.POST, 'https://toto:@ants-hub.example.com/api/chrono/rendez-vous-disponibles/', status=500
)
with pytest.raises(AntsHubException):
push_rendez_vous_disponibles({})
def test_ping_application_error(hub):
hub.replace(
responses.GET, 'https://toto:@ants-hub.example.com/api/chrono/ping/', json={'err': 'overload'}
)
with pytest.raises(AntsHubException, match='overload'):
ping()
def test_push_rendez_vous_disponibles_application_error(hub):
hub.add(
responses.POST,
'https://toto:@ants-hub.example.com/api/chrono/rendez-vous-disponibles/',
json={'err': 'overload'},
)
with pytest.raises(AntsHubException, match='overload'):
push_rendez_vous_disponibles({})

View File

@ -0,0 +1,192 @@
# chrono - agendas system
# Copyright (C) 2023 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from unittest import mock
import pytest
import responses
from chrono.apps.ants_hub.models import ANTSMeetingType, ANTSPersonsNumber, City, PlaceAgenda
from tests.utils import login
pytestmark = pytest.mark.django_db
def test_unconfigured(app, admin_user):
login(app)
resp = app.get('/manage/', status=200)
assert 'ANTS' not in resp
def test_unlogged(ants_settings, app):
app.get('/manage/ants/', status=302)
def test_configured(hub, app, admin_user):
login(app)
resp = app.get('/manage/', status=200)
resp = resp.click('ANTS')
assert 'ANTS Hub is responding' in resp
assert 'New city' in resp
def test_hub_is_down(hub, app, admin_user):
hub.replace(responses.GET, 'https://toto:@ants-hub.example.com/api/chrono/ping/', status=500)
login(app)
resp = app.get('/manage/', status=200)
resp = resp.click('ANTS')
assert 'ANTS Hub is down' in resp
def test_city_add(hub, app, admin_user):
login(app)
resp = app.get('/manage/ants/', status=200)
assert 'Newcity' not in resp
resp = resp.click('New city')
resp.form.set('name', 'Newcity')
resp.form.set('url', 'https://newcity.com/rdv/')
resp.form.set('logo_url', 'https://newcity.com/logo.png')
resp = resp.form.submit().follow()
assert 'Newcity' in resp
def test_city_edit(hub, app, admin_user, city):
login(app)
resp = app.get('/manage/ants/')
resp = resp.click(href='edit')
assert 'logo.png' in resp
def test_city_delete(hub, app, admin_user, city):
login(app)
resp = app.get('/manage/ants/')
resp = resp.click(href='delete')
resp = resp.form.submit(status=302)
assert City.objects.count() == 0
def test_add_place(hub, app, admin_user, city):
login(app)
resp = app.get('/manage/ants/')
resp = resp.click('Add place')
resp.form.set('name', 'Townhall')
resp.form.set('address', '221B Baker Street')
resp.form.set('zipcode', '13260')
resp.form.set('city_name', 'Newcity')
resp.form.set('longitude', '2.3')
resp.form.set('latitude', '-40.3')
assert city.places.count() == 0
resp = resp.form.submit().follow()
assert city.places.count() == 1
assert 'Newcity' in resp
assert 'Townhall' in resp
def test_edit_place(hub, app, admin_user, city, place):
login(app)
resp = app.get('/manage/ants/')
resp = resp.click('Townhall')
resp = resp.click('Edit', href='edit')
assert 'Baker Street' in resp
resp.form.set('address', 'Downing Street')
resp = resp.form.submit().follow()
assert 'Downing Street' in resp
place.refresh_from_db()
assert place.address == 'Downing Street'
def test_url_edit_place(hub, app, admin_user, city, place):
login(app)
resp = app.get('/manage/ants/')
resp = resp.click('Townhall')
assert 'https://townhall.example.com/rdv/' not in resp
resp = resp.click('Edit', href='url')
resp.form.set('url', 'https://townhall.example.com/rdv/')
resp = resp.form.submit().follow()
assert 'https://townhall.example.com/rdv/' in resp
place.refresh_from_db()
assert place.url == 'https://townhall.example.com/rdv/'
def test_delete_place(hub, app, admin_user, city, place):
login(app)
resp = app.get('/manage/ants/')
resp = resp.click('Townhall')
resp = resp.click('Remove')
assert city.places.count() == 1
resp = resp.form.submit().follow()
assert city.places.count() == 0
def test_add_agenda(hub, app, admin_user, city, place, agenda, freezer):
freezer.move_to('2023-06-01T17:12:00+02:00')
login(app)
resp = app.get('/manage/ants/')
resp = resp.click('Townhall')
resp = resp.click('Add')
resp.form.set('agenda', str(agenda.pk))
assert not place.agendas.exists()
resp = resp.form.submit().follow()
place_agenda = place.agendas.get()
assert list(place_agenda.iter_open_dates()) == []
assert not place_agenda.setting.get('meeting-types')
assert '(not configured)' in resp
assert '1 person' not in resp
resp = resp.click(href='agenda.*edit')
# make the meeting-type of 15 minutes correpond to a meeting to get a CNI
# for 1 person
resp.form.set('mt_mt-15_1', [str(ANTSMeetingType.CNI)])
resp.form.set('mt_mt-15_2', [str(ANTSPersonsNumber.ONE)])
resp = resp.form.submit().follow()
place_agenda.refresh_from_db()
assert place_agenda.setting.get('meeting-types')
assert '(not configured)' not in resp
assert '(CNI, 1 person)' in resp
assert place_agenda.setting == {
'meeting-types': {'mt-15': {'ants_meeting_type': [1], 'ants_persons_number': [1]}}
}
open_dates = list(place_agenda.iter_open_dates())
assert len(open_dates) == 39
assert open_dates[0] == {
'date': '2023-06-02',
'heure_debut': '08:00:00',
'heure_fin': '17:00:00',
'duree': 15,
'personnes': 1,
'types_rdv': ['CNI'],
}
def test_delete_agenda(hub, app, admin_user, city, place, agenda, place_agenda):
login(app)
resp = app.get('/manage/ants/')
resp = resp.click('Townhall')
resp = resp.click(href='agenda.*delete')
resp = resp.form.submit().follow()
assert 'CNI' not in resp
assert PlaceAgenda.objects.count() == 0
def test_synchronize(hub, app, admin_user, city, place, agenda, place_agenda, freezer):
freezer.move_to('2023-06-01T17:12:00+02:00')
login(app)
resp = app.get('/manage/', status=200)
resp = resp.click('ANTS')
resp = resp.click('Synchronize')
with mock.patch('chrono.apps.ants_hub.views.Synchronize.synchronize') as method:
resp = resp.form.submit().follow()
assert method.called

View File

@ -0,0 +1,616 @@
# chrono - agendas system
# Copyright (C) 2023 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import pytest
from chrono.apps.ants_hub.models import City, Place, PlaceAgenda
from tests.utils import add_meeting, build_meetings_agenda, build_virtual_agenda, paris
@pytest.fixture(autouse=True)
def ants_setup(db, freezer):
freezer.move_to(paris('2023-04-07 17:32'))
class Namespace:
durations = [15, 30, 45, 60]
mairie_agenda = build_meetings_agenda(
label='cni_passport_mairie',
meeting_types=durations,
desks=('desk1', 'monday-friday 9:00-12:00 14:00-17:00'),
maximal_booking_delay=10,
)
annexe_agenda = build_virtual_agenda(
label='annexe',
agendas={
'Agenda 1': {
'desks': ('Bureau 1', 'monday-wednesday 08:00-12:00'),
'meeting_types': durations,
},
'Agenda 2': {
'desks': ('Bureau 2', 'thursday-friday 14:00-17:00'),
'meeting_types': durations,
},
},
maximal_booking_delay=5,
)
city = City.objects.create(
id=1,
name='Saint-Didier',
url='https://saint-didier.fr/',
)
mairie = Place.objects.create(
id=1,
city=city,
name='Mairie',
address='2 rue du four',
zipcode='99999',
city_name='Saint-Didier',
)
annexe = Place.objects.create(
id=2,
city=city,
name='Mairie annexe',
address='3 rue du four',
zipcode='99999',
city_name='Saint-Didier',
)
PlaceAgenda.objects.create(
place=mairie,
agenda=mairie_agenda,
setting={
'meeting-types': {
'mt-15': {
'ants_meeting_type': [1, 2],
'ants_persons_number': [1],
},
'mt-30': {
'ants_meeting_type': [1, 2],
'ants_persons_number': [2],
},
'mt-45': {
'ants_meeting_type': [1, 2],
'ants_persons_number': [3],
},
'mt-60': {
'ants_meeting_type': [1, 2],
'ants_persons_number': [4],
},
}
},
)
PlaceAgenda.objects.create(
place=annexe,
agenda=annexe_agenda,
setting={
'meeting-types': {
'mt-15': {
'ants_meeting_type': [1, 2],
'ants_persons_number': [1],
},
'mt-30': {
'ants_meeting_type': [1, 2],
'ants_persons_number': [2],
},
'mt-45': {
'ants_meeting_type': [1, 2],
'ants_persons_number': [3],
},
'mt-60': {
'ants_meeting_type': [1, 2],
'ants_persons_number': [4],
},
}
},
)
add_meeting(
mairie_agenda,
paris('2023-04-10 09:00'),
cancellation_datetime=paris('2023-04-08 13:15'),
meeting_type='mt-15',
extra_data={'ants_identifiant_predemande': '12345678'},
)
add_meeting(
mairie_agenda,
paris('2023-04-11 11:00'),
meeting_type='mt-30',
extra_data={'ants_identifiant_predemande': 'ABCDEFGH'},
)
add_meeting(
annexe_agenda._agenda_1,
paris('2023-04-10 10:00'),
cancellation_datetime=paris('2023-04-08 13:15'),
meeting_type='mt-45',
extra_data={'ants_identifiant_predemande': '1234ABCD'},
)
add_meeting(
annexe_agenda._agenda_1,
paris('2023-04-10 11:00'),
meeting_type='mt-45',
extra_data={'ants_identifiant_predemande': 'XYZ12JKL'},
)
add_meeting(
annexe_agenda._agenda_2,
paris('2023-04-11 11:00'),
meeting_type='mt-60',
extra_data={'ants_identifiant_predemande': 'ABCD1234'},
)
return Namespace
def test_export_to_push(ants_setup):
assert ants_setup.city.export_to_push() == {
'full': True,
'id': '1',
'nom': 'Saint-Didier',
'url': 'https://saint-didier.fr/',
'logo_url': '',
'rdv_url': '',
'annulation_url': '',
'gestion_url': '',
'lieux': [
{
'full': True,
'id': '1',
'nom': 'Mairie',
'numero_rue': '2 rue du four',
'code_postal': '99999',
'ville': 'Saint-Didier',
'latitude': 46.596,
'longitude': 2.476,
'url': '',
'rdv_url': '',
'gestion_url': '',
'annulation_url': '',
'rdvs': [
{'annule': True, 'date': '2023-04-10T07:00:00+00:00', 'id': '12345678'},
{'date': '2023-04-11T09:00:00+00:00', 'id': 'ABCDEFGH'},
],
'plages': [
{
'date': '2023-04-10',
'duree': 15,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-10',
'duree': 30,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-10',
'duree': 45,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 3,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-10',
'duree': 60,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 4,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-10',
'duree': 15,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-10',
'duree': 30,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-10',
'duree': 45,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 3,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-10',
'duree': 60,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 4,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 15,
'heure_debut': '09:00:00',
'heure_fin': '11:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 30,
'heure_debut': '09:00:00',
'heure_fin': '11:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 45,
'heure_debut': '09:00:00',
'heure_fin': '11:00:00',
'personnes': 3,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 60,
'heure_debut': '09:00:00',
'heure_fin': '11:00:00',
'personnes': 4,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 15,
'heure_debut': '11:30:00',
'heure_fin': '12:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 30,
'heure_debut': '11:30:00',
'heure_fin': '12:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 15,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 30,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 45,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 3,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 60,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 4,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-12',
'duree': 15,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-12',
'duree': 30,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-12',
'duree': 45,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 3,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-12',
'duree': 60,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 4,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-12',
'duree': 15,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-12',
'duree': 30,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-12',
'duree': 45,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 3,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-12',
'duree': 60,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 4,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-13',
'duree': 15,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-13',
'duree': 30,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-13',
'duree': 45,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 3,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-13',
'duree': 60,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 4,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-13',
'duree': 15,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-13',
'duree': 30,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-13',
'duree': 45,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 3,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-13',
'duree': 60,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 4,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-14',
'duree': 15,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-14',
'duree': 30,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-14',
'duree': 45,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 3,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-14',
'duree': 60,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 4,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-14',
'duree': 15,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-14',
'duree': 30,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-14',
'duree': 45,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 3,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-14',
'duree': 60,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 4,
'types_rdv': ['CNI', 'PASSPORT'],
},
],
},
{
'full': True,
'id': '2',
'nom': 'Mairie annexe',
'numero_rue': '3 rue du four',
'code_postal': '99999',
'ville': 'Saint-Didier',
'latitude': 46.596,
'longitude': 2.476,
'url': '',
'rdv_url': '',
'gestion_url': '',
'annulation_url': '',
'rdvs': [
{'annule': True, 'date': '2023-04-10T08:00:00+00:00', 'id': '1234ABCD'},
{'date': '2023-04-10T09:00:00+00:00', 'id': 'XYZ12JKL'},
{'date': '2023-04-11T09:00:00+00:00', 'id': 'ABCD1234'},
],
'plages': [
{
'date': '2023-04-10',
'duree': 15,
'heure_debut': '08:00:00',
'heure_fin': '11:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-10',
'duree': 30,
'heure_debut': '08:00:00',
'heure_fin': '11:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-10',
'duree': 45,
'heure_debut': '08:00:00',
'heure_fin': '11:00:00',
'personnes': 3,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-10',
'duree': 60,
'heure_debut': '08:00:00',
'heure_fin': '11:00:00',
'personnes': 4,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-10',
'duree': 15,
'heure_debut': '11:45:00',
'heure_fin': '12:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 15,
'heure_debut': '08:00:00',
'heure_fin': '12:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 30,
'heure_debut': '08:00:00',
'heure_fin': '12:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 45,
'heure_debut': '08:00:00',
'heure_fin': '12:00:00',
'personnes': 3,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 60,
'heure_debut': '08:00:00',
'heure_fin': '12:00:00',
'personnes': 4,
'types_rdv': ['CNI', 'PASSPORT'],
},
],
},
],
}

View File

@ -321,7 +321,7 @@ def add_exception_source(target: typing.Union[Desk, Agenda], ics_filename: str,
def add_meeting(
target: typing.Union[Desk, Agenda],
start_datetime: datetime.datetime,
meeting_type: MeetingType = None,
meeting_type: typing.Union[str, MeetingType] = None,
**kwargs,
):
if isinstance(target, Desk):
@ -332,6 +332,8 @@ def add_meeting(
if meeting_type is None:
meeting_type = desk.agenda.meetingtype_set.get()
elif isinstance(meeting_type, str):
meeting_type = desk.agenda.meetingtype_set.get(slug=meeting_type)
event = Event.objects.create(
agenda=desk.agenda,

View File

@ -35,6 +35,7 @@ deps =
django32: psycopg2-binary>=2.9
codestyle: pre-commit
git+https://git.entrouvert.org/publik-django-templatetags.git
responses
commands =
./getlasso3.sh
python3 setup.py compile_translations
@ -60,6 +61,7 @@ deps =
pytest-freezegun
psycopg2-binary<2.9
git+https://git.entrouvert.org/publik-django-templatetags.git
responses
commands =
./getlasso3.sh
pylint: ./pylint.sh chrono/ tests/