From e09da74527488adbeb6110a3ef70e82bcb11ed1c Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Wed, 5 Apr 2023 15:48:01 +0200 Subject: [PATCH] add ants_hub application (#76286) It manages synchronization of meetings agendas with the ANTS hub. --- chrono/apps/__init__.py | 0 chrono/apps/ants_hub/__init__.py | 0 chrono/apps/ants_hub/hub.py | 73 +++ chrono/apps/ants_hub/management/__init__.py | 0 .../ants_hub/management/commands/__init__.py | 0 .../management/commands/sync-ants-hub.py | 26 + .../apps/ants_hub/migrations/0001_initial.py | 159 +++++ chrono/apps/ants_hub/migrations/__init__.py | 0 chrono/apps/ants_hub/models.py | 355 ++++++++++ .../templates/chrono/manager_ants_hub.html | 57 ++ .../chrono/manager_ants_hub_add_form.html | 19 + .../manager_ants_hub_agenda_edit_form.html | 57 ++ .../chrono/manager_ants_hub_base.html | 7 + .../chrono/manager_ants_hub_place.html | 78 +++ .../manager_ants_hub_place_edit_form.html | 17 + .../chrono/manager_ants_hub_synchronize.html | 19 + chrono/apps/ants_hub/urls.py | 87 +++ chrono/apps/ants_hub/views.py | 241 +++++++ chrono/manager/static/css/style.scss | 43 ++ .../chrono/manager_confirm_delete.html | 2 +- .../templates/chrono/manager_home.html | 3 + chrono/manager/urls.py | 3 +- chrono/manager/views.py | 1 + chrono/settings.py | 3 + chrono/utils/spooler.py | 14 + debian/uwsgi.ini | 2 + tests/ants_hub/conftest.py | 86 +++ tests/ants_hub/test_commands.py | 70 ++ tests/ants_hub/test_hub.py | 69 ++ tests/ants_hub/test_manager.py | 192 ++++++ tests/ants_hub/test_models.py | 616 ++++++++++++++++++ tests/utils.py | 4 +- tox.ini | 2 + 33 files changed, 2302 insertions(+), 3 deletions(-) create mode 100644 chrono/apps/__init__.py create mode 100644 chrono/apps/ants_hub/__init__.py create mode 100644 chrono/apps/ants_hub/hub.py create mode 100644 chrono/apps/ants_hub/management/__init__.py create mode 100644 chrono/apps/ants_hub/management/commands/__init__.py create mode 100644 chrono/apps/ants_hub/management/commands/sync-ants-hub.py create mode 100644 chrono/apps/ants_hub/migrations/0001_initial.py create mode 100644 chrono/apps/ants_hub/migrations/__init__.py create mode 100644 chrono/apps/ants_hub/models.py create mode 100644 chrono/apps/ants_hub/templates/chrono/manager_ants_hub.html create mode 100644 chrono/apps/ants_hub/templates/chrono/manager_ants_hub_add_form.html create mode 100644 chrono/apps/ants_hub/templates/chrono/manager_ants_hub_agenda_edit_form.html create mode 100644 chrono/apps/ants_hub/templates/chrono/manager_ants_hub_base.html create mode 100644 chrono/apps/ants_hub/templates/chrono/manager_ants_hub_place.html create mode 100644 chrono/apps/ants_hub/templates/chrono/manager_ants_hub_place_edit_form.html create mode 100644 chrono/apps/ants_hub/templates/chrono/manager_ants_hub_synchronize.html create mode 100644 chrono/apps/ants_hub/urls.py create mode 100644 chrono/apps/ants_hub/views.py create mode 100644 tests/ants_hub/conftest.py create mode 100644 tests/ants_hub/test_commands.py create mode 100644 tests/ants_hub/test_hub.py create mode 100644 tests/ants_hub/test_manager.py create mode 100644 tests/ants_hub/test_models.py 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 %} +
+

+ + {{ object }} + + + {% trans "Remove" %} +

+
+ +

+ {% trans "Add place" %} +

+
+
+ {% 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 %} +
+ {% csrf_token %} + {{ form|with_template }} +
+ + {% trans 'Cancel' %} +
+ {% block after-form %} + {% endblock %} +
+{% 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 %} +

{{ object.agenda }}

+{% endblock %} + +{% block content %} +
+ {% csrf_token %} +

{% blocktrans with url=object.agenda.get_absolute_url name=object.agenda %}Configure the mapping between meeting types and ANTS meeting types for agenda {{ name }}.{% endblocktrans %}

+ {% if form.meeting_types %} + + + {% for label, fields in form.field_by_labels %} + + + + + {% endfor %} + +
{{ label }} + {% for field in fields %} + {% if field.errors %} +

+ {% for error in field.errors %} + {{ error }}{% if not forloop.last %}
{% endif %} + {% endfor %} +

+ {% endif %} + {{ field }} + {% endfor %} +
+ {% else %} +
{% blocktrans trimmed %} + This agenda doesn't have any meeting type yet. + {% endblocktrans %}
+ {% endif %} + +
+ {% if form.meeting_types %} + + {% endif %} + {% trans 'Cancel' %} +
+
+{% 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 %}. +

+
+

+ {% trans "Address" %} + {% trans "Edit" %} +

+
+

+ {{ view.place.address }}
+ {{ view.place.zipcode }} {{ view.place.city_name }} +

+

+ {% trans "Geolocation:" %} {{ view.place.longitude }} {{ view.place.latitude }} +

+
+
+
+

+ {% trans "URLs" %} + {% trans "Edit" %} +

+ + + {% for label, value in view.place.url_details %} + + {% endfor %} + +
{{ label }}{{ value }}
+
+
+

+ {% trans "Agendas" %} + {% trans 'Add' %} +

+ {% 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 %} +
+ {% csrf_token %} +

+ {% blocktrans %}Are you sure you want to synchronize your agendas with the ANTS now?{% endblocktrans %} +

+
+ + {% trans 'Cancel' %} +
+
+{% 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/