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

It manages synchronization of meetings agendas with the ANTS hub.
This commit is contained in:
Benjamin Dauvergne 2023-04-05 15:48:01 +02:00
parent 730a9a0233
commit 61562ffb4e
27 changed files with 1867 additions and 4 deletions

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

View File

View File

@ -0,0 +1,67 @@
# 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()
return response.json()['err'] == 0
except requests.Timeout:
return True
except 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()
return response.json()
except requests.Timeout:
return True
except requests.RequestException as e:
raise AntsHubException(str(e))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,80 @@
{% 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>
{% if view.place.url_details %}
<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>
{% endif %}
<div class="section">
<h3>
{% trans "Agendas" %}
<a rel="popup" class="button" href="{% url 'chrono-manager-ants-hub-agenda-add' city_pk=view.place.city_id pk=view.place.pk %}">{% trans 'Add' %}</a>
</h3>
{% if object_list %}
<ul class="objects-list single-links">
{% for object in object_list %}
<li {% if not object.ants_properties %}class="ants-setting-not-configured"{% endif %}>
<a rel="popup" id="open-place-agenda-{{ object.pk }}" class="edit" href="{% url 'chrono-manager-ants-hub-agenda-edit' pk=object.pk city_pk=view.place.city_id place_pk=view.place.pk %}">
<span class="label">{{ object }}</span>
<span class="properties">({% if object.ants_properties %}{{ object.ants_properties|join:", " }}{% else %}{% trans "not configured" %}{% endif %})</span></a>
<a class="delete" rel="popup" href="{% url 'chrono-manager-ants-hub-agenda-delete' pk=object.pk city_pk=view.place.city_id place_pk=view.place.pk %}">{% trans "remove" %}</a>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% if not object_list %}
<div class="big-msg-info">
{% blocktrans trimmed %}
This site doesn't have any agenda yet. Click on the "New place" button in the top
right of the page to add a first one.
{% endblocktrans %}
</div>
{% endif %}
<script>
setTimeout(function () {
$(window.location.hash).click();
}, 100);
</script>
{% endblock %}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,236 @@
# 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 chrono.utils import spooler
from . import api, 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 ok is not True:
try:
api.ping()
messages.info(self.request, _('ANTS Hub is responding.'))
cache.set('ants-hub-ok', True, 600)
except api.AntsHubException as e:
messages.warning(self.request, _('ANTS Hub is down: "%s".') % e)
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):
spooler.run(models.City.push)
messages.info(request, _('Synchronization has been launched.'))
return redirect('chrono-manager-ants-hub')

View File

@ -595,3 +595,47 @@ span.togglable {
.extra-user-block {
padding-left: 2em;
}
.icon-edit:before { content: "\f044"; }
/* ants-hub */
ul.objects-list.single-links li.ants-setting-not-configured a.edit {
color: red;
}
.ants-setting {
.meeting-type {
vertical-align: top;
text-align: center;
}
ul.inline {
display: flex;
width: 40em;
margin: 1ex;
margin-block-start: 0em;
padding-inline-start: 0em;
}
ul.inline li {
flex: 1;
text-align: center;
border: 1px solid grey;
border-width: 1px 0px 1px 1px;
list-style: none;
}
ul.inline li:first-child {
border-radius: 5px 0px 0px 5px;
}
li label {
width: 100%;
display: inline-block;
}
ul.inline li:last-child {
border-radius: 0px 5px 5px 0px;
border-width: 1px 1px 1px 1px;
}
ul.inline input {
display: none;
}
label.checked {
background: lightblue;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -57,6 +57,7 @@ INSTALLED_APPS = (
'chrono.agendas',
'chrono.api',
'chrono.manager',
'chrono.apps.ants_hub',
'rest_framework',
'django_filters',
)
@ -199,6 +200,8 @@ REST_FRAMEWORK = {'EXCEPTION_HANDLER': 'chrono.api.utils.exception_handler'}
SHARED_CUSTODY_ENABLED = False
CHRONO_ANTS_HUB_URL = None
local_settings_file = os.environ.get(
'CHRONO_SETTINGS_FILE', os.path.join(os.path.dirname(__file__), 'local_settings.py')
)

1
debian/uwsgi.ini vendored
View File

@ -25,6 +25,7 @@ cron2 = minute=-5,unique=1 /usr/bin/chrono-manage tenant_command update_event_re
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=23,unique=1 /usr/bin/chrono-manage tenant_command sync_desks_timeperiod_exceptions --all-tenants
cron2 = minute=33,unique=1 /usr/bin/chrono-manage tenant_command sync-ants-hub --all-tenants
# daily
cron2 = minute=33,hour=4,unique=1 /usr/bin/chrono-manage tenant_command anonymize_bookings --all-tenants
# monthly

View File

@ -0,0 +1,619 @@
# 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_meeting_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_meeting_agenda(
label='cni_passport_mairie', durations=durations, agenda_kwargs={'maximal_booking_delay': 10}
)
annexe_agenda = build_virtual_agenda(
label='annexe',
agendas=[
{
'label': 'Agenda 1',
'desks': {
'desk': ['monday-wednesday;08:00-12:00'],
},
'durations': durations,
},
{
'label': 'Agenda 2',
'desks': {
'desk': ['thursday-friday;14:00-17:00'],
},
'durations': durations,
},
],
agenda_kwargs={'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-1': {
'ants_meeting_type': [1, 2],
'ants_persons_number': [1],
},
'mt-2': {
'ants_meeting_type': [1, 2],
'ants_persons_number': [2],
},
'mt-3': {
'ants_meeting_type': [1, 2],
'ants_persons_number': [3],
},
'mt-4': {
'ants_meeting_type': [1, 2],
'ants_persons_number': [4],
},
}
},
)
PlaceAgenda.objects.create(
place=annexe,
agenda=annexe_agenda,
setting={
'meeting-types': {
'mt-1': {
'ants_meeting_type': [1, 2],
'ants_persons_number': [1],
},
'mt-2': {
'ants_meeting_type': [1, 2],
'ants_persons_number': [2],
},
'mt-3': {
'ants_meeting_type': [1, 2],
'ants_persons_number': [3],
},
'mt-4': {
'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-1',
extra_data={'ants_identifiant_predemande': '12345678'},
)
add_meeting(
mairie_agenda,
paris('2023-04-11 11:00'),
meeting_type='mt-2',
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-3',
extra_data={'ants_identifiant_predemande': '1234ABCD'},
)
add_meeting(
annexe_agenda._agenda_1,
paris('2023-04-10 11:00'),
meeting_type='mt-3',
extra_data={'ants_identifiant_predemande': 'XYZ12JKL'},
)
add_meeting(
annexe_agenda._agenda_2,
paris('2023-04-11 11:00'),
meeting_type='mt-4',
extra_data={'ants_identifiant_predemande': 'ABCD1234'},
)
return Namespace
def test_export_to_push(ants_setup):
assert ants_setup.city.export_to_push() == {
'full': True,
'id': '1',
'nom': 'Saint-Didier',
'url': 'https://saint-didier.fr/',
'logo_url': '',
'rdv_url': '',
'annulation_url': '',
'gestion_url': '',
'lieux': [
{
'full': True,
'id': '1',
'nom': 'Mairie',
'numero_rue': '2 rue du four',
'code_postal': '99999',
'ville': 'Saint-Didier',
'latitude': 46.596,
'longitude': 2.476,
'url': '',
'rdv_url': '',
'gestion_url': '',
'annulation_url': '',
'rdvs': [
{'annule': True, 'date': '2023-04-10T07:00:00+00:00', 'id': '12345678'},
{'date': '2023-04-11T09:00:00+00:00', 'id': 'ABCDEFGH'},
],
'plages': [
{
'date': '2023-04-10',
'duree': 15,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-10',
'duree': 30,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-10',
'duree': 45,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 3,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-10',
'duree': 60,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 4,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-10',
'duree': 15,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-10',
'duree': 30,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-10',
'duree': 45,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 3,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-10',
'duree': 60,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 4,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 15,
'heure_debut': '09:00:00',
'heure_fin': '11:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 30,
'heure_debut': '09:00:00',
'heure_fin': '11:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 45,
'heure_debut': '09:00:00',
'heure_fin': '11:00:00',
'personnes': 3,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 60,
'heure_debut': '09:00:00',
'heure_fin': '11:00:00',
'personnes': 4,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 15,
'heure_debut': '11:30:00',
'heure_fin': '12:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 30,
'heure_debut': '11:30:00',
'heure_fin': '12:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 15,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 30,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 45,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 3,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 60,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 4,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-12',
'duree': 15,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-12',
'duree': 30,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-12',
'duree': 45,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 3,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-12',
'duree': 60,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 4,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-12',
'duree': 15,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-12',
'duree': 30,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-12',
'duree': 45,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 3,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-12',
'duree': 60,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 4,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-13',
'duree': 15,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-13',
'duree': 30,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-13',
'duree': 45,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 3,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-13',
'duree': 60,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 4,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-13',
'duree': 15,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-13',
'duree': 30,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-13',
'duree': 45,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 3,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-13',
'duree': 60,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 4,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-14',
'duree': 15,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-14',
'duree': 30,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-14',
'duree': 45,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 3,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-14',
'duree': 60,
'heure_debut': '09:00:00',
'heure_fin': '12:00:00',
'personnes': 4,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-14',
'duree': 15,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-14',
'duree': 30,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-14',
'duree': 45,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 3,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-14',
'duree': 60,
'heure_debut': '14:00:00',
'heure_fin': '17:00:00',
'personnes': 4,
'types_rdv': ['CNI', 'PASSPORT'],
},
],
},
{
'full': True,
'id': '2',
'nom': 'Mairie annexe',
'numero_rue': '3 rue du four',
'code_postal': '99999',
'ville': 'Saint-Didier',
'latitude': 46.596,
'longitude': 2.476,
'url': '',
'rdv_url': '',
'gestion_url': '',
'annulation_url': '',
'rdvs': [
{'annule': True, 'date': '2023-04-10T08:00:00+00:00', 'id': '1234ABCD'},
{'date': '2023-04-10T09:00:00+00:00', 'id': 'XYZ12JKL'},
{'date': '2023-04-11T09:00:00+00:00', 'id': 'ABCD1234'},
],
'plages': [
{
'date': '2023-04-10',
'duree': 15,
'heure_debut': '08:00:00',
'heure_fin': '11:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-10',
'duree': 30,
'heure_debut': '08:00:00',
'heure_fin': '11:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-10',
'duree': 45,
'heure_debut': '08:00:00',
'heure_fin': '11:00:00',
'personnes': 3,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-10',
'duree': 60,
'heure_debut': '08:00:00',
'heure_fin': '11:00:00',
'personnes': 4,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-10',
'duree': 15,
'heure_debut': '11:45:00',
'heure_fin': '12:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 15,
'heure_debut': '08:00:00',
'heure_fin': '12:00:00',
'personnes': 1,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 30,
'heure_debut': '08:00:00',
'heure_fin': '12:00:00',
'personnes': 2,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 45,
'heure_debut': '08:00:00',
'heure_fin': '12:00:00',
'personnes': 3,
'types_rdv': ['CNI', 'PASSPORT'],
},
{
'date': '2023-04-11',
'duree': 60,
'heure_debut': '08:00:00',
'heure_fin': '12:00:00',
'personnes': 4,
'types_rdv': ['CNI', 'PASSPORT'],
},
],
},
],
}

View File

@ -225,7 +225,10 @@ def add_exception(
def add_meeting(
target: typing.Union[Desk, Agenda], start: datetime.datetime, meeting_type: MeetingType = None
target: typing.Union[Desk, Agenda],
start: datetime.datetime,
meeting_type: typing.Union[str, MeetingType] = None,
**kwargs,
):
if isinstance(target, Desk):
desk = target
@ -235,11 +238,13 @@ def add_meeting(
if meeting_type is None:
meeting_type = desk.agenda.meetingtype_set.get()
elif isinstance(meeting_type, str):
meeting_type = desk.agenda.meetingtype_set.get(slug=meeting_type)
event = Event.objects.create(
agenda=desk.agenda, desk=desk, meeting_type=meeting_type, start_datetime=start, full=False, places=1
)
booking = Booking.objects.create(event=event)
booking = Booking.objects.create(event=event, **kwargs)
event.booking = booking
return event