ants-hub/src/ants_hub/data/models.py

432 lines
14 KiB
Python

# ANTS-Hub - Copyright (C) Entr'ouvert
import datetime
import hashlib
import secrets
import typing
import uuid
from django import forms
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from django.utils.timezone import is_naive, localtime, make_aware
class CharField(models.TextField):
'''SQL Text column using forms.CharField'''
def formfield(self, **kwargs):
kwargs['widget'] = forms.TextInput
return super().formfield(**kwargs)
class URLField(models.URLField):
'''URLField using a TEXT column for storage'''
def get_internal_type(self):
return 'TextField'
class Horaire(typing.NamedTuple):
start: datetime.time
end: datetime.time
class HoraireList(list):
def __str__(self):
return ','.join(f'{h.start.isoformat()}-{h.end.isoformat()}' for h in self)
def append(self, value):
assert isinstance(value, Horaire)
if self and value.start < self[-1].end:
raise ValueError
super().append(value)
@classmethod
def from_str(cls, value):
try:
terms = value.strip().split(',')
hl = cls()
for term in terms:
start, end = term.split('-', 1)
start = datetime.time.fromisoformat(start)
end = datetime.time.fromisoformat(end)
if end < start:
raise ValueError
hl.append(Horaire(start, end))
return hl
except (ValueError, TypeError):
raise ValueError
class HoraireFormField(forms.CharField):
def to_python(self, value):
if value in self.empty_values:
return None
if isinstance(value, list):
return HoraireList(x if isinstance(x, Horaire) else Horaire(*x) for x in value)
try:
return HoraireList.from_str(value)
except ValueError:
raise ValidationError('Horaires invalides, ex.: 12:00-13:00,14:00-15:00')
class HoraireField(CharField):
def formfield(self, **kwargs):
defaults = {'form_class': HoraireFormField}
defaults.update(**kwargs)
return super().formfield(**defaults)
def get_prep_value(self, value):
return str(value)
def from_db_value(self, value, expression, connection):
return HoraireList.from_str(value)
def to_python(self, value):
if isinstance(value, HoraireList):
return value
if value is None:
return value
if isinstance(value, list):
return HoraireList(Horaire(x) for x in value)
try:
return HoraireList.from_str(value)
except ValueError:
raise ValidationError('Horaires invalides, ex.: 12:00-13:00,14:00-15:00')
class TypeDeRdv(models.IntegerChoices):
CNI = 1, 'CNI'
PASSPORT = 2, 'PASSPORT'
CNI_PASSPORT = 3, 'CNI-PASSPORT'
@classmethod
def from_label(cls, label):
for value, _label in cls.choices:
if label == _label:
return value
raise ValueError(label)
class TypeDeRdvField(models.SmallIntegerField):
def __init__(self, **kwargs):
kwargs['choices'] = TypeDeRdv.choices
super().__init__(**kwargs)
def generate_apikey():
return uuid.uuid4().hex
def make_uuid_from_apikey(apikey):
return uuid.UUID(bytes=hashlib.sha256(apikey.encode()).digest()[:16])
class RaccordementManager(models.Manager):
def get_by_natural_key(self, name):
return self.get(name=name)
def get_by_apikey(self, apikey):
'''Use an hashed version of the apikey for lookup, to prevent timing attacks.'''
apikey = apikey.strip()
guid = make_uuid_from_apikey(apikey)
try:
raccordement = self.get(apikey_digest=guid)
except self.model.DoesNotExist:
return None
if secrets.compare_digest(raccordement.apikey, apikey):
return raccordement
return None
class Raccordement(models.Model):
uuid = models.UUIDField(verbose_name='UUID', default=uuid.uuid4, primary_key=True, editable=False)
name = CharField(verbose_name='Nom', unique=True)
apikey = CharField(
verbose_name='API key',
default=generate_apikey,
unique=True,
help_text='Écrire "NEW" pour en générer une nouvelle.',
)
apikey_digest = models.UUIDField(verbose_name='Condensat de l\'API key', db_index=True, editable=False)
notes = models.TextField(verbose_name="Notes", blank=True)
created = models.DateTimeField(verbose_name='Création', auto_now_add=True)
last_update = models.DateTimeField(verbose_name='Dernière mise à jour', auto_now=True)
objects = RaccordementManager()
def __str__(self):
return f'{self.name}'
def natural_key(self):
return (self.name,)
def clean(self):
if self.apikey.strip() == 'NEW':
self.apikey = generate_apikey()
self.apikey = self.apikey.strip()
if len(self.apikey) < 32:
raise ValidationError('API key doit faire au moins 32 caractères')
def save(self, *args, **kwargs):
self.apikey_digest = make_uuid_from_apikey(self.apikey)
return super().save(*args, **kwargs)
def lock(self):
type(self).objects.filter(pk=self.pk).select_for_update().get()
class Meta:
verbose_name = 'raccordement'
verbose_name_plural = 'raccordements'
ordering = ('name',)
db_table = 'ants_hub_raccordement'
class CollectiviteManager(models.Manager):
def get_by_natural_key(self, source_id, *args):
return self.get(source_id=source_id, raccordement=Raccordement.objects.get_by_natural_key(*args))
class Collectivite(models.Model):
raccordement = models.ForeignKey(
verbose_name='Raccordement', to=Raccordement, related_name='collectivites', on_delete=models.PROTECT
)
nom = CharField(verbose_name='Nom')
source_id = CharField(verbose_name='Identifiant de la collectivité à la source')
url = URLField(verbose_name='URL du portail')
logo_url = URLField(verbose_name='URL du logo', blank=True)
rdv_url = URLField(verbose_name='URL de prise de rendez-vous ', blank=True)
gestion_url = URLField(verbose_name='URL de gestion des rendez-vous', blank=True)
annulation_url = URLField(verbose_name='URL d\'annulation des rendez-vous', blank=True)
created = models.DateTimeField(verbose_name='Création', auto_now_add=True)
last_update = models.DateTimeField(verbose_name='Dernière mise à jour', auto_now=True)
objects = CollectiviteManager()
def __str__(self):
return f'{self.nom}'
def natural_key(self):
return (self.source_id,) + self.raccordement.natural_key()
natural_key.dependencies = ['data.raccordement']
@property
def slug(self):
return slugify(self.nom)
class Meta:
verbose_name = 'collectivité'
verbose_name_plural = 'collectivités'
unique_together = [
['raccordement', 'source_id'],
['raccordement', 'nom'],
]
db_table = 'ants_hub_collectivite'
class LieuManager(models.Manager):
def get_by_natural_key(self, source_id, *args):
return self.get(source_id=source_id, collectivite=Collectivite.objects.get_by_natural_key(*args))
class Lieu(models.Model):
collectivite = models.ForeignKey(
verbose_name='Collectivité', to=Collectivite, related_name='lieux', on_delete=models.PROTECT
)
nom = CharField(verbose_name='Nom')
source_id = CharField(verbose_name='Identifiant du lieu à la source')
numero_rue = CharField(verbose_name='Numéro rue')
code_postal = CharField(verbose_name='Code postal')
ville = CharField(verbose_name='Ville')
longitude = models.FloatField(verbose_name='Longitude')
latitude = models.FloatField(verbose_name='Latitude')
url = URLField(verbose_name='URL du portail', blank=True)
logo_url = URLField(verbose_name='URL du logo', blank=True)
rdv_url = URLField(verbose_name='URL de prise de rendez-vous ', blank=True)
gestion_url = URLField(verbose_name='URL de gestion des rendez-vous', blank=True)
annulation_url = URLField(verbose_name='URL d\'annulation des rendez-vous', blank=True)
created = models.DateTimeField(verbose_name='Création', auto_now_add=True)
last_update = models.DateTimeField(verbose_name='Dernière mise à jour', auto_now=True)
objects = LieuManager()
def __str__(self):
return f'{self.nom} / {self.numero_rue} / {self.ville}'
def natural_key(self):
return (self.source_id,) + self.collectivite.natural_key()
natural_key.dependencies = ['data.collectivite']
@property
def slug(self):
return slugify(self.nom)
def get_rdv_url(self):
return self.rdv_url or self.collectivite.rdv_url or self.url or self.collectivite.url
class Meta:
verbose_name = 'lieu'
verbose_name_plural = 'lieux'
unique_together = [
('collectivite', 'source_id'),
('collectivite', 'nom'),
]
db_table = 'ants_hub_lieu'
class PlageManager(models.Manager):
def get_by_natural_key(self, date, type_de_rdv, *args):
return self.get(date=date, type_de_rdv=type_de_rdv, lieu=Lieu.objects.get_by_natural_key(*args))
class Plage(models.Model):
lieu = models.ForeignKey(verbose_name='Lieu', to=Lieu, related_name='plages', on_delete=models.CASCADE)
date = models.DateField(verbose_name='Date')
horaires = HoraireField(verbose_name='Horaire')
duree = models.SmallIntegerField(verbose_name='Durée')
type_de_rdv = TypeDeRdvField(verbose_name='Type de rendez-vous')
personnes = models.SmallIntegerField(
verbose_name='Nombre de personnes maximum',
default=1,
validators=[MinValueValidator(1), MaxValueValidator(4)],
)
created = models.DateTimeField(verbose_name='Création', auto_now_add=True)
last_update = models.DateTimeField(verbose_name='Dernière mise à jour', auto_now=True)
objects = PlageManager()
def __str__(self):
return f'{self.date.isoformat()} / {self.horaires}'
def natural_key(self):
return (self.date, self.type_de_rdv) + self.lieu.natural_key()
natural_key.dependencies = ['data.lieu']
def _make_rdv_url(self, datetime):
return reverse(
'rdv-redirect',
kwargs={
'collectivite_pk': self.lieu.collectivite.pk,
'collectivite_slug': self.lieu.collectivite.slug,
'lieu_pk': self.lieu.pk,
'lieu_slug': self.lieu.slug,
'date': datetime,
},
)
def available_time_slots(self):
for horaire in self.horaires:
start = datetime.datetime.combine(self.date, horaire.start)
if is_naive(start):
start = make_aware(start)
end = datetime.datetime.combine(self.date, horaire.end)
if is_naive(end):
end = make_aware(end)
start = localtime(start)
end = localtime(end)
while start < end:
yield {
'datetime': start,
'callback_url': self._make_rdv_url(start),
}
start += datetime.timedelta(minutes=self.duree)
class Meta:
verbose_name = 'plage'
verbose_name_plural = 'plages'
unique_together = [
['type_de_rdv', 'date', 'lieu'],
]
db_table = 'ants_hub_plage'
class RendezVousManager(models.Manager):
def get_by_natural_key(self, identifiant_predemande, *args):
return self.get(
identifiant_predemande=identifiant_predemande, lieu=Lieu.objects.get_by_natural_key(*args)
)
class RendezVous(models.Model):
uuid = models.UUIDField(verbose_name='UUID', default=uuid.uuid4, primary_key=True, editable=False)
lieu = models.ForeignKey(verbose_name='Lieu', to=Lieu, related_name='rdvs', on_delete=models.CASCADE)
identifiant_predemande = CharField(verbose_name='Identifiant de prédemande', db_index=True)
date = models.DateTimeField(verbose_name='Date')
gestion_url = URLField(verbose_name='URL de gestion des rendez-vous', blank=True)
annulation_url = URLField(verbose_name='URL d\'annulation des rendez-vous', blank=True)
created = models.DateTimeField(verbose_name='Création', auto_now_add=True)
last_update = models.DateTimeField(verbose_name='Dernière mise à jour', auto_now=True)
objects = RendezVousManager()
def __str__(self):
return f'{self.date} / {self.identifiant_predemande}'
def natural_key(self):
return (self.identifiant_predemande,) + self.lieu.natural_key()
natural_key.dependencies = ['data.lieu']
def make_gestion_url(self):
return reverse(
'gestion-redirect',
kwargs={
'collectivite_pk': self.lieu.collectivite.pk,
'collectivite_slug': self.lieu.collectivite.slug,
'lieu_pk': self.lieu.pk,
'lieu_slug': self.lieu.slug,
'date': self.date,
'rdv_pk': self.pk,
},
)
def get_gestion_url(self):
return (
self.gestion_url
or self.lieu.gestion_url
or self.lieu.collectivite.gestion_url
or self.lieu.rdv_url
or self.lieu.collectivite.rdv_url
or self.lieu.url
or self.lieu.collectivite.url
)
def make_annulation_url(self):
return reverse(
'annulation-redirect',
kwargs={
'collectivite_pk': self.lieu.collectivite.pk,
'collectivite_slug': self.lieu.collectivite.slug,
'lieu_pk': self.lieu.pk,
'lieu_slug': self.lieu.slug,
'date': self.date,
'rdv_pk': self.pk,
},
)
def get_annulation_url(self):
return (
self.annulation_url
or self.lieu.annulation_url
or self.lieu.collectivite.annulation_url
or self.lieu.rdv_url
or self.lieu.collectivite.rdv_url
or self.lieu.url
or self.lieu.collectivite.url
)
class Meta:
verbose_name = 'rendez-vous'
verbose_name_plural = 'rendez-vous'
db_table = 'ants_hub_rendez_vous'