import secrets from django.contrib.gis.db import models from django.conf import settings from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django.core.exceptions import ValidationError from django.template.defaultfilters import truncatechars from datetime import datetime, timedelta from dateutil.relativedelta import relativedelta class RoadAuthorization(models.Model): AUTH_TYPE = ( (2, _('Affichage (panneau, ...)')), (21, _('Terrasse')), (22, _('Mobilier urbain')), (23, _("Œuvre d'art")), (32, _('Nouvel Emplacement PMR')), (33, _('Nouvelle Zone de livraison')), (34, _('Tournage audiovisuel')), (100, _('Evénement')), (51, _('Plante grimpante')), (52, _("Pied d'arbre")), ) AUTH_STATUS = ( (1, _('À traiter')), (2, _('En cours')), (3, _('Autorisé')), (4, _('Refusé')), (5, _('Clôturé')), ) LANGUAGE_CHOICES = [('fr', 'Français'), ('nl', 'Nederlands'), ('en', 'English')] code = models.CharField(max_length=50, default='') status = models.IntegerField(default=1, choices=AUTH_STATUS) language = models.CharField( max_length=5, default='fr', choices=LANGUAGE_CHOICES, verbose_name=_('Langue'), ) is_email_verified = models.BooleanField( default=False, verbose_name=_("Email vérifié"), help_text=_("Indique si l'adresse email du demandeur a été vérifiée."), ) type = models.IntegerField(_("Type d'occupation"), choices=AUTH_TYPE) description = models.TextField(_('Description de la demande')) applicant_name = models.CharField(_('Nom'), max_length=100) applicant_email = models.EmailField(_('Email')) applicant_address = models.CharField(_('Adresse'), max_length=300) applicant_enterprise = models.CharField(_('Entreprise'), max_length=100, null=True, blank=True) creation_date = models.DateTimeField(auto_now_add=True) update_date = models.DateTimeField(auto_now=True) agent = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('Agent traitant'), ) agent_comment = models.TextField(_('Commentaire'), null=True, blank=True) location = models.CharField(_('Localisation – Adresse'), max_length=500) geom = models.MultiPolygonField(_('Localisation'), srid=4326) @property def end_date(self): return self.dates.all().latest('end_date').end_date @property def date_status(self): """ Returns: 1 – older than 2 years 2 – past (< 2 years) 3 – future 4 – active (ongoing right now) """ datestatus = 1 now = datetime.now().astimezone() for d in self.dates.all(): end = d.end_date if end is None: # No end date → treat as open-ended (active if already started) if d.begin_date <= now: return 4 else: datestatus = max(3, datestatus) elif end >= now and d.begin_date <= now: return 4 elif d.begin_date >= now: datestatus = max(3, datestatus) elif end > (now - relativedelta(years=2)): datestatus = max(2, datestatus) return datestatus @property def truncated_description(self): return truncatechars(self.description, 100) class Meta: ordering = ['status', '-code'] verbose_name = _("Autorisation d'occupation de l'espace public") verbose_name_plural = _("Autorisations d'occupation de l'espace public") # Maps AUTH_TYPE values to their 2-digit code prefix TYPE_PREFIX = { 2: '01', 21: '02', 22: '03', 23: '04', 32: '05', 33: '06', 34: '07', 100: '08', 51: '09', 52: '10', } def save(self, *args, **kwargs): super().save(*args, **kwargs) if not self.code and self.pk: year = self.creation_date.year if self.creation_date else datetime.now().year prefix = self.TYPE_PREFIX.get(self.type, '00') # Count same-type authorizations created this year (excl. self) that already have a code count = RoadAuthorization.objects.filter( type=self.type, creation_date__year=year, ).exclude(pk=self.pk).exclude(code='').count() seq = count + 1 seq_str = f"{seq:03d}" if seq < 1000 else str(seq) self.code = f"AUTH-{year}-{prefix}{seq_str}" RoadAuthorization.objects.filter(pk=self.pk).update(code=self.code) def __str__(self): return f"{self.code} – {self.truncated_description}" if self.code else self.truncated_description def validate_file_size(file): limit_mb = 15 if file.size > limit_mb * 1024 * 1024: raise ValidationError(_("La taille maximale du fichier est de %(limit)s Mo.") % {'limit': limit_mb}) class RoadAuthorizationDocument(models.Model): file = models.FileField( verbose_name=_('Fichier'), upload_to='authorizations_documents/', validators=[validate_file_size], ) description = models.CharField(_('Description'), max_length=200) authorization = models.ForeignKey( RoadAuthorization, on_delete=models.CASCADE, related_name='documents', ) class Meta: verbose_name = _('Document') verbose_name_plural = _('Documents') def __str__(self): return self.description class RoadAuthorizationDate(models.Model): authorization = models.ForeignKey( RoadAuthorization, on_delete=models.CASCADE, related_name='dates', ) begin_date = models.DateTimeField(_("Début de l'occupation")) end_date = models.DateTimeField(_("Fin de l'occupation"), null=True, blank=True) class Meta: verbose_name = _("Date d'occupation") verbose_name_plural = _("Dates d'occupation") ordering = ['begin_date'] def __str__(self): begin_str = f"{self.begin_date:%d/%m/%Y %H:%M}" if self.begin_date else "—" end_str = f"{self.end_date:%d/%m/%Y %H:%M}" if self.end_date else "—" return f"{begin_str} → {end_str}" # --------------------------------------------------------------------------- # Email verification # --------------------------------------------------------------------------- class VerifiedEmail(models.Model): """Whitelist des adresses email ayant été vérifiées.""" email = models.EmailField(unique=True, verbose_name=_("Email")) verified_at = models.DateTimeField(auto_now_add=True) class Meta: verbose_name = _("Email vérifié") verbose_name_plural = _("Emails vérifiés") def __str__(self): return self.email class EmailVerificationToken(models.Model): """Code à 6 chiffres de vérification d'email lié à une demande d'autorisation en attente.""" authorization = models.OneToOneField( RoadAuthorization, on_delete=models.CASCADE, related_name='verification_token', ) code = models.CharField(max_length=6) created_at = models.DateTimeField(auto_now_add=True) expires_at = models.DateTimeField() attempts = models.PositiveSmallIntegerField(default=0) TOKEN_VALIDITY_MINUTES = 10 MAX_ATTEMPTS = 5 class Meta: verbose_name = "Token de vérification" verbose_name_plural = "Tokens de vérification" def is_expired(self): return timezone.now() > self.expires_at def is_locked(self): return self.attempts >= self.MAX_ATTEMPTS @classmethod def create_for_authorization(cls, authorization): code = f"{secrets.randbelow(1_000_000):06d}" expires_at = timezone.now() + timedelta(minutes=cls.TOKEN_VALIDITY_MINUTES) # Supprimer tout token existant pour cette autorisation cls.objects.filter(authorization=authorization).delete() return cls.objects.create( authorization=authorization, code=code, expires_at=expires_at, ) class Meta: verbose_name = _("Code de vérification") verbose_name_plural = _("Codes de vérification") def __str__(self): return f"Code pour {self.authorization}"