It manages synchronization of meetings agendas with the ANTS hub.
This commit is contained in:
parent
05bce1763e
commit
e09da74527
|
@ -0,0 +1,73 @@
|
||||||
|
# chrono - agendas system
|
||||||
|
# Copyright (C) 2023 Entr'ouvert
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU Affero General Public License as published
|
||||||
|
# by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3.util.retry import Retry
|
||||||
|
|
||||||
|
|
||||||
|
class AntsHubException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def make_http_session(retries=3):
|
||||||
|
session = requests.Session()
|
||||||
|
retry = Retry(
|
||||||
|
total=retries,
|
||||||
|
read=retries,
|
||||||
|
connect=retries,
|
||||||
|
backoff_factor=0.5,
|
||||||
|
status_forcelist=(502, 503),
|
||||||
|
)
|
||||||
|
adapter = HTTPAdapter(max_retries=retry)
|
||||||
|
session.mount('http://', adapter)
|
||||||
|
session.mount('https://', adapter)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def make_url(path):
|
||||||
|
return f'{settings.CHRONO_ANTS_HUB_URL}{path}'
|
||||||
|
|
||||||
|
|
||||||
|
def ping(timeout=1):
|
||||||
|
session = make_http_session()
|
||||||
|
try:
|
||||||
|
response = session.get(make_url('ping/'), timeout=timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
err = response.json()['err']
|
||||||
|
if err != 0:
|
||||||
|
raise AntsHubException(err)
|
||||||
|
except requests.Timeout:
|
||||||
|
pass
|
||||||
|
except (TypeError, KeyError, requests.RequestException) as e:
|
||||||
|
raise AntsHubException(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def push_rendez_vous_disponibles(payload):
|
||||||
|
session = make_http_session()
|
||||||
|
try:
|
||||||
|
response = session.post(make_url('rendez-vous-disponibles/'), json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
err = data['err']
|
||||||
|
if err != 0:
|
||||||
|
raise AntsHubException(err)
|
||||||
|
return data
|
||||||
|
except requests.Timeout:
|
||||||
|
return True
|
||||||
|
except (TypeError, KeyError, requests.RequestException) as e:
|
||||||
|
raise AntsHubException(str(e))
|
|
@ -0,0 +1,26 @@
|
||||||
|
# chrono - agendas system
|
||||||
|
# Copyright (C) 2023 Entr'ouvert
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU Affero General Public License as published
|
||||||
|
# by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from chrono.apps.ants_hub import models
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Synchronize agendas with the ANTS hub.'
|
||||||
|
|
||||||
|
def handle(self, **options):
|
||||||
|
models.City.push()
|
|
@ -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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,355 @@
|
||||||
|
# chrono - agendas system
|
||||||
|
# Copyright (C) 2023 Entr'ouvert
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU Affero General Public License as published
|
||||||
|
# by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import collections
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models, transaction
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from chrono.agendas.models import Agenda, Booking
|
||||||
|
from chrono.utils.timezone import localtime, now
|
||||||
|
|
||||||
|
from .hub import push_rendez_vous_disponibles
|
||||||
|
|
||||||
|
# fields without max_length problems
|
||||||
|
|
||||||
|
|
||||||
|
class CharField(models.TextField):
|
||||||
|
'''TextField using forms.TextInput as widget'''
|
||||||
|
|
||||||
|
def formfield(self, **kwargs):
|
||||||
|
defaults = {'widget': forms.TextInput}
|
||||||
|
defaults.update(**kwargs)
|
||||||
|
return super().formfield(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
class URLField(models.URLField):
|
||||||
|
'''URLField using a TEXT column for storage'''
|
||||||
|
|
||||||
|
def get_internal_type(self):
|
||||||
|
return 'TextField'
|
||||||
|
|
||||||
|
|
||||||
|
class CityManager(models.Manager):
|
||||||
|
def get_by_natural_key(self, name):
|
||||||
|
return self.get(name=name)
|
||||||
|
|
||||||
|
|
||||||
|
def get_portal_url():
|
||||||
|
template_vars = getattr(settings, 'TEMPLATE_VARS', {})
|
||||||
|
return template_vars.get('portal_url', '')
|
||||||
|
|
||||||
|
|
||||||
|
class City(models.Model):
|
||||||
|
name = CharField(verbose_name=_('Name'), unique=True)
|
||||||
|
url = URLField(verbose_name=_('Portal URL'), default=get_portal_url, blank=True)
|
||||||
|
logo_url = URLField(verbose_name=_('Logo URL'), blank=True)
|
||||||
|
meeting_url = URLField(
|
||||||
|
verbose_name=_('Booking URL'), help_text=_('URL of the web form to make a booking.'), blank=True
|
||||||
|
)
|
||||||
|
management_url = URLField(
|
||||||
|
verbose_name=_('Booking management URL'),
|
||||||
|
help_text=_('Generic URL to find and manage an existing booking.'),
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
cancel_url = URLField(
|
||||||
|
verbose_name=_('Booking cancellation URL'),
|
||||||
|
help_text=_('Generic URL to find and cancel an existing booking.'),
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
created = models.DateTimeField(verbose_name=_('Created'), auto_now_add=True)
|
||||||
|
last_update = models.DateTimeField(verbose_name=_('Last update'), auto_now=True)
|
||||||
|
full_sync = models.BooleanField(
|
||||||
|
verbose_name=_('Full synchronization next time'), default=False, editable=False
|
||||||
|
)
|
||||||
|
last_sync = models.DateTimeField(verbose_name=_('Last synchronization'), null=True, editable=False)
|
||||||
|
|
||||||
|
objects = CityManager()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.name}'
|
||||||
|
|
||||||
|
def natural_key(self):
|
||||||
|
return (self.name,)
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse('chrono-manager-ants-hub')
|
||||||
|
|
||||||
|
def details(self):
|
||||||
|
details = []
|
||||||
|
for key in ['url', 'logo_url', 'management_url', 'cancel_url', 'full_sync']:
|
||||||
|
value = getattr(self, key, None)
|
||||||
|
if value:
|
||||||
|
verbose_name = type(self)._meta.get_field(key).verbose_name
|
||||||
|
details.append((verbose_name, value))
|
||||||
|
return details
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@transaction.atomic(savepoint=False)
|
||||||
|
def push(cls):
|
||||||
|
reference = now()
|
||||||
|
# prevent concurrent pushes with locks
|
||||||
|
cities = list(cls.objects.select_for_update())
|
||||||
|
push_rendez_vous_disponibles(
|
||||||
|
{
|
||||||
|
'collectivites': [city.export_to_push() for city in cities],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
City.objects.update(last_sync=reference)
|
||||||
|
Place.objects.update(last_sync=reference)
|
||||||
|
|
||||||
|
def export_to_push(self):
|
||||||
|
payload = {
|
||||||
|
'full': True,
|
||||||
|
'id': str(self.pk),
|
||||||
|
'nom': self.name,
|
||||||
|
'url': self.url,
|
||||||
|
'logo_url': self.logo_url,
|
||||||
|
'rdv_url': self.meeting_url,
|
||||||
|
'gestion_url': self.management_url,
|
||||||
|
'annulation_url': self.cancel_url,
|
||||||
|
'lieux': [place.export_to_push() for place in self.places.all()],
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _('City')
|
||||||
|
verbose_name_plural = _('Cities')
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceManager(models.Manager):
|
||||||
|
def get_by_natural_key(self, name, *args):
|
||||||
|
return self.get(name=name, city=City.objects.get_by_natural_key(*args))
|
||||||
|
|
||||||
|
|
||||||
|
class Place(models.Model):
|
||||||
|
city = models.ForeignKey(
|
||||||
|
verbose_name=_('City'), to=City, related_name='places', on_delete=models.CASCADE, editable=False
|
||||||
|
)
|
||||||
|
name = CharField(verbose_name=_('Name'))
|
||||||
|
address = CharField(verbose_name=_('Address'))
|
||||||
|
zipcode = CharField(verbose_name=_('Code postal'))
|
||||||
|
city_name = CharField(verbose_name=_('City name'))
|
||||||
|
longitude = models.FloatField(verbose_name=_('Longitude'), default=2.476)
|
||||||
|
latitude = models.FloatField(verbose_name=_('Latitude'), default=46.596)
|
||||||
|
url = URLField(verbose_name=_('Portal URL'), blank=True)
|
||||||
|
logo_url = URLField(verbose_name=_('Logo URL'), blank=True)
|
||||||
|
meeting_url = URLField(
|
||||||
|
verbose_name=_('Booking URL'), help_text=_('URL of the web form to make a booking.'), blank=True
|
||||||
|
)
|
||||||
|
management_url = URLField(
|
||||||
|
verbose_name=_('Booking management URL'),
|
||||||
|
help_text=_('Generic URL to find and manage an existing booking.'),
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
cancel_url = URLField(
|
||||||
|
verbose_name=_('Booking cancellation URL'),
|
||||||
|
help_text=_('Generic URL to find and cancel an existing booking.'),
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
created = models.DateTimeField(verbose_name=_('Created'), auto_now_add=True)
|
||||||
|
last_update = models.DateTimeField(verbose_name=_('Last update'), auto_now=True)
|
||||||
|
full_sync = models.BooleanField(verbose_name=_('Full synchronization'), default=False)
|
||||||
|
last_sync = models.DateTimeField(verbose_name=_('Last synchronization'), null=True, editable=False)
|
||||||
|
|
||||||
|
objects = PlaceManager()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.name}'
|
||||||
|
|
||||||
|
def natural_key(self):
|
||||||
|
return (self.name,) + self.city.natural_key()
|
||||||
|
|
||||||
|
natural_key.dependencies = ['ants_hub.city']
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse('chrono-manager-ants-hub-place', kwargs={'city_pk': self.city_id, 'pk': self.pk})
|
||||||
|
|
||||||
|
def url_details(self):
|
||||||
|
details = []
|
||||||
|
for key in ['url', 'logo_url', 'management_url', 'cancel_url', 'full_sync']:
|
||||||
|
value = getattr(self, key, None)
|
||||||
|
if value:
|
||||||
|
verbose_name = type(self)._meta.get_field(key).verbose_name
|
||||||
|
details.append((verbose_name, value))
|
||||||
|
return details
|
||||||
|
|
||||||
|
def export_to_push(self):
|
||||||
|
payload = {
|
||||||
|
'full': True,
|
||||||
|
'id': str(self.pk),
|
||||||
|
'nom': self.name,
|
||||||
|
'numero_rue': self.address,
|
||||||
|
'code_postal': self.zipcode,
|
||||||
|
'ville': self.city_name,
|
||||||
|
'longitude': self.longitude,
|
||||||
|
'latitude': self.latitude,
|
||||||
|
'url': self.url,
|
||||||
|
'rdv_url': self.meeting_url,
|
||||||
|
'gestion_url': self.management_url,
|
||||||
|
'annulation_url': self.cancel_url,
|
||||||
|
'plages': list(self.iter_open_dates()),
|
||||||
|
'rdvs': list(self.iter_predemandes()),
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def iter_open_dates(self):
|
||||||
|
for place_agenda in self.agendas.all():
|
||||||
|
yield from place_agenda.iter_open_dates()
|
||||||
|
|
||||||
|
def iter_predemandes(self):
|
||||||
|
agendas = Agenda.objects.filter(ants_place__place=self)
|
||||||
|
agendas |= Agenda.objects.filter(virtual_agendas__ants_place__place=self)
|
||||||
|
agendas = set(agendas)
|
||||||
|
bookings = (
|
||||||
|
Booking.objects.filter(
|
||||||
|
event__desk__agenda__in=agendas,
|
||||||
|
event__start_datetime__gt=now(),
|
||||||
|
extra_data__ants_identifiant_predemande__isnull=False,
|
||||||
|
)
|
||||||
|
.values_list(
|
||||||
|
'extra_data__ants_identifiant_predemande', 'event__start_datetime', 'cancellation_datetime'
|
||||||
|
)
|
||||||
|
.order_by('event__state_datetime')
|
||||||
|
)
|
||||||
|
for identifiant_predemande, start_datetime, cancellation_datetime in bookings:
|
||||||
|
if not isinstance(identifiant_predemande, str):
|
||||||
|
continue
|
||||||
|
rdv = {
|
||||||
|
'id': identifiant_predemande,
|
||||||
|
'date': start_datetime.isoformat(),
|
||||||
|
}
|
||||||
|
if cancellation_datetime is not None:
|
||||||
|
rdv['annule'] = True
|
||||||
|
yield rdv
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _('place')
|
||||||
|
verbose_name_plural = _('places')
|
||||||
|
unique_together = [
|
||||||
|
('city', 'name'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ANTSMeetingType(models.IntegerChoices):
|
||||||
|
CNI = 1, _('CNI')
|
||||||
|
PASSPORT = 2, _('Passport')
|
||||||
|
CNI_PASSPORT = 3, _('CNI and passport')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ants_name(self):
|
||||||
|
return super().name.replace('_', '-')
|
||||||
|
|
||||||
|
|
||||||
|
class ANTSPersonsNumber(models.IntegerChoices):
|
||||||
|
ONE = 1, _('1 person')
|
||||||
|
TWO = 2, _('2 persons')
|
||||||
|
THREE = 3, _('3 persons')
|
||||||
|
FOUR = 4, _('4 persons')
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceAgenda(models.Model):
|
||||||
|
place = models.ForeignKey(
|
||||||
|
verbose_name=_('Place'),
|
||||||
|
to=Place,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='agendas',
|
||||||
|
)
|
||||||
|
agenda = models.ForeignKey(
|
||||||
|
verbose_name=_('Agenda'),
|
||||||
|
to='agendas.Agenda',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='+',
|
||||||
|
related_query_name='ants_place',
|
||||||
|
)
|
||||||
|
setting = models.JSONField(verbose_name=_('Setting'), default=dict, blank=True)
|
||||||
|
|
||||||
|
def set_meeting_type_setting(self, meeting_type, key, value):
|
||||||
|
assert key in ['ants_meeting_type', 'ants_persons_number']
|
||||||
|
meeting_types = self.setting.setdefault('meeting-types', {})
|
||||||
|
value = map(int, value)
|
||||||
|
if key == 'ants_meeting_type':
|
||||||
|
value = list(map(ANTSMeetingType, value))
|
||||||
|
else:
|
||||||
|
value = list(map(ANTSPersonsNumber, value))
|
||||||
|
meeting_types.setdefault(str(meeting_type.slug), {})[key] = value
|
||||||
|
|
||||||
|
def get_meeting_type_setting(self, meeting_type, key):
|
||||||
|
assert key in ['ants_meeting_type', 'ants_persons_number']
|
||||||
|
meeting_types = self.setting.setdefault('meeting-types', {})
|
||||||
|
value = meeting_types.get(str(meeting_type.slug), {}).get(key, [])
|
||||||
|
value = map(int, value)
|
||||||
|
if key == 'ants_meeting_type':
|
||||||
|
value = list(map(ANTSMeetingType, value))
|
||||||
|
else:
|
||||||
|
value = list(map(ANTSPersonsNumber, value))
|
||||||
|
return value
|
||||||
|
|
||||||
|
def iter_ants_meeting_types_and_persons(self):
|
||||||
|
d = collections.defaultdict(set)
|
||||||
|
for meeting_type in self.agenda.iter_meetingtypes():
|
||||||
|
for ants_meeting_type in self.get_meeting_type_setting(meeting_type, 'ants_meeting_type'):
|
||||||
|
for ants_persons_number in self.get_meeting_type_setting(meeting_type, 'ants_persons_number'):
|
||||||
|
meeting_type.id = meeting_type.slug
|
||||||
|
d[(meeting_type, ants_persons_number)].add(ants_meeting_type)
|
||||||
|
for (meeting_type, ants_persons_number), ants_meeting_types in d.items():
|
||||||
|
yield meeting_type, ants_persons_number, ants_meeting_types
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ants_properties(self):
|
||||||
|
rdv = set()
|
||||||
|
persons = set()
|
||||||
|
for meeting_type in self.agenda.meetingtype_set.all():
|
||||||
|
rdv.update(self.get_meeting_type_setting(meeting_type, 'ants_meeting_type'))
|
||||||
|
persons.update(self.get_meeting_type_setting(meeting_type, 'ants_persons_number'))
|
||||||
|
rdv = sorted(list(rdv))
|
||||||
|
persons = sorted(list(persons))
|
||||||
|
return [x.label for x in rdv] + [x.label for x in persons]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return str(self.agenda)
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return self.place.get_absolute_url()
|
||||||
|
|
||||||
|
def iter_open_dates(self):
|
||||||
|
settings = list(self.iter_ants_meeting_types_and_persons())
|
||||||
|
if not settings:
|
||||||
|
return
|
||||||
|
intervals = self.agenda.get_free_time(end_datetime=now() + datetime.timedelta(days=6 * 30))
|
||||||
|
for start, end in intervals:
|
||||||
|
for meeting_type, ants_persons_number, ants_meeting_types in settings:
|
||||||
|
duration = datetime.timedelta(minutes=meeting_type.duration)
|
||||||
|
if end - start < duration:
|
||||||
|
continue
|
||||||
|
yield {
|
||||||
|
'date': localtime(start).date().isoformat(),
|
||||||
|
# use naive local time representation
|
||||||
|
'heure_debut': localtime(start).time().replace(tzinfo=None).isoformat(),
|
||||||
|
'heure_fin': localtime(end).time().replace(tzinfo=None).isoformat(),
|
||||||
|
'duree': meeting_type.duration,
|
||||||
|
'personnes': int(ants_persons_number),
|
||||||
|
'types_rdv': [x.ants_name for x in ants_meeting_types],
|
||||||
|
}
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = [
|
||||||
|
('place', 'agenda'),
|
||||||
|
]
|
|
@ -0,0 +1,57 @@
|
||||||
|
{% extends "chrono/manager_ants_hub_base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block appbar %}
|
||||||
|
<h2>{% trans 'ANTS Hub' %}</h2>
|
||||||
|
<span class="actions">
|
||||||
|
<a class="extra-actions-menu-opener"></a>
|
||||||
|
<ul class="extra-actions-menu">
|
||||||
|
<li><a rel="popup" href="{% url 'chrono-manager-ants-hub-synchronize' %}">{% trans 'Synchronize agendas' %}</a></li>
|
||||||
|
</ul>
|
||||||
|
<a rel="popup" href="{% url 'chrono-manager-ants-hub-city-add' %}">{% trans 'New city' %}</a>
|
||||||
|
</span>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if object_list %}
|
||||||
|
{% for object in object_list %}
|
||||||
|
<div class="section">
|
||||||
|
<h3>
|
||||||
|
<span>
|
||||||
|
{{ object }}
|
||||||
|
<a class="icon-edit" rel="popup" href="{% url 'chrono-manager-ants-hub-city-edit' pk=object.pk %}"></a>
|
||||||
|
</span>
|
||||||
|
<a class="button delete-button" rel="popup" href="{% url 'chrono-manager-ants-hub-city-delete' pk=object.pk %}">{% trans "Remove" %}</a>
|
||||||
|
</h3>
|
||||||
|
<div>
|
||||||
|
<ul class="objects-list single-links">
|
||||||
|
<p>
|
||||||
|
{% if not object.last_sync %}{% trans "Never synchronized" %}{% else %}{% blocktrans with date=object.last_sync|date:"SHORT_DATETIME_FORMAT" %}Last synchronization on {{ date }}{% endblocktrans %}{% endif %}.
|
||||||
|
</p>
|
||||||
|
{% for label, value in object.details %}
|
||||||
|
<p>{{ label }}: {{ value }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for place in object.places.all %}
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'chrono-manager-ants-hub-place' city_pk=object.pk pk=place.pk %}">{{ place }}
|
||||||
|
{% if place.agendas.count %}<span class="identifier">({% blocktrans count counter=place.agendas.count %}1 agenda{% plural %}{{ counter }} agendas{% endblocktrans %})</span>{% endif %}</a>
|
||||||
|
<a class="delete" rel="popup" href="{% url 'chrono-manager-ants-hub-place-delete' city_pk=object.pk pk=place.pk %}">{% trans "remove" %}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
<a class="button" rel="popup" href="{% url 'chrono-manager-ants-hub-place-add' pk=object.pk %}">{% trans "Add place" %}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="big-msg-info">
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
This site doesn't have any city yet. Click on the "New city" button in the top
|
||||||
|
right of the page to add a first one.
|
||||||
|
{% endblocktrans %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,19 @@
|
||||||
|
{% extends "chrono/manager_home.html" %}
|
||||||
|
{% load i18n gadjo %}
|
||||||
|
|
||||||
|
{% block appbar %}
|
||||||
|
<h2>{% if object %}{{ object }}{% else %}{{ view.name }}{% endif %}</h2>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form|with_template }}
|
||||||
|
<div class="buttons">
|
||||||
|
<button class="submit-button">{% trans "Save" %}</button>
|
||||||
|
<a class="cancel" href="{% url 'chrono-manager-ants-hub' %}">{% trans 'Cancel' %}</a>
|
||||||
|
</div>
|
||||||
|
{% block after-form %}
|
||||||
|
{% endblock %}
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,57 @@
|
||||||
|
{% extends "chrono/manager_home.html" %}
|
||||||
|
{% load i18n gadjo %}
|
||||||
|
|
||||||
|
{% block appbar %}
|
||||||
|
<h2><a href="{{ object.agenda.get_absolute_url }}">{{ object.agenda }}</a></h2>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
<p>{% blocktrans with url=object.agenda.get_absolute_url name=object.agenda %}Configure the mapping between meeting types and ANTS meeting types for agenda <a href="{{ url }}">{{ name }}</a>.{% endblocktrans %}</p>
|
||||||
|
{% if form.meeting_types %}
|
||||||
|
<table class="main ants-setting">
|
||||||
|
<tbody>
|
||||||
|
{% for label, fields in form.field_by_labels %}
|
||||||
|
<tr>
|
||||||
|
<td class="meeting-type">{{ label }}</td>
|
||||||
|
<td>
|
||||||
|
{% for field in fields %}
|
||||||
|
{% if field.errors %}
|
||||||
|
<div class="error"><p>
|
||||||
|
{% for error in field.errors %}
|
||||||
|
{{ error }}{% if not forloop.last %}<br>{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</p></div>
|
||||||
|
{% endif %}
|
||||||
|
{{ field }}
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="big-msg-info">{% blocktrans trimmed %}
|
||||||
|
This agenda doesn't have any meeting type yet.
|
||||||
|
{% endblocktrans %}</div>
|
||||||
|
{% endif %}
|
||||||
|
<script>
|
||||||
|
$('form').on('click', 'label', function (event) {
|
||||||
|
console.log(event);
|
||||||
|
});
|
||||||
|
$('form').on('change', 'input', function (event) {
|
||||||
|
$(event.target).parent().toggleClass('checked');
|
||||||
|
});
|
||||||
|
$('form input:checked').each(function (i, elt) {
|
||||||
|
$(elt).parent().toggleClass('checked');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<div class="buttons">
|
||||||
|
{% if form.meeting_types %}
|
||||||
|
<button class="submit-button">{% trans "Save" %}</button>
|
||||||
|
{% endif %}
|
||||||
|
<a class="cancel" href="{% url 'chrono-manager-ants-hub' %}">{% trans 'Cancel' %}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,7 @@
|
||||||
|
{% extends "chrono/manager_base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block breadcrumb %}
|
||||||
|
{{ block.super }}
|
||||||
|
<a href="{% url "chrono-manager-ants-hub" %}">{% trans "ANTS Hub" %}</a>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,78 @@
|
||||||
|
{% extends "chrono/manager_ants_hub_base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block breadcrumb %}
|
||||||
|
{{ block.super }}
|
||||||
|
<a href=".">{{ view.place }}</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block appbar %}
|
||||||
|
<h2>{{ view.place }}</h2>
|
||||||
|
<a rel="popup" class="delete-button" href="{% url 'chrono-manager-ants-hub-place-delete' city_pk=view.place.city_id pk=view.place.pk %}">{% trans "Remove" %}</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p>
|
||||||
|
{% if not view.place.last_sync %}{% trans "Never synchronized" %}{% else %}{% blocktrans with date=view.place.last_sync|date:"SHORT_DATETIME_FORMAT" %}Last synchronization on {{ date }}{% endblocktrans %}{% endif %}.
|
||||||
|
</p>
|
||||||
|
<div class="section">
|
||||||
|
<h3>
|
||||||
|
{% trans "Address" %}
|
||||||
|
<a class="button" rel="popup" href="{% url 'chrono-manager-ants-hub-place-edit' city_pk=view.place.city_id pk=view.place.pk %}">{% trans "Edit" %}</a>
|
||||||
|
</h3>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
{{ view.place.address }}</br>
|
||||||
|
{{ view.place.zipcode }} {{ view.place.city_name }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{% trans "Geolocation:" %} {{ view.place.longitude }} {{ view.place.latitude }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section">
|
||||||
|
<h3>
|
||||||
|
{% trans "URLs" %}
|
||||||
|
<a class="button" rel="popup" href="{% url 'chrono-manager-ants-hub-place-url' city_pk=view.place.city_id pk=view.place.pk %}">{% trans "Edit" %}</a>
|
||||||
|
</h3>
|
||||||
|
<table class="main">
|
||||||
|
<tbody>
|
||||||
|
{% for label, value in view.place.url_details %}
|
||||||
|
<tr><td>{{ label }}</td><td>{{ value }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="section">
|
||||||
|
<h3>
|
||||||
|
{% trans "Agendas" %}
|
||||||
|
<a rel="popup" class="button" href="{% url 'chrono-manager-ants-hub-agenda-add' city_pk=view.place.city_id pk=view.place.pk %}">{% trans 'Add' %}</a>
|
||||||
|
</h3>
|
||||||
|
{% if object_list %}
|
||||||
|
<ul class="objects-list single-links">
|
||||||
|
{% for object in object_list %}
|
||||||
|
<li {% if not object.ants_properties %}class="ants-setting-not-configured"{% endif %}>
|
||||||
|
<a rel="popup" id="open-place-agenda-{{ object.pk }}" class="edit" href="{% url 'chrono-manager-ants-hub-agenda-edit' pk=object.pk city_pk=view.place.city_id place_pk=view.place.pk %}">
|
||||||
|
<span class="label">{{ object }}</span>
|
||||||
|
<span class="properties">({% if object.ants_properties %}{{ object.ants_properties|join:", " }}{% else %}{% trans "not configured" %}{% endif %})</span></a>
|
||||||
|
<a class="delete" rel="popup" href="{% url 'chrono-manager-ants-hub-agenda-delete' pk=object.pk city_pk=view.place.city_id place_pk=view.place.pk %}">{% trans "remove" %}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if not object_list %}
|
||||||
|
<div class="big-msg-info">
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
This site doesn't have any agenda yet. Click on the "New place" button in the top
|
||||||
|
right of the page to add a first one.
|
||||||
|
{% endblocktrans %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<script>
|
||||||
|
setTimeout(function () {
|
||||||
|
$(window.location.hash).click();
|
||||||
|
}, 100);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,17 @@
|
||||||
|
{% extends "chrono/manager_ants_hub_add_form.html" %}
|
||||||
|
{% load i18n gadjo %}
|
||||||
|
|
||||||
|
{% block after-form %}
|
||||||
|
<script>
|
||||||
|
$('form').on('change keyup', '#id_address, #id_zipcode, #id_city_name', function (event) {
|
||||||
|
var q = $('#id_address').val() + ' ' + $('#id_zipcode').val() + ' ' + $('#id_city_name').val();
|
||||||
|
console.log('q', q)
|
||||||
|
var url = "https://api-adresse.data.gouv.fr/search/?q=" + encodeURIComponent(q);
|
||||||
|
$.ajax(url).done(function (data) {
|
||||||
|
var coords = data.features[0].geometry.coordinates;
|
||||||
|
$('#id_longitude').val(coords[0]);
|
||||||
|
$('#id_latitude').val(coords[1]);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,19 @@
|
||||||
|
{% extends "chrono/manager_home.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block appbar %}
|
||||||
|
<h2>{% trans "Synchronize agendas" %}</h2>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<p>
|
||||||
|
{% blocktrans %}Are you sure you want to synchronize your agendas with the ANTS now?{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
<div class="buttons">
|
||||||
|
<button class="button" >{% trans 'Synchronize' %}</button>
|
||||||
|
<a class="cancel" href="{% url 'chrono-manager-ants-hub' %}">{% trans 'Cancel' %}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,87 @@
|
||||||
|
# chrono - agendas system
|
||||||
|
# Copyright (C) 2023 Entr'ouvert
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU Affero General Public License as published
|
||||||
|
# by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import functools
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.urls import path
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
|
||||||
|
def view_decorator(func):
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(request, *args, **kwargs):
|
||||||
|
if not request.user.is_staff:
|
||||||
|
raise PermissionDenied
|
||||||
|
if not settings.CHRONO_ANTS_HUB_URL:
|
||||||
|
messages.info(
|
||||||
|
request, _('Configure CHRONO_ANTS_HUB_URL to get access to ANTS-Hub configuration panel.')
|
||||||
|
)
|
||||||
|
return redirect('chrono-manager-homepage')
|
||||||
|
return func(request, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', views.Homepage.as_view(), name='chrono-manager-ants-hub'),
|
||||||
|
path('synchronize/', views.Synchronize.as_view(), name='chrono-manager-ants-hub-synchronize'),
|
||||||
|
path('city/add/', views.CityAddView.as_view(), name='chrono-manager-ants-hub-city-add'),
|
||||||
|
path('city/<int:pk>/edit/', views.CityEditView.as_view(), name='chrono-manager-ants-hub-city-edit'),
|
||||||
|
path('city/<int:pk>/delete/', views.CityDeleteView.as_view(), name='chrono-manager-ants-hub-city-delete'),
|
||||||
|
path('city/<int:pk>/place/add/', views.PlaceAddView.as_view(), name='chrono-manager-ants-hub-place-add'),
|
||||||
|
path(
|
||||||
|
'city/<int:city_pk>/place/<int:pk>/', views.PlaceView.as_view(), name='chrono-manager-ants-hub-place'
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
'city/<int:city_pk>/place/<int:pk>/edit/',
|
||||||
|
views.PlaceEditView.as_view(),
|
||||||
|
name='chrono-manager-ants-hub-place-edit',
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
'city/<int:city_pk>/place/<int:pk>/url/',
|
||||||
|
views.PlaceUrlEditView.as_view(),
|
||||||
|
name='chrono-manager-ants-hub-place-url',
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
'city/<int:city_pk>/place/<int:pk>/delete/',
|
||||||
|
views.PlaceDeleteView.as_view(),
|
||||||
|
name='chrono-manager-ants-hub-place-delete',
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
'city/<int:city_pk>/place/<int:pk>/agenda/add/',
|
||||||
|
views.PlaceAgendaAddView.as_view(),
|
||||||
|
name='chrono-manager-ants-hub-agenda-add',
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
'city/<int:city_pk>/place/<int:place_pk>/agenda/<int:pk>/edit/',
|
||||||
|
views.PlaceAgendaEditView.as_view(),
|
||||||
|
name='chrono-manager-ants-hub-agenda-edit',
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
'city/<int:city_pk>/place/<int:place_pk>/agenda/<int:pk>/delete/',
|
||||||
|
views.PlaceAgendaDeleteView.as_view(),
|
||||||
|
name='chrono-manager-ants-hub-agenda-delete',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in urlpatterns:
|
||||||
|
pattern.callback = view_decorator(pattern.callback)
|
|
@ -0,0 +1,241 @@
|
||||||
|
# chrono - agendas system
|
||||||
|
# Copyright (C) 2023 Entr'ouvert
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU Affero General Public License as published
|
||||||
|
# by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.views.generic import CreateView, DeleteView, ListView, TemplateView, UpdateView
|
||||||
|
|
||||||
|
from . import hub, models
|
||||||
|
|
||||||
|
|
||||||
|
class Homepage(ListView):
|
||||||
|
template_name = 'chrono/manager_ants_hub.html'
|
||||||
|
model = models.City
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
ok = cache.get('ants-hub-ok')
|
||||||
|
if not ok:
|
||||||
|
try:
|
||||||
|
hub.ping()
|
||||||
|
except hub.AntsHubException as e:
|
||||||
|
messages.warning(self.request, _('ANTS Hub is down: "%s".') % e)
|
||||||
|
else:
|
||||||
|
messages.info(self.request, _('ANTS Hub is responding.'))
|
||||||
|
cache.set('ants-hub-ok', True, 600)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class CityAddView(CreateView):
|
||||||
|
template_name = 'chrono/manager_ants_hub_add_form.html'
|
||||||
|
model = models.City
|
||||||
|
name = _('New city')
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
class CityMixin:
|
||||||
|
def dispatch(self, request, pk):
|
||||||
|
self.city = get_object_or_404(models.City, pk=pk)
|
||||||
|
return super().dispatch(request, pk=pk)
|
||||||
|
|
||||||
|
|
||||||
|
class CityView(CityMixin, ListView):
|
||||||
|
template_name = 'chrono/manager_ants_hub_city.html'
|
||||||
|
model = models.Place
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().filter(city=self.city)
|
||||||
|
|
||||||
|
|
||||||
|
class CityEditView(UpdateView):
|
||||||
|
template_name = 'chrono/manager_ants_hub_add_form.html'
|
||||||
|
model = models.City
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
class CityDeleteView(DeleteView):
|
||||||
|
template_name = 'chrono/manager_confirm_delete.html'
|
||||||
|
model = models.City
|
||||||
|
success_url = '../../../'
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.Place
|
||||||
|
exclude = ['city']
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceAddView(CityMixin, CreateView):
|
||||||
|
template_name = 'chrono/manager_ants_hub_add_form.html'
|
||||||
|
model = models.Place
|
||||||
|
form_class = PlaceForm
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return _('New place in %s') % self.city
|
||||||
|
|
||||||
|
def dispatch(self, request, pk):
|
||||||
|
self.city = get_object_or_404(models.City, pk=pk)
|
||||||
|
return super().dispatch(request, pk)
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
kwargs = super().get_form_kwargs()
|
||||||
|
kwargs['instance'] = models.Place(city=self.city)
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceMixin:
|
||||||
|
def dispatch(self, request, city_pk, pk):
|
||||||
|
self.place = get_object_or_404(models.Place, pk=pk, city_id=city_pk)
|
||||||
|
self.city = self.place.city
|
||||||
|
return super().dispatch(request, pk=pk)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceView(PlaceMixin, ListView):
|
||||||
|
template_name = 'chrono/manager_ants_hub_place.html'
|
||||||
|
model = models.PlaceAgenda
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().filter(place=self.place)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceEditForm(PlaceForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.Place
|
||||||
|
fields = ['name', 'address', 'zipcode', 'city_name', 'longitude', 'latitude']
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceEditView(UpdateView):
|
||||||
|
template_name = 'chrono/manager_ants_hub_place_edit_form.html'
|
||||||
|
model = models.Place
|
||||||
|
fields = ['zipcode', 'city_name', 'address', 'longitude', 'latitude']
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceDeleteView(DeleteView):
|
||||||
|
template_name = 'chrono/manager_confirm_delete.html'
|
||||||
|
model = models.Place
|
||||||
|
success_url = '../../../../../'
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceUrlEditView(UpdateView):
|
||||||
|
template_name = 'chrono/manager_ants_hub_add_form.html'
|
||||||
|
model = models.Place
|
||||||
|
fields = ['url', 'logo_url', 'meeting_url', 'management_url', 'cancel_url']
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceAgendaAddForm(forms.ModelForm):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
# hide agendas already linked to the place
|
||||||
|
self.fields['agenda'].queryset = self.fields['agenda'].queryset.exclude(
|
||||||
|
ants_place__place=self.instance.place
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.PlaceAgenda
|
||||||
|
exclude = ['place', 'setting']
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceAgendaAddView(PlaceMixin, CreateView):
|
||||||
|
template_name = 'chrono/manager_ants_hub_add_form.html'
|
||||||
|
model = models.PlaceAgenda
|
||||||
|
form_class = PlaceAgendaAddForm
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return _('New agenda for %s') % self.place
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
kwargs = super().get_form_kwargs()
|
||||||
|
kwargs['instance'] = models.PlaceAgenda(place=self.place)
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return f'../../#open-place-agenda-{self.object.pk}'
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceAgendaEditForm(forms.ModelForm):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.meeting_types = self.instance.agenda.meetingtype_set.order_by('label')
|
||||||
|
for meeting_type in self.instance.agenda.iter_meetingtypes():
|
||||||
|
field_meeting_type = forms.TypedMultipleChoiceField(
|
||||||
|
label=_('%s') % meeting_type.label,
|
||||||
|
choices=models.ANTSMeetingType.choices,
|
||||||
|
widget=forms.CheckboxSelectMultiple(attrs={'class': 'inline'}),
|
||||||
|
required=False,
|
||||||
|
initial=self.instance.get_meeting_type_setting(meeting_type, 'ants_meeting_type'),
|
||||||
|
)
|
||||||
|
field_meeting_type.meeting_type = meeting_type
|
||||||
|
field_meeting_type.key = 'ants_meeting_type'
|
||||||
|
field_persons_number = forms.TypedMultipleChoiceField(
|
||||||
|
label=_('%s') % meeting_type.label,
|
||||||
|
choices=models.ANTSPersonsNumber.choices,
|
||||||
|
widget=forms.CheckboxSelectMultiple(attrs={'class': 'inline'}),
|
||||||
|
required=False,
|
||||||
|
initial=self.instance.get_meeting_type_setting(meeting_type, 'ants_persons_number'),
|
||||||
|
)
|
||||||
|
field_persons_number.meeting_type = meeting_type
|
||||||
|
field_persons_number.key = 'ants_persons_number'
|
||||||
|
self.fields[f'mt_{meeting_type.slug}_1'] = field_meeting_type
|
||||||
|
self.fields[f'mt_{meeting_type.slug}_2'] = field_persons_number
|
||||||
|
|
||||||
|
def field_by_labels(self):
|
||||||
|
d = {}
|
||||||
|
for bound_field in self:
|
||||||
|
d.setdefault(bound_field.label, []).append(bound_field)
|
||||||
|
return list(d.items())
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
for key, field in self.fields.items():
|
||||||
|
value = self.cleaned_data.get(key, [])
|
||||||
|
self.instance.set_meeting_type_setting(field.meeting_type, field.key, value)
|
||||||
|
return self.cleaned_data
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.PlaceAgenda
|
||||||
|
fields = []
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceAgendaEditView(UpdateView):
|
||||||
|
template_name = 'chrono/manager_ants_hub_agenda_edit_form.html'
|
||||||
|
model = models.PlaceAgenda
|
||||||
|
form_class = PlaceAgendaEditForm
|
||||||
|
success_url = '../../../'
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceAgendaDeleteView(DeleteView):
|
||||||
|
template_name = 'chrono/manager_confirm_delete.html'
|
||||||
|
model = models.PlaceAgenda
|
||||||
|
success_url = '../../../'
|
||||||
|
|
||||||
|
|
||||||
|
class Synchronize(TemplateView):
|
||||||
|
template_name = 'chrono/manager_ants_hub_synchronize.html'
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
self.synchronize()
|
||||||
|
messages.info(request, _('Synchronization has been launched.'))
|
||||||
|
return redirect('chrono-manager-ants-hub')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def synchronize(cls):
|
||||||
|
from chrono.utils.spooler import ants_hub_city_push
|
||||||
|
|
||||||
|
ants_hub_city_push.spool()
|
|
@ -604,3 +604,46 @@ table.partial-bookings {
|
||||||
outline: solid 1px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block appbar %}
|
{% block appbar %}
|
||||||
<h2>{{ view.model.get_verbose_name }}</h2>
|
<h2>{% firstof object view.model.get_verbose_name %}</h2>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
|
@ -21,6 +21,9 @@
|
||||||
{% if user.is_staff %}
|
{% if user.is_staff %}
|
||||||
<li><a href="{% url 'chrono-manager-resource-list' %}">{% trans 'Resources' %}</a></li>
|
<li><a href="{% url 'chrono-manager-resource-list' %}">{% trans 'Resources' %}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if ants_hub_enabled and user.is_staff %}
|
||||||
|
<li><a href="{% url 'chrono-manager-ants-hub' %}">{% trans 'ANTS Hub' %}</a></li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if user.is_staff %}
|
{% if user.is_staff %}
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from django.urls import path, re_path
|
from django.urls import include, path, re_path
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
|
@ -510,4 +510,5 @@ urlpatterns = [
|
||||||
name='chrono-manager-shared-custody-agenda-delete-period',
|
name='chrono-manager-shared-custody-agenda-delete-period',
|
||||||
),
|
),
|
||||||
re_path(r'^menu.json$', views.menu_json),
|
re_path(r'^menu.json$', views.menu_json),
|
||||||
|
path('ants/', include('chrono.apps.ants_hub.urls')),
|
||||||
]
|
]
|
||||||
|
|
|
@ -174,6 +174,7 @@ class HomepageView(ListView):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context['has_access_to_unavailability_calendars'] = self.has_access_to_unavailability_calendars()
|
context['has_access_to_unavailability_calendars'] = self.has_access_to_unavailability_calendars()
|
||||||
context['shared_custody_enabled'] = settings.SHARED_CUSTODY_ENABLED
|
context['shared_custody_enabled'] = settings.SHARED_CUSTODY_ENABLED
|
||||||
|
context['ants_hub_enabled'] = bool(settings.CHRONO_ANTS_HUB_URL)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
|
|
|
@ -57,6 +57,7 @@ INSTALLED_APPS = (
|
||||||
'chrono.agendas',
|
'chrono.agendas',
|
||||||
'chrono.api',
|
'chrono.api',
|
||||||
'chrono.manager',
|
'chrono.manager',
|
||||||
|
'chrono.apps.ants_hub',
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
'django_filters',
|
'django_filters',
|
||||||
)
|
)
|
||||||
|
@ -201,6 +202,8 @@ SHARED_CUSTODY_ENABLED = False
|
||||||
LEGACY_FILLSLOTS_ENABLED = False
|
LEGACY_FILLSLOTS_ENABLED = False
|
||||||
PARTIAL_BOOKINGS_ENABLED = False
|
PARTIAL_BOOKINGS_ENABLED = False
|
||||||
|
|
||||||
|
CHRONO_ANTS_HUB_URL = None
|
||||||
|
|
||||||
local_settings_file = os.environ.get(
|
local_settings_file = os.environ.get(
|
||||||
'CHRONO_SETTINGS_FILE', os.path.join(os.path.dirname(__file__), 'local_settings.py')
|
'CHRONO_SETTINGS_FILE', os.path.join(os.path.dirname(__file__), 'local_settings.py')
|
||||||
)
|
)
|
||||||
|
|
|
@ -69,3 +69,17 @@ def event_notify_checked(args):
|
||||||
return
|
return
|
||||||
|
|
||||||
event.notify_checked()
|
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
|
||||||
|
|
|
@ -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 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 send_email_notifications --all-tenants -v0
|
||||||
cron2 = minute=-5,unique=1 /usr/bin/chrono-manage tenant_command update_event_recurrences --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
|
# hourly
|
||||||
cron2 = minute=3,unique=1 /usr/bin/chrono-manage tenant_command clearsessions --all-tenants
|
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
|
cron2 = minute=13,unique=1 /usr/bin/chrono-manage tenant_command send_booking_reminders --all-tenants
|
||||||
|
|
|
@ -0,0 +1,86 @@
|
||||||
|
# chrono - agendas system
|
||||||
|
# Copyright (C) 2023 Entr'ouvert
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU Affero General Public License as published
|
||||||
|
# by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import responses
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
from chrono.apps.ants_hub.models import City, Place, PlaceAgenda
|
||||||
|
from tests.manager.conftest import admin_user, simple_user # noqa pylint: disabled=unused-import
|
||||||
|
from tests.utils import build_agendas
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ants_settings(settings):
|
||||||
|
settings.CHRONO_ANTS_HUB_URL = 'https://toto:@ants-hub.example.com/api/chrono/'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def hub(ants_settings):
|
||||||
|
cache.delete('ants-hub-ok')
|
||||||
|
with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
|
||||||
|
rsps.add(responses.GET, 'https://toto:@ants-hub.example.com/api/chrono/ping/', json={'err': 0})
|
||||||
|
yield rsps
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def city():
|
||||||
|
return City.objects.create(
|
||||||
|
id=1, name='Newcity', logo_url='https://newcity.com/logo.png', url='https://newcity.com/rdv/'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def place(city):
|
||||||
|
return Place.objects.create(
|
||||||
|
id=1,
|
||||||
|
city=city,
|
||||||
|
name='Townhall',
|
||||||
|
address='221B Baker Street',
|
||||||
|
zipcode='13260',
|
||||||
|
city_name='Newcity',
|
||||||
|
longitude='2.3',
|
||||||
|
latitude='-40.3',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def agenda():
|
||||||
|
return build_agendas(
|
||||||
|
'''
|
||||||
|
meetings CNI
|
||||||
|
desk "Desk1"
|
||||||
|
timeperiod monday-friday 08:00-17:00
|
||||||
|
meeting-type 15
|
||||||
|
'''
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def place_agenda(place, agenda):
|
||||||
|
return PlaceAgenda.objects.create(
|
||||||
|
id=1,
|
||||||
|
place=place,
|
||||||
|
agenda=agenda,
|
||||||
|
setting={
|
||||||
|
'meeting-types': {
|
||||||
|
'mt-15': {
|
||||||
|
'ants_meeting_type': [1],
|
||||||
|
'ants_persons_number': [1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
|
@ -0,0 +1,70 @@
|
||||||
|
# chrono - agendas system
|
||||||
|
# Copyright (C) 2023 Entr'ouvert
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU Affero General Public License as published
|
||||||
|
# by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import responses
|
||||||
|
from django.core.management import call_command
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_ants_hub(db, hub, place_agenda, freezer):
|
||||||
|
freezer.move_to('2023-06-01T17:12:00+02:00')
|
||||||
|
response = hub.add(
|
||||||
|
responses.POST,
|
||||||
|
'https://toto:@ants-hub.example.com/api/chrono/rendez-vous-disponibles/',
|
||||||
|
json={'err': 0},
|
||||||
|
)
|
||||||
|
call_command('sync-ants-hub')
|
||||||
|
assert response.call_count == 1
|
||||||
|
payload = json.loads(hub.calls[-1].request.body)
|
||||||
|
assert len(payload['collectivites']) == 1
|
||||||
|
assert {**payload['collectivites'][0], 'lieux': None} == {
|
||||||
|
'annulation_url': '',
|
||||||
|
'full': True,
|
||||||
|
'gestion_url': '',
|
||||||
|
'id': '1',
|
||||||
|
'lieux': None,
|
||||||
|
'logo_url': 'https://newcity.com/logo.png',
|
||||||
|
'nom': 'Newcity',
|
||||||
|
'rdv_url': '',
|
||||||
|
'url': 'https://newcity.com/rdv/',
|
||||||
|
}
|
||||||
|
assert len(payload['collectivites'][0]['lieux']) == 1
|
||||||
|
assert {**payload['collectivites'][0]['lieux'][0], 'plages': None} == {
|
||||||
|
'annulation_url': '',
|
||||||
|
'code_postal': '13260',
|
||||||
|
'full': True,
|
||||||
|
'gestion_url': '',
|
||||||
|
'id': '1',
|
||||||
|
'latitude': -40.3,
|
||||||
|
'longitude': 2.3,
|
||||||
|
'nom': 'Townhall',
|
||||||
|
'numero_rue': '221B Baker Street',
|
||||||
|
'plages': None,
|
||||||
|
'rdv_url': '',
|
||||||
|
'rdvs': [],
|
||||||
|
'url': '',
|
||||||
|
'ville': 'Newcity',
|
||||||
|
}
|
||||||
|
assert len(payload['collectivites'][0]['lieux'][0]['plages']) == 39
|
||||||
|
assert payload['collectivites'][0]['lieux'][0]['plages'][0] == {
|
||||||
|
'date': '2023-06-02',
|
||||||
|
'duree': 15,
|
||||||
|
'heure_debut': '08:00:00',
|
||||||
|
'heure_fin': '17:00:00',
|
||||||
|
'personnes': 1,
|
||||||
|
'types_rdv': ['CNI'],
|
||||||
|
}
|
|
@ -0,0 +1,69 @@
|
||||||
|
# chrono - agendas system
|
||||||
|
# Copyright (C) 2023 Entr'ouvert
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU Affero General Public License as published
|
||||||
|
# by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
import responses
|
||||||
|
|
||||||
|
from chrono.apps.ants_hub.hub import AntsHubException, ping, push_rendez_vous_disponibles
|
||||||
|
|
||||||
|
|
||||||
|
def test_ping_timeout(hub):
|
||||||
|
hub.replace(
|
||||||
|
responses.GET, 'https://toto:@ants-hub.example.com/api/chrono/ping/', body=requests.Timeout('boom!')
|
||||||
|
)
|
||||||
|
ping()
|
||||||
|
|
||||||
|
|
||||||
|
def test_push_rendez_vous_disponibles_timeout(hub):
|
||||||
|
hub.add(
|
||||||
|
responses.POST,
|
||||||
|
'https://toto:@ants-hub.example.com/api/chrono/rendez-vous-disponibles/',
|
||||||
|
body=requests.Timeout('boom!'),
|
||||||
|
)
|
||||||
|
push_rendez_vous_disponibles({})
|
||||||
|
|
||||||
|
|
||||||
|
def test_ping_internal_server_error(hub):
|
||||||
|
hub.replace(responses.GET, 'https://toto:@ants-hub.example.com/api/chrono/ping/', status=500)
|
||||||
|
with pytest.raises(AntsHubException):
|
||||||
|
ping()
|
||||||
|
|
||||||
|
|
||||||
|
def test_push_rendez_vous_disponibles_internal_server_error(hub):
|
||||||
|
hub.add(
|
||||||
|
responses.POST, 'https://toto:@ants-hub.example.com/api/chrono/rendez-vous-disponibles/', status=500
|
||||||
|
)
|
||||||
|
with pytest.raises(AntsHubException):
|
||||||
|
push_rendez_vous_disponibles({})
|
||||||
|
|
||||||
|
|
||||||
|
def test_ping_application_error(hub):
|
||||||
|
hub.replace(
|
||||||
|
responses.GET, 'https://toto:@ants-hub.example.com/api/chrono/ping/', json={'err': 'overload'}
|
||||||
|
)
|
||||||
|
with pytest.raises(AntsHubException, match='overload'):
|
||||||
|
ping()
|
||||||
|
|
||||||
|
|
||||||
|
def test_push_rendez_vous_disponibles_application_error(hub):
|
||||||
|
hub.add(
|
||||||
|
responses.POST,
|
||||||
|
'https://toto:@ants-hub.example.com/api/chrono/rendez-vous-disponibles/',
|
||||||
|
json={'err': 'overload'},
|
||||||
|
)
|
||||||
|
with pytest.raises(AntsHubException, match='overload'):
|
||||||
|
push_rendez_vous_disponibles({})
|
|
@ -0,0 +1,192 @@
|
||||||
|
# chrono - agendas system
|
||||||
|
# Copyright (C) 2023 Entr'ouvert
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU Affero General Public License as published
|
||||||
|
# by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import responses
|
||||||
|
|
||||||
|
from chrono.apps.ants_hub.models import ANTSMeetingType, ANTSPersonsNumber, City, PlaceAgenda
|
||||||
|
from tests.utils import login
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_unconfigured(app, admin_user):
|
||||||
|
login(app)
|
||||||
|
resp = app.get('/manage/', status=200)
|
||||||
|
assert 'ANTS' not in resp
|
||||||
|
|
||||||
|
|
||||||
|
def test_unlogged(ants_settings, app):
|
||||||
|
app.get('/manage/ants/', status=302)
|
||||||
|
|
||||||
|
|
||||||
|
def test_configured(hub, app, admin_user):
|
||||||
|
login(app)
|
||||||
|
resp = app.get('/manage/', status=200)
|
||||||
|
resp = resp.click('ANTS')
|
||||||
|
assert 'ANTS Hub is responding' in resp
|
||||||
|
assert 'New city' in resp
|
||||||
|
|
||||||
|
|
||||||
|
def test_hub_is_down(hub, app, admin_user):
|
||||||
|
hub.replace(responses.GET, 'https://toto:@ants-hub.example.com/api/chrono/ping/', status=500)
|
||||||
|
login(app)
|
||||||
|
resp = app.get('/manage/', status=200)
|
||||||
|
resp = resp.click('ANTS')
|
||||||
|
assert 'ANTS Hub is down' in resp
|
||||||
|
|
||||||
|
|
||||||
|
def test_city_add(hub, app, admin_user):
|
||||||
|
login(app)
|
||||||
|
resp = app.get('/manage/ants/', status=200)
|
||||||
|
assert 'Newcity' not in resp
|
||||||
|
resp = resp.click('New city')
|
||||||
|
resp.form.set('name', 'Newcity')
|
||||||
|
resp.form.set('url', 'https://newcity.com/rdv/')
|
||||||
|
resp.form.set('logo_url', 'https://newcity.com/logo.png')
|
||||||
|
resp = resp.form.submit().follow()
|
||||||
|
assert 'Newcity' in resp
|
||||||
|
|
||||||
|
|
||||||
|
def test_city_edit(hub, app, admin_user, city):
|
||||||
|
login(app)
|
||||||
|
resp = app.get('/manage/ants/')
|
||||||
|
resp = resp.click(href='edit')
|
||||||
|
assert 'logo.png' in resp
|
||||||
|
|
||||||
|
|
||||||
|
def test_city_delete(hub, app, admin_user, city):
|
||||||
|
login(app)
|
||||||
|
resp = app.get('/manage/ants/')
|
||||||
|
resp = resp.click(href='delete')
|
||||||
|
resp = resp.form.submit(status=302)
|
||||||
|
assert City.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_place(hub, app, admin_user, city):
|
||||||
|
login(app)
|
||||||
|
resp = app.get('/manage/ants/')
|
||||||
|
resp = resp.click('Add place')
|
||||||
|
resp.form.set('name', 'Townhall')
|
||||||
|
resp.form.set('address', '221B Baker Street')
|
||||||
|
resp.form.set('zipcode', '13260')
|
||||||
|
resp.form.set('city_name', 'Newcity')
|
||||||
|
resp.form.set('longitude', '2.3')
|
||||||
|
resp.form.set('latitude', '-40.3')
|
||||||
|
assert city.places.count() == 0
|
||||||
|
resp = resp.form.submit().follow()
|
||||||
|
assert city.places.count() == 1
|
||||||
|
assert 'Newcity' in resp
|
||||||
|
assert 'Townhall' in resp
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_place(hub, app, admin_user, city, place):
|
||||||
|
login(app)
|
||||||
|
resp = app.get('/manage/ants/')
|
||||||
|
resp = resp.click('Townhall')
|
||||||
|
resp = resp.click('Edit', href='edit')
|
||||||
|
assert 'Baker Street' in resp
|
||||||
|
resp.form.set('address', 'Downing Street')
|
||||||
|
resp = resp.form.submit().follow()
|
||||||
|
assert 'Downing Street' in resp
|
||||||
|
place.refresh_from_db()
|
||||||
|
assert place.address == 'Downing Street'
|
||||||
|
|
||||||
|
|
||||||
|
def test_url_edit_place(hub, app, admin_user, city, place):
|
||||||
|
login(app)
|
||||||
|
resp = app.get('/manage/ants/')
|
||||||
|
resp = resp.click('Townhall')
|
||||||
|
assert 'https://townhall.example.com/rdv/' not in resp
|
||||||
|
resp = resp.click('Edit', href='url')
|
||||||
|
resp.form.set('url', 'https://townhall.example.com/rdv/')
|
||||||
|
resp = resp.form.submit().follow()
|
||||||
|
assert 'https://townhall.example.com/rdv/' in resp
|
||||||
|
place.refresh_from_db()
|
||||||
|
assert place.url == 'https://townhall.example.com/rdv/'
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_place(hub, app, admin_user, city, place):
|
||||||
|
login(app)
|
||||||
|
resp = app.get('/manage/ants/')
|
||||||
|
resp = resp.click('Townhall')
|
||||||
|
resp = resp.click('Remove')
|
||||||
|
assert city.places.count() == 1
|
||||||
|
resp = resp.form.submit().follow()
|
||||||
|
assert city.places.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_agenda(hub, app, admin_user, city, place, agenda, freezer):
|
||||||
|
freezer.move_to('2023-06-01T17:12:00+02:00')
|
||||||
|
login(app)
|
||||||
|
resp = app.get('/manage/ants/')
|
||||||
|
resp = resp.click('Townhall')
|
||||||
|
resp = resp.click('Add')
|
||||||
|
resp.form.set('agenda', str(agenda.pk))
|
||||||
|
assert not place.agendas.exists()
|
||||||
|
resp = resp.form.submit().follow()
|
||||||
|
place_agenda = place.agendas.get()
|
||||||
|
assert list(place_agenda.iter_open_dates()) == []
|
||||||
|
assert not place_agenda.setting.get('meeting-types')
|
||||||
|
assert '(not configured)' in resp
|
||||||
|
assert '1 person' not in resp
|
||||||
|
resp = resp.click(href='agenda.*edit')
|
||||||
|
# make the meeting-type of 15 minutes correpond to a meeting to get a CNI
|
||||||
|
# for 1 person
|
||||||
|
resp.form.set('mt_mt-15_1', [str(ANTSMeetingType.CNI)])
|
||||||
|
resp.form.set('mt_mt-15_2', [str(ANTSPersonsNumber.ONE)])
|
||||||
|
resp = resp.form.submit().follow()
|
||||||
|
place_agenda.refresh_from_db()
|
||||||
|
assert place_agenda.setting.get('meeting-types')
|
||||||
|
assert '(not configured)' not in resp
|
||||||
|
assert '(CNI, 1 person)' in resp
|
||||||
|
assert place_agenda.setting == {
|
||||||
|
'meeting-types': {'mt-15': {'ants_meeting_type': [1], 'ants_persons_number': [1]}}
|
||||||
|
}
|
||||||
|
|
||||||
|
open_dates = list(place_agenda.iter_open_dates())
|
||||||
|
assert len(open_dates) == 39
|
||||||
|
assert open_dates[0] == {
|
||||||
|
'date': '2023-06-02',
|
||||||
|
'heure_debut': '08:00:00',
|
||||||
|
'heure_fin': '17:00:00',
|
||||||
|
'duree': 15,
|
||||||
|
'personnes': 1,
|
||||||
|
'types_rdv': ['CNI'],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_agenda(hub, app, admin_user, city, place, agenda, place_agenda):
|
||||||
|
login(app)
|
||||||
|
resp = app.get('/manage/ants/')
|
||||||
|
resp = resp.click('Townhall')
|
||||||
|
resp = resp.click(href='agenda.*delete')
|
||||||
|
resp = resp.form.submit().follow()
|
||||||
|
assert 'CNI' not in resp
|
||||||
|
assert PlaceAgenda.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_synchronize(hub, app, admin_user, city, place, agenda, place_agenda, freezer):
|
||||||
|
freezer.move_to('2023-06-01T17:12:00+02:00')
|
||||||
|
login(app)
|
||||||
|
resp = app.get('/manage/', status=200)
|
||||||
|
resp = resp.click('ANTS')
|
||||||
|
resp = resp.click('Synchronize')
|
||||||
|
with mock.patch('chrono.apps.ants_hub.views.Synchronize.synchronize') as method:
|
||||||
|
resp = resp.form.submit().follow()
|
||||||
|
assert method.called
|
|
@ -0,0 +1,616 @@
|
||||||
|
# chrono - agendas system
|
||||||
|
# Copyright (C) 2023 Entr'ouvert
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU Affero General Public License as published
|
||||||
|
# by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from chrono.apps.ants_hub.models import City, Place, PlaceAgenda
|
||||||
|
from tests.utils import add_meeting, build_meetings_agenda, build_virtual_agenda, paris
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def ants_setup(db, freezer):
|
||||||
|
freezer.move_to(paris('2023-04-07 17:32'))
|
||||||
|
|
||||||
|
class Namespace:
|
||||||
|
durations = [15, 30, 45, 60]
|
||||||
|
mairie_agenda = build_meetings_agenda(
|
||||||
|
label='cni_passport_mairie',
|
||||||
|
meeting_types=durations,
|
||||||
|
desks=('desk1', 'monday-friday 9:00-12:00 14:00-17:00'),
|
||||||
|
maximal_booking_delay=10,
|
||||||
|
)
|
||||||
|
annexe_agenda = build_virtual_agenda(
|
||||||
|
label='annexe',
|
||||||
|
agendas={
|
||||||
|
'Agenda 1': {
|
||||||
|
'desks': ('Bureau 1', 'monday-wednesday 08:00-12:00'),
|
||||||
|
'meeting_types': durations,
|
||||||
|
},
|
||||||
|
'Agenda 2': {
|
||||||
|
'desks': ('Bureau 2', 'thursday-friday 14:00-17:00'),
|
||||||
|
'meeting_types': durations,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
maximal_booking_delay=5,
|
||||||
|
)
|
||||||
|
city = City.objects.create(
|
||||||
|
id=1,
|
||||||
|
name='Saint-Didier',
|
||||||
|
url='https://saint-didier.fr/',
|
||||||
|
)
|
||||||
|
mairie = Place.objects.create(
|
||||||
|
id=1,
|
||||||
|
city=city,
|
||||||
|
name='Mairie',
|
||||||
|
address='2 rue du four',
|
||||||
|
zipcode='99999',
|
||||||
|
city_name='Saint-Didier',
|
||||||
|
)
|
||||||
|
annexe = Place.objects.create(
|
||||||
|
id=2,
|
||||||
|
city=city,
|
||||||
|
name='Mairie annexe',
|
||||||
|
address='3 rue du four',
|
||||||
|
zipcode='99999',
|
||||||
|
city_name='Saint-Didier',
|
||||||
|
)
|
||||||
|
PlaceAgenda.objects.create(
|
||||||
|
place=mairie,
|
||||||
|
agenda=mairie_agenda,
|
||||||
|
setting={
|
||||||
|
'meeting-types': {
|
||||||
|
'mt-15': {
|
||||||
|
'ants_meeting_type': [1, 2],
|
||||||
|
'ants_persons_number': [1],
|
||||||
|
},
|
||||||
|
'mt-30': {
|
||||||
|
'ants_meeting_type': [1, 2],
|
||||||
|
'ants_persons_number': [2],
|
||||||
|
},
|
||||||
|
'mt-45': {
|
||||||
|
'ants_meeting_type': [1, 2],
|
||||||
|
'ants_persons_number': [3],
|
||||||
|
},
|
||||||
|
'mt-60': {
|
||||||
|
'ants_meeting_type': [1, 2],
|
||||||
|
'ants_persons_number': [4],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
PlaceAgenda.objects.create(
|
||||||
|
place=annexe,
|
||||||
|
agenda=annexe_agenda,
|
||||||
|
setting={
|
||||||
|
'meeting-types': {
|
||||||
|
'mt-15': {
|
||||||
|
'ants_meeting_type': [1, 2],
|
||||||
|
'ants_persons_number': [1],
|
||||||
|
},
|
||||||
|
'mt-30': {
|
||||||
|
'ants_meeting_type': [1, 2],
|
||||||
|
'ants_persons_number': [2],
|
||||||
|
},
|
||||||
|
'mt-45': {
|
||||||
|
'ants_meeting_type': [1, 2],
|
||||||
|
'ants_persons_number': [3],
|
||||||
|
},
|
||||||
|
'mt-60': {
|
||||||
|
'ants_meeting_type': [1, 2],
|
||||||
|
'ants_persons_number': [4],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
add_meeting(
|
||||||
|
mairie_agenda,
|
||||||
|
paris('2023-04-10 09:00'),
|
||||||
|
cancellation_datetime=paris('2023-04-08 13:15'),
|
||||||
|
meeting_type='mt-15',
|
||||||
|
extra_data={'ants_identifiant_predemande': '12345678'},
|
||||||
|
)
|
||||||
|
add_meeting(
|
||||||
|
mairie_agenda,
|
||||||
|
paris('2023-04-11 11:00'),
|
||||||
|
meeting_type='mt-30',
|
||||||
|
extra_data={'ants_identifiant_predemande': 'ABCDEFGH'},
|
||||||
|
)
|
||||||
|
|
||||||
|
add_meeting(
|
||||||
|
annexe_agenda._agenda_1,
|
||||||
|
paris('2023-04-10 10:00'),
|
||||||
|
cancellation_datetime=paris('2023-04-08 13:15'),
|
||||||
|
meeting_type='mt-45',
|
||||||
|
extra_data={'ants_identifiant_predemande': '1234ABCD'},
|
||||||
|
)
|
||||||
|
add_meeting(
|
||||||
|
annexe_agenda._agenda_1,
|
||||||
|
paris('2023-04-10 11:00'),
|
||||||
|
meeting_type='mt-45',
|
||||||
|
extra_data={'ants_identifiant_predemande': 'XYZ12JKL'},
|
||||||
|
)
|
||||||
|
add_meeting(
|
||||||
|
annexe_agenda._agenda_2,
|
||||||
|
paris('2023-04-11 11:00'),
|
||||||
|
meeting_type='mt-60',
|
||||||
|
extra_data={'ants_identifiant_predemande': 'ABCD1234'},
|
||||||
|
)
|
||||||
|
|
||||||
|
return Namespace
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_to_push(ants_setup):
|
||||||
|
assert ants_setup.city.export_to_push() == {
|
||||||
|
'full': True,
|
||||||
|
'id': '1',
|
||||||
|
'nom': 'Saint-Didier',
|
||||||
|
'url': 'https://saint-didier.fr/',
|
||||||
|
'logo_url': '',
|
||||||
|
'rdv_url': '',
|
||||||
|
'annulation_url': '',
|
||||||
|
'gestion_url': '',
|
||||||
|
'lieux': [
|
||||||
|
{
|
||||||
|
'full': True,
|
||||||
|
'id': '1',
|
||||||
|
'nom': 'Mairie',
|
||||||
|
'numero_rue': '2 rue du four',
|
||||||
|
'code_postal': '99999',
|
||||||
|
'ville': 'Saint-Didier',
|
||||||
|
'latitude': 46.596,
|
||||||
|
'longitude': 2.476,
|
||||||
|
'url': '',
|
||||||
|
'rdv_url': '',
|
||||||
|
'gestion_url': '',
|
||||||
|
'annulation_url': '',
|
||||||
|
'rdvs': [
|
||||||
|
{'annule': True, 'date': '2023-04-10T07:00:00+00:00', 'id': '12345678'},
|
||||||
|
{'date': '2023-04-11T09:00:00+00:00', 'id': 'ABCDEFGH'},
|
||||||
|
],
|
||||||
|
'plages': [
|
||||||
|
{
|
||||||
|
'date': '2023-04-10',
|
||||||
|
'duree': 15,
|
||||||
|
'heure_debut': '09:00:00',
|
||||||
|
'heure_fin': '12:00:00',
|
||||||
|
'personnes': 1,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-10',
|
||||||
|
'duree': 30,
|
||||||
|
'heure_debut': '09:00:00',
|
||||||
|
'heure_fin': '12:00:00',
|
||||||
|
'personnes': 2,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-10',
|
||||||
|
'duree': 45,
|
||||||
|
'heure_debut': '09:00:00',
|
||||||
|
'heure_fin': '12:00:00',
|
||||||
|
'personnes': 3,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-10',
|
||||||
|
'duree': 60,
|
||||||
|
'heure_debut': '09:00:00',
|
||||||
|
'heure_fin': '12:00:00',
|
||||||
|
'personnes': 4,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-10',
|
||||||
|
'duree': 15,
|
||||||
|
'heure_debut': '14:00:00',
|
||||||
|
'heure_fin': '17:00:00',
|
||||||
|
'personnes': 1,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-10',
|
||||||
|
'duree': 30,
|
||||||
|
'heure_debut': '14:00:00',
|
||||||
|
'heure_fin': '17:00:00',
|
||||||
|
'personnes': 2,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-10',
|
||||||
|
'duree': 45,
|
||||||
|
'heure_debut': '14:00:00',
|
||||||
|
'heure_fin': '17:00:00',
|
||||||
|
'personnes': 3,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-10',
|
||||||
|
'duree': 60,
|
||||||
|
'heure_debut': '14:00:00',
|
||||||
|
'heure_fin': '17:00:00',
|
||||||
|
'personnes': 4,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-11',
|
||||||
|
'duree': 15,
|
||||||
|
'heure_debut': '09:00:00',
|
||||||
|
'heure_fin': '11:00:00',
|
||||||
|
'personnes': 1,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-11',
|
||||||
|
'duree': 30,
|
||||||
|
'heure_debut': '09:00:00',
|
||||||
|
'heure_fin': '11:00:00',
|
||||||
|
'personnes': 2,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-11',
|
||||||
|
'duree': 45,
|
||||||
|
'heure_debut': '09:00:00',
|
||||||
|
'heure_fin': '11:00:00',
|
||||||
|
'personnes': 3,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-11',
|
||||||
|
'duree': 60,
|
||||||
|
'heure_debut': '09:00:00',
|
||||||
|
'heure_fin': '11:00:00',
|
||||||
|
'personnes': 4,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-11',
|
||||||
|
'duree': 15,
|
||||||
|
'heure_debut': '11:30:00',
|
||||||
|
'heure_fin': '12:00:00',
|
||||||
|
'personnes': 1,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-11',
|
||||||
|
'duree': 30,
|
||||||
|
'heure_debut': '11:30:00',
|
||||||
|
'heure_fin': '12:00:00',
|
||||||
|
'personnes': 2,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-11',
|
||||||
|
'duree': 15,
|
||||||
|
'heure_debut': '14:00:00',
|
||||||
|
'heure_fin': '17:00:00',
|
||||||
|
'personnes': 1,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-11',
|
||||||
|
'duree': 30,
|
||||||
|
'heure_debut': '14:00:00',
|
||||||
|
'heure_fin': '17:00:00',
|
||||||
|
'personnes': 2,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-11',
|
||||||
|
'duree': 45,
|
||||||
|
'heure_debut': '14:00:00',
|
||||||
|
'heure_fin': '17:00:00',
|
||||||
|
'personnes': 3,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-11',
|
||||||
|
'duree': 60,
|
||||||
|
'heure_debut': '14:00:00',
|
||||||
|
'heure_fin': '17:00:00',
|
||||||
|
'personnes': 4,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-12',
|
||||||
|
'duree': 15,
|
||||||
|
'heure_debut': '09:00:00',
|
||||||
|
'heure_fin': '12:00:00',
|
||||||
|
'personnes': 1,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-12',
|
||||||
|
'duree': 30,
|
||||||
|
'heure_debut': '09:00:00',
|
||||||
|
'heure_fin': '12:00:00',
|
||||||
|
'personnes': 2,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-12',
|
||||||
|
'duree': 45,
|
||||||
|
'heure_debut': '09:00:00',
|
||||||
|
'heure_fin': '12:00:00',
|
||||||
|
'personnes': 3,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-12',
|
||||||
|
'duree': 60,
|
||||||
|
'heure_debut': '09:00:00',
|
||||||
|
'heure_fin': '12:00:00',
|
||||||
|
'personnes': 4,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-12',
|
||||||
|
'duree': 15,
|
||||||
|
'heure_debut': '14:00:00',
|
||||||
|
'heure_fin': '17:00:00',
|
||||||
|
'personnes': 1,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-12',
|
||||||
|
'duree': 30,
|
||||||
|
'heure_debut': '14:00:00',
|
||||||
|
'heure_fin': '17:00:00',
|
||||||
|
'personnes': 2,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-12',
|
||||||
|
'duree': 45,
|
||||||
|
'heure_debut': '14:00:00',
|
||||||
|
'heure_fin': '17:00:00',
|
||||||
|
'personnes': 3,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-12',
|
||||||
|
'duree': 60,
|
||||||
|
'heure_debut': '14:00:00',
|
||||||
|
'heure_fin': '17:00:00',
|
||||||
|
'personnes': 4,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-13',
|
||||||
|
'duree': 15,
|
||||||
|
'heure_debut': '09:00:00',
|
||||||
|
'heure_fin': '12:00:00',
|
||||||
|
'personnes': 1,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-13',
|
||||||
|
'duree': 30,
|
||||||
|
'heure_debut': '09:00:00',
|
||||||
|
'heure_fin': '12:00:00',
|
||||||
|
'personnes': 2,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-13',
|
||||||
|
'duree': 45,
|
||||||
|
'heure_debut': '09:00:00',
|
||||||
|
'heure_fin': '12:00:00',
|
||||||
|
'personnes': 3,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-13',
|
||||||
|
'duree': 60,
|
||||||
|
'heure_debut': '09:00:00',
|
||||||
|
'heure_fin': '12:00:00',
|
||||||
|
'personnes': 4,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-13',
|
||||||
|
'duree': 15,
|
||||||
|
'heure_debut': '14:00:00',
|
||||||
|
'heure_fin': '17:00:00',
|
||||||
|
'personnes': 1,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-13',
|
||||||
|
'duree': 30,
|
||||||
|
'heure_debut': '14:00:00',
|
||||||
|
'heure_fin': '17:00:00',
|
||||||
|
'personnes': 2,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-13',
|
||||||
|
'duree': 45,
|
||||||
|
'heure_debut': '14:00:00',
|
||||||
|
'heure_fin': '17:00:00',
|
||||||
|
'personnes': 3,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-13',
|
||||||
|
'duree': 60,
|
||||||
|
'heure_debut': '14:00:00',
|
||||||
|
'heure_fin': '17:00:00',
|
||||||
|
'personnes': 4,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-14',
|
||||||
|
'duree': 15,
|
||||||
|
'heure_debut': '09:00:00',
|
||||||
|
'heure_fin': '12:00:00',
|
||||||
|
'personnes': 1,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-14',
|
||||||
|
'duree': 30,
|
||||||
|
'heure_debut': '09:00:00',
|
||||||
|
'heure_fin': '12:00:00',
|
||||||
|
'personnes': 2,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-14',
|
||||||
|
'duree': 45,
|
||||||
|
'heure_debut': '09:00:00',
|
||||||
|
'heure_fin': '12:00:00',
|
||||||
|
'personnes': 3,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-14',
|
||||||
|
'duree': 60,
|
||||||
|
'heure_debut': '09:00:00',
|
||||||
|
'heure_fin': '12:00:00',
|
||||||
|
'personnes': 4,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-14',
|
||||||
|
'duree': 15,
|
||||||
|
'heure_debut': '14:00:00',
|
||||||
|
'heure_fin': '17:00:00',
|
||||||
|
'personnes': 1,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-14',
|
||||||
|
'duree': 30,
|
||||||
|
'heure_debut': '14:00:00',
|
||||||
|
'heure_fin': '17:00:00',
|
||||||
|
'personnes': 2,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-14',
|
||||||
|
'duree': 45,
|
||||||
|
'heure_debut': '14:00:00',
|
||||||
|
'heure_fin': '17:00:00',
|
||||||
|
'personnes': 3,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-14',
|
||||||
|
'duree': 60,
|
||||||
|
'heure_debut': '14:00:00',
|
||||||
|
'heure_fin': '17:00:00',
|
||||||
|
'personnes': 4,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'full': True,
|
||||||
|
'id': '2',
|
||||||
|
'nom': 'Mairie annexe',
|
||||||
|
'numero_rue': '3 rue du four',
|
||||||
|
'code_postal': '99999',
|
||||||
|
'ville': 'Saint-Didier',
|
||||||
|
'latitude': 46.596,
|
||||||
|
'longitude': 2.476,
|
||||||
|
'url': '',
|
||||||
|
'rdv_url': '',
|
||||||
|
'gestion_url': '',
|
||||||
|
'annulation_url': '',
|
||||||
|
'rdvs': [
|
||||||
|
{'annule': True, 'date': '2023-04-10T08:00:00+00:00', 'id': '1234ABCD'},
|
||||||
|
{'date': '2023-04-10T09:00:00+00:00', 'id': 'XYZ12JKL'},
|
||||||
|
{'date': '2023-04-11T09:00:00+00:00', 'id': 'ABCD1234'},
|
||||||
|
],
|
||||||
|
'plages': [
|
||||||
|
{
|
||||||
|
'date': '2023-04-10',
|
||||||
|
'duree': 15,
|
||||||
|
'heure_debut': '08:00:00',
|
||||||
|
'heure_fin': '11:00:00',
|
||||||
|
'personnes': 1,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-10',
|
||||||
|
'duree': 30,
|
||||||
|
'heure_debut': '08:00:00',
|
||||||
|
'heure_fin': '11:00:00',
|
||||||
|
'personnes': 2,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-10',
|
||||||
|
'duree': 45,
|
||||||
|
'heure_debut': '08:00:00',
|
||||||
|
'heure_fin': '11:00:00',
|
||||||
|
'personnes': 3,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-10',
|
||||||
|
'duree': 60,
|
||||||
|
'heure_debut': '08:00:00',
|
||||||
|
'heure_fin': '11:00:00',
|
||||||
|
'personnes': 4,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-10',
|
||||||
|
'duree': 15,
|
||||||
|
'heure_debut': '11:45:00',
|
||||||
|
'heure_fin': '12:00:00',
|
||||||
|
'personnes': 1,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-11',
|
||||||
|
'duree': 15,
|
||||||
|
'heure_debut': '08:00:00',
|
||||||
|
'heure_fin': '12:00:00',
|
||||||
|
'personnes': 1,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-11',
|
||||||
|
'duree': 30,
|
||||||
|
'heure_debut': '08:00:00',
|
||||||
|
'heure_fin': '12:00:00',
|
||||||
|
'personnes': 2,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-11',
|
||||||
|
'duree': 45,
|
||||||
|
'heure_debut': '08:00:00',
|
||||||
|
'heure_fin': '12:00:00',
|
||||||
|
'personnes': 3,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '2023-04-11',
|
||||||
|
'duree': 60,
|
||||||
|
'heure_debut': '08:00:00',
|
||||||
|
'heure_fin': '12:00:00',
|
||||||
|
'personnes': 4,
|
||||||
|
'types_rdv': ['CNI', 'PASSPORT'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
|
@ -321,7 +321,7 @@ def add_exception_source(target: typing.Union[Desk, Agenda], ics_filename: str,
|
||||||
def add_meeting(
|
def add_meeting(
|
||||||
target: typing.Union[Desk, Agenda],
|
target: typing.Union[Desk, Agenda],
|
||||||
start_datetime: datetime.datetime,
|
start_datetime: datetime.datetime,
|
||||||
meeting_type: MeetingType = None,
|
meeting_type: typing.Union[str, MeetingType] = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
if isinstance(target, Desk):
|
if isinstance(target, Desk):
|
||||||
|
@ -332,6 +332,8 @@ def add_meeting(
|
||||||
|
|
||||||
if meeting_type is None:
|
if meeting_type is None:
|
||||||
meeting_type = desk.agenda.meetingtype_set.get()
|
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(
|
event = Event.objects.create(
|
||||||
agenda=desk.agenda,
|
agenda=desk.agenda,
|
||||||
|
|
2
tox.ini
2
tox.ini
|
@ -35,6 +35,7 @@ deps =
|
||||||
django32: psycopg2-binary>=2.9
|
django32: psycopg2-binary>=2.9
|
||||||
codestyle: pre-commit
|
codestyle: pre-commit
|
||||||
git+https://git.entrouvert.org/publik-django-templatetags.git
|
git+https://git.entrouvert.org/publik-django-templatetags.git
|
||||||
|
responses
|
||||||
commands =
|
commands =
|
||||||
./getlasso3.sh
|
./getlasso3.sh
|
||||||
python3 setup.py compile_translations
|
python3 setup.py compile_translations
|
||||||
|
@ -60,6 +61,7 @@ deps =
|
||||||
pytest-freezegun
|
pytest-freezegun
|
||||||
psycopg2-binary<2.9
|
psycopg2-binary<2.9
|
||||||
git+https://git.entrouvert.org/publik-django-templatetags.git
|
git+https://git.entrouvert.org/publik-django-templatetags.git
|
||||||
|
responses
|
||||||
commands =
|
commands =
|
||||||
./getlasso3.sh
|
./getlasso3.sh
|
||||||
pylint: ./pylint.sh chrono/ tests/
|
pylint: ./pylint.sh chrono/ tests/
|
||||||
|
|
Loading…
Reference in New Issue