diff --git a/chrono/apps/__init__.py b/chrono/apps/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/chrono/apps/ants_hub/__init__.py b/chrono/apps/ants_hub/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/chrono/apps/ants_hub/hub.py b/chrono/apps/ants_hub/hub.py
new file mode 100644
index 00000000..78e9a11e
--- /dev/null
+++ b/chrono/apps/ants_hub/hub.py
@@ -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 .
+
+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))
diff --git a/chrono/apps/ants_hub/management/__init__.py b/chrono/apps/ants_hub/management/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/chrono/apps/ants_hub/management/commands/__init__.py b/chrono/apps/ants_hub/management/commands/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/chrono/apps/ants_hub/management/commands/sync-ants-hub.py b/chrono/apps/ants_hub/management/commands/sync-ants-hub.py
new file mode 100644
index 00000000..4f03af71
--- /dev/null
+++ b/chrono/apps/ants_hub/management/commands/sync-ants-hub.py
@@ -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 .
+
+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()
diff --git a/chrono/apps/ants_hub/migrations/0001_initial.py b/chrono/apps/ants_hub/migrations/0001_initial.py
new file mode 100644
index 00000000..65e0cfec
--- /dev/null
+++ b/chrono/apps/ants_hub/migrations/0001_initial.py
@@ -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')},
+ },
+ ),
+ ]
diff --git a/chrono/apps/ants_hub/migrations/__init__.py b/chrono/apps/ants_hub/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/chrono/apps/ants_hub/models.py b/chrono/apps/ants_hub/models.py
new file mode 100644
index 00000000..7a3e6774
--- /dev/null
+++ b/chrono/apps/ants_hub/models.py
@@ -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 .
+
+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'),
+ ]
diff --git a/chrono/apps/ants_hub/templates/chrono/manager_ants_hub.html b/chrono/apps/ants_hub/templates/chrono/manager_ants_hub.html
new file mode 100644
index 00000000..f0014f05
--- /dev/null
+++ b/chrono/apps/ants_hub/templates/chrono/manager_ants_hub.html
@@ -0,0 +1,57 @@
+{% extends "chrono/manager_ants_hub_base.html" %}
+{% load i18n %}
+
+{% block appbar %}
+
{% trans 'ANTS Hub' %}
+
+
+
+ {% trans 'New city' %}
+
+{% endblock %}
+
+{% block content %}
+ {% if object_list %}
+ {% for object in object_list %}
+
+ {% endfor %}
+ {% else %}
+
+ {% 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 %}
+
+ {% endif %}
+{% endblock %}
diff --git a/chrono/apps/ants_hub/templates/chrono/manager_ants_hub_add_form.html b/chrono/apps/ants_hub/templates/chrono/manager_ants_hub_add_form.html
new file mode 100644
index 00000000..c2f0c670
--- /dev/null
+++ b/chrono/apps/ants_hub/templates/chrono/manager_ants_hub_add_form.html
@@ -0,0 +1,19 @@
+{% extends "chrono/manager_home.html" %}
+{% load i18n gadjo %}
+
+{% block appbar %}
+ {% if object %}{{ object }}{% else %}{{ view.name }}{% endif %}
+{% endblock %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/chrono/apps/ants_hub/templates/chrono/manager_ants_hub_agenda_edit_form.html b/chrono/apps/ants_hub/templates/chrono/manager_ants_hub_agenda_edit_form.html
new file mode 100644
index 00000000..bd9ef445
--- /dev/null
+++ b/chrono/apps/ants_hub/templates/chrono/manager_ants_hub_agenda_edit_form.html
@@ -0,0 +1,57 @@
+{% extends "chrono/manager_home.html" %}
+{% load i18n gadjo %}
+
+{% block appbar %}
+
+{% endblock %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/chrono/apps/ants_hub/templates/chrono/manager_ants_hub_base.html b/chrono/apps/ants_hub/templates/chrono/manager_ants_hub_base.html
new file mode 100644
index 00000000..61f1549a
--- /dev/null
+++ b/chrono/apps/ants_hub/templates/chrono/manager_ants_hub_base.html
@@ -0,0 +1,7 @@
+{% extends "chrono/manager_base.html" %}
+{% load i18n %}
+
+{% block breadcrumb %}
+ {{ block.super }}
+ {% trans "ANTS Hub" %}
+{% endblock %}
diff --git a/chrono/apps/ants_hub/templates/chrono/manager_ants_hub_place.html b/chrono/apps/ants_hub/templates/chrono/manager_ants_hub_place.html
new file mode 100644
index 00000000..7f4e14f9
--- /dev/null
+++ b/chrono/apps/ants_hub/templates/chrono/manager_ants_hub_place.html
@@ -0,0 +1,78 @@
+{% extends "chrono/manager_ants_hub_base.html" %}
+{% load i18n %}
+
+{% block breadcrumb %}
+ {{ block.super }}
+ {{ view.place }}
+{% endblock %}
+
+{% block appbar %}
+ {{ view.place }}
+ {% trans "Remove" %}
+{% endblock %}
+
+
+{% block content %}
+
+ {% 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 %}.
+
+
+
+
+
+ {{ view.place.address }}
+ {{ view.place.zipcode }} {{ view.place.city_name }}
+
+
+ {% trans "Geolocation:" %} {{ view.place.longitude }} {{ view.place.latitude }}
+
+
+
+
+
+
+
+ {% for label, value in view.place.url_details %}
+ {{ label }} | {{ value }} |
+ {% endfor %}
+
+
+
+
+
+ {% if object_list %}
+
+ {% endif %}
+
+ {% if not object_list %}
+
+ {% 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 %}
+
+ {% endif %}
+
+{% endblock %}
diff --git a/chrono/apps/ants_hub/templates/chrono/manager_ants_hub_place_edit_form.html b/chrono/apps/ants_hub/templates/chrono/manager_ants_hub_place_edit_form.html
new file mode 100644
index 00000000..27b206bf
--- /dev/null
+++ b/chrono/apps/ants_hub/templates/chrono/manager_ants_hub_place_edit_form.html
@@ -0,0 +1,17 @@
+{% extends "chrono/manager_ants_hub_add_form.html" %}
+{% load i18n gadjo %}
+
+{% block after-form %}
+
+{% endblock %}
diff --git a/chrono/apps/ants_hub/templates/chrono/manager_ants_hub_synchronize.html b/chrono/apps/ants_hub/templates/chrono/manager_ants_hub_synchronize.html
new file mode 100644
index 00000000..6f08c313
--- /dev/null
+++ b/chrono/apps/ants_hub/templates/chrono/manager_ants_hub_synchronize.html
@@ -0,0 +1,19 @@
+{% extends "chrono/manager_home.html" %}
+{% load i18n %}
+
+{% block appbar %}
+ {% trans "Synchronize agendas" %}
+{% endblock %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/chrono/apps/ants_hub/urls.py b/chrono/apps/ants_hub/urls.py
new file mode 100644
index 00000000..192e324b
--- /dev/null
+++ b/chrono/apps/ants_hub/urls.py
@@ -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 .
+
+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//edit/', views.CityEditView.as_view(), name='chrono-manager-ants-hub-city-edit'),
+ path('city//delete/', views.CityDeleteView.as_view(), name='chrono-manager-ants-hub-city-delete'),
+ path('city//place/add/', views.PlaceAddView.as_view(), name='chrono-manager-ants-hub-place-add'),
+ path(
+ 'city//place//', views.PlaceView.as_view(), name='chrono-manager-ants-hub-place'
+ ),
+ path(
+ 'city//place//edit/',
+ views.PlaceEditView.as_view(),
+ name='chrono-manager-ants-hub-place-edit',
+ ),
+ path(
+ 'city//place//url/',
+ views.PlaceUrlEditView.as_view(),
+ name='chrono-manager-ants-hub-place-url',
+ ),
+ path(
+ 'city//place//delete/',
+ views.PlaceDeleteView.as_view(),
+ name='chrono-manager-ants-hub-place-delete',
+ ),
+ path(
+ 'city//place//agenda/add/',
+ views.PlaceAgendaAddView.as_view(),
+ name='chrono-manager-ants-hub-agenda-add',
+ ),
+ path(
+ 'city//place//agenda//edit/',
+ views.PlaceAgendaEditView.as_view(),
+ name='chrono-manager-ants-hub-agenda-edit',
+ ),
+ path(
+ 'city//place//agenda//delete/',
+ views.PlaceAgendaDeleteView.as_view(),
+ name='chrono-manager-ants-hub-agenda-delete',
+ ),
+]
+
+for pattern in urlpatterns:
+ pattern.callback = view_decorator(pattern.callback)
diff --git a/chrono/apps/ants_hub/views.py b/chrono/apps/ants_hub/views.py
new file mode 100644
index 00000000..4304a822
--- /dev/null
+++ b/chrono/apps/ants_hub/views.py
@@ -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 .
+
+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()
diff --git a/chrono/manager/static/css/style.scss b/chrono/manager/static/css/style.scss
index 5e3e2395..6d23ed38 100644
--- a/chrono/manager/static/css/style.scss
+++ b/chrono/manager/static/css/style.scss
@@ -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;
+ }
+}
diff --git a/chrono/manager/templates/chrono/manager_confirm_delete.html b/chrono/manager/templates/chrono/manager_confirm_delete.html
index 63a05d2f..b9ccd441 100644
--- a/chrono/manager/templates/chrono/manager_confirm_delete.html
+++ b/chrono/manager/templates/chrono/manager_confirm_delete.html
@@ -2,7 +2,7 @@
{% load i18n %}
{% block appbar %}
- {{ view.model.get_verbose_name }}
+ {% firstof object view.model.get_verbose_name %}
{% endblock %}
{% block content %}
diff --git a/chrono/manager/templates/chrono/manager_home.html b/chrono/manager/templates/chrono/manager_home.html
index 604ef1d4..028780ec 100644
--- a/chrono/manager/templates/chrono/manager_home.html
+++ b/chrono/manager/templates/chrono/manager_home.html
@@ -21,6 +21,9 @@
{% if user.is_staff %}
{% trans 'Resources' %}
{% endif %}
+ {% if ants_hub_enabled and user.is_staff %}
+ {% trans 'ANTS Hub' %}
+ {% endif %}
{% endif %}
{% if user.is_staff %}
diff --git a/chrono/manager/urls.py b/chrono/manager/urls.py
index 2d24ce6f..002c7455 100644
--- a/chrono/manager/urls.py
+++ b/chrono/manager/urls.py
@@ -14,7 +14,7 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-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')),
]
diff --git a/chrono/manager/views.py b/chrono/manager/views.py
index 5a7e2058..6cfbbad1 100644
--- a/chrono/manager/views.py
+++ b/chrono/manager/views.py
@@ -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):
diff --git a/chrono/settings.py b/chrono/settings.py
index 7aa8eab0..d0e6be7f 100644
--- a/chrono/settings.py
+++ b/chrono/settings.py
@@ -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')
)
diff --git a/chrono/utils/spooler.py b/chrono/utils/spooler.py
index 38a04dc2..c3ebc92a 100644
--- a/chrono/utils/spooler.py
+++ b/chrono/utils/spooler.py
@@ -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
diff --git a/debian/uwsgi.ini b/debian/uwsgi.ini
index 708711d6..f25a745c 100644
--- a/debian/uwsgi.ini
+++ b/debian/uwsgi.ini
@@ -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
diff --git a/tests/ants_hub/conftest.py b/tests/ants_hub/conftest.py
new file mode 100644
index 00000000..b1e3c137
--- /dev/null
+++ b/tests/ants_hub/conftest.py
@@ -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 .
+
+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],
+ }
+ }
+ },
+ )
diff --git a/tests/ants_hub/test_commands.py b/tests/ants_hub/test_commands.py
new file mode 100644
index 00000000..a3f8a09e
--- /dev/null
+++ b/tests/ants_hub/test_commands.py
@@ -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 .
+
+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'],
+ }
diff --git a/tests/ants_hub/test_hub.py b/tests/ants_hub/test_hub.py
new file mode 100644
index 00000000..cdea8c5d
--- /dev/null
+++ b/tests/ants_hub/test_hub.py
@@ -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 .
+
+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({})
diff --git a/tests/ants_hub/test_manager.py b/tests/ants_hub/test_manager.py
new file mode 100644
index 00000000..219f0394
--- /dev/null
+++ b/tests/ants_hub/test_manager.py
@@ -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 .
+
+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
diff --git a/tests/ants_hub/test_models.py b/tests/ants_hub/test_models.py
new file mode 100644
index 00000000..b2ac3ae6
--- /dev/null
+++ b/tests/ants_hub/test_models.py
@@ -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 .
+
+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'],
+ },
+ ],
+ },
+ ],
+ }
diff --git a/tests/utils.py b/tests/utils.py
index 25ffc694..11b03623 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -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,
diff --git a/tox.ini b/tox.ini
index 2a4ad61d..fc626354 100644
--- a/tox.ini
+++ b/tox.ini
@@ -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/