ants-hub/src/ants_hub/api/views/chrono.py

290 lines
11 KiB
Python

# ANTS-Hub - Copyright (C) Entr'ouvert
import base64
import binascii
import collections
import datetime
import functools
import json
import os
import jsonschema
from django.core.exceptions import ValidationError
from django.db.transaction import atomic
from django.http import JsonResponse
from django.utils.timezone import now
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import View
from ants_hub.data.models import Collectivite, Horaire, HoraireList, Lieu, Raccordement, RendezVous, TypeDeRdv
with open(os.path.join(os.path.dirname(__file__), '..', 'static', 'schemas', 'rdv-disponibles.json')) as fd:
RENDEZ_VOUS_DISPONIBLES_SCHEMA = json.load(fd)
def authenticate(func):
@functools.wraps(func)
def wrapper(request, *args, **kwargs):
header = request.headers.get('Authorization', '').encode()
if not header:
return JsonResponse("Missing Authorization header", status=401, safe=False)
auth = header.split()
if not auth or auth[0].lower() != b'basic' or len(auth) != 2:
return JsonResponse("Invalid Authorization header", status=401, safe=False)
try:
auth_decoded = base64.b64decode(auth[1]).decode()
except (TypeError, UnicodeDecodeError, binascii.Error):
return JsonResponse("Invalid Authorization header", status=401, safe=False)
apikey = auth_decoded.split(':', 1)[0]
raccordement = Raccordement.objects.get_by_apikey(apikey)
if not raccordement:
return JsonResponse("Invalid API key", status=401, safe=False)
request.raccordement = raccordement
return func(request, *args, **kwargs)
return wrapper
@authenticate
def ping(request):
return JsonResponse({'err': 0})
class RendezVousDisponibleView(View):
def get(self, request):
collectivites_data = []
for collectivite in request.raccordement.collectivites.prefetch_related('lieux').order_by('created'):
lieux_data = []
collectivite_data = {
'id': collectivite.source_id,
'nom': collectivite.nom,
'url': collectivite.url,
'created': collectivite.created.isoformat(),
'last_update': collectivite.created.isoformat(),
'lieux': lieux_data,
'nombre_de_lieux': collectivite.lieux.count(),
'nombre_de_pre_demandes_actives': RendezVous.objects.filter(
date__lt=now(), lieu__collectivite=collectivite
).count(),
}
for key in ['logo_url', 'rdv_url', 'gestion_url', 'annulation_url']:
if getattr(collectivite, key, None):
collectivite_data[key] = getattr(collectivite, key)
for lieu in collectivite.lieux.order_by('created'):
lieu_data = {
'id': lieu.source_id,
'nom': lieu.nom,
'numero_rue': lieu.numero_rue,
'code_postal': lieu.code_postal,
'ville': lieu.ville,
'longitude': lieu.longitude,
'latitude': lieu.latitude,
'nombre_de_pre_demandes_actives': lieu.rdvs.filter(date__lt=now()).count(),
'nombre_de_jours_avec_rdv_disponibles': lieu.plages.filter(date__lt=now())
.distinct('date')
.count(),
}
for key in ['url', 'logo_url', 'rdv_url', 'gestion_url', 'annulation_url']:
if getattr(lieu, key, None):
lieu_data[key] = getattr(collectivite, key)
lieux_data.append(lieu_data)
collectivites_data.append(collectivite_data)
return JsonResponse(
{
'err': 0,
'collectivites': collectivites_data,
}
)
collectivites_created = 0
collectivites_updated = 0
lieux_created = 0
lieux_updated = 0
plage_created = 0
plage_deleted = 0
rdv_created = 0
rdv_deleted = 0
def handle_rdv_payload(self, lieu, rdv):
try:
identifiant_predemande = rdv.pop('id')
date = datetime.datetime.fromisoformat(rdv.pop('date'))
annule = bool(rdv.pop('annule', False))
except ValueError as e:
raise ValidationError('rdv %s invalid: %s' % (rdv, e))
if annule:
_, count_by_model = lieu.rdvs.filter(
identifiant_predemande=identifiant_predemande, date=date
).delete()
self.rdv_deleted += count_by_model['data.RendezVous']
return
_, created = lieu.rdvs.update_or_create(
identifiant_predemande=identifiant_predemande, date=date, defaults=rdv
)
self.rdv_created += bool(created)
def handle_plages_payload(self, lieu, plages):
by_date_and_type_and_personnes = collections.defaultdict(set)
full = set()
for plage in plages:
try:
date = datetime.date.fromisoformat(plage['date'])
types_rdv = [TypeDeRdv.from_label(typ) for typ in plage['types_rdv']]
if not types_rdv:
types_rdv = [TypeDeRdv.CNI]
personnes = plage.get('personnes', 1)
if not plage.get('heure_debut'):
full.update((date, t, personnes) for t in types_rdv)
continue
heure_debut = datetime.time.fromisoformat(plage['heure_debut'])
heure_fin = datetime.time.fromisoformat(plage['heure_fin'])
duree = int(plage['duree'])
if heure_fin < heure_debut:
raise ValueError('heure de fin inférieure à heure de début')
if datetime.datetime.combine(date, heure_fin) - datetime.datetime.combine(
date, heure_debut
) < datetime.timedelta(minutes=duree):
raise ValueError('différence heure de début et de fin inférieure à la durée')
for t in types_rdv:
by_date_and_type_and_personnes[(date, t, personnes)].add((heure_debut, heure_fin, duree))
except ValueError as e:
raise ValidationError('plage %s: %s' % (plage, e))
for x in set(by_date_and_type_and_personnes) | full:
date, type_rdv, personnes = x
_, count_by_model = lieu.plages.filter(date=date, type_de_rdv=type_rdv).delete()
if x in full:
self.plage_deleted += count_by_model['data.Plage']
continue
values = by_date_and_type_and_personnes.get(x)
durees = list({duree for heure_debut, heure_fin, duree in values})
if len(durees) > 1:
raise ValidationError(
'plages %s: la durée varie pour les mêmes rendez-vous du même lieu' % values
)
duree = durees[0]
horaires = HoraireList()
for start, end, _ in values:
try:
horaires.append(Horaire(start=start, end=end))
except ValueError:
raise ValidationError('plages %s-%s: les périodes se chevauchent' % (date, values))
lieu.plages.create(
date=date, type_de_rdv=type_rdv, personnes=personnes, horaires=horaires, duree=duree
)
self.plage_created += 1
def handle_lieu_payload(self, collectivite, payload):
source_id = payload.pop('id')
plages = payload.pop('plages', [])
rdvs = payload.pop('rdvs', [])
try:
lieu = collectivite.lieux.get(source_id=source_id)
updated = False
for key in payload:
if getattr(lieu, key) != payload[key]:
setattr(lieu, key, payload[key])
updated = True
lieu.full_clean()
if updated:
lieu.save()
self.lieux_updated += 1
except Lieu.DoesNotExist:
lieu = Lieu(collectivite=collectivite, source_id=source_id, **payload)
lieu.full_clean()
lieu.save()
self.lieux_created += 1
self.handle_plages_payload(lieu, plages)
for rdv in rdvs:
self.handle_rdv_payload(lieu, rdv)
def handle_collectivite_payload(self, raccordement, payload):
source_id = payload.pop('id')
lieux_payload = payload.pop('lieux', [])
try:
collectivite = raccordement.collectivites.get(source_id=source_id)
updated = False
for key in payload:
if getattr(collectivite, key) != payload[key]:
setattr(collectivite, key, payload[key])
updated = True
collectivite.full_clean()
if updated:
collectivite.save()
self.collectivites_updated += 1
except Collectivite.DoesNotExist:
collectivite = Collectivite(raccordement=raccordement, source_id=source_id, **payload)
collectivite.full_clean()
collectivite.save()
self.collectivites_created += 1
for lieu_payload in lieux_payload:
self.handle_lieu_payload(collectivite, lieu_payload)
def post(self, request):
try:
payload = json.loads(request.body)
except ValueError:
return JsonResponse({'err': 'invalid-json'}, status=400)
try:
jsonschema.validate(payload, RENDEZ_VOUS_DISPONIBLES_SCHEMA)
except jsonschema.ValidationError as e:
return JsonResponse({'err': 'invalid-json', 'detail': e.message}, status=400)
try:
with atomic():
# prevent concurrent updates to the data of the same raccordement
request.raccordement.lock()
for collectivite in payload.get('collectivites', []):
self.handle_collectivite_payload(request.raccordement, collectivite)
except (ValueError, ValidationError) as e:
return JsonResponse(
{
'err': 1,
'error': str(e),
}
)
return JsonResponse(
{
'err': 0,
'data': {
'collectivites_created': self.collectivites_created,
'collectivites_updated': self.collectivites_updated,
'lieux_created': self.lieux_created,
'lieux_updated': self.lieux_updated,
'plage_created': self.plage_created,
'plage_deleted': self.plage_deleted,
'rdv_created': self.rdv_created,
'rdv_deleted': self.rdv_deleted,
},
}
)
rendez_vous_disponibles = csrf_exempt(authenticate(RendezVousDisponibleView.as_view()))
@csrf_exempt
@authenticate
def predemandes(request):
if request.method == 'GET':
return get_predemandes(request)
elif request.method == 'POST':
return post_predemandes(request)
return JsonResponse({'err': 'method-no-allowed'}, status=405)
def get_predemandes(request):
return JsonResponse({'err': 0})
@atomic
def post_predemandes(request):
return JsonResponse({'err': 0})