from django.db import models from django.contrib.auth.models import User from django.utils.translation import gettext_lazy as _ from django.utils import timezone from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.fields import GenericForeignKey class Team(models.Model): """Une équipe regroupant des utilisateurs.""" name = models.CharField(max_length=100, verbose_name=_("Nom")) description = models.TextField(blank=True, verbose_name=_("Description")) members = models.ManyToManyField( User, related_name='notification_teams', blank=True, verbose_name=_("Membres"), ) created_by = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, blank=True, related_name='notification_teams_created', verbose_name=_("Créé par"), ) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Créé le")) def __str__(self): return self.name class Meta: verbose_name = _("Équipe") verbose_name_plural = _("Équipes") ordering = ['name'] class Notification(models.Model): """Une notification pouvant être envoyée à un ou plusieurs utilisateurs ou équipes.""" TYPE_MESSAGE = 'message' TYPE_REMINDER = 'reminder' TYPE_TASK = 'task' TYPE_ALERT = 'alert' TYPE_INFO = 'info' TYPES = [ (TYPE_MESSAGE, _('Message')), (TYPE_REMINDER, _('Rappel')), (TYPE_TASK, _('Tâche')), (TYPE_ALERT, _('Alerte')), (TYPE_INFO, _('Information')), ] # Icon and color classes per type (used in templates) TYPE_META = { TYPE_MESSAGE: {'icon': 'bi-chat-dots', 'color': 'primary'}, TYPE_REMINDER: {'icon': 'bi-alarm', 'color': 'warning'}, TYPE_TASK: {'icon': 'bi-check2-square', 'color': 'success'}, TYPE_ALERT: {'icon': 'bi-exclamation-triangle', 'color': 'danger'}, TYPE_INFO: {'icon': 'bi-info-circle', 'color': 'info'}, } notification_type = models.CharField( max_length=20, choices=TYPES, default=TYPE_INFO, verbose_name=_("Type"), ) title = models.CharField(max_length=255, verbose_name=_("Titre")) content = models.TextField(blank=True, verbose_name=_("Contenu")) sender = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, blank=True, related_name='sent_notifications', verbose_name=_("Expéditeur"), ) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Créé le")) expires_at = models.DateTimeField( null=True, blank=True, verbose_name=_("Expire le"), help_text=_("Laisser vide pour ne pas faire expirer la notification."), ) # Optional link to an object in the application action_url = models.CharField( max_length=500, blank=True, verbose_name=_("Lien d'action"), help_text=_("URL relative ou absolue vers laquelle l'utilisateur est redirigé."), ) action_label = models.CharField( max_length=100, blank=True, verbose_name=_("Libellé du lien"), ) # App that generated the notification source_app = models.CharField( max_length=50, blank=True, verbose_name=_("Application source"), help_text=_("Nom de l'application qui a généré la notification."), ) is_system = models.BooleanField( default=False, verbose_name=_("Notification système"), help_text=_("Générée automatiquement par le système."), ) is_single_claim = models.BooleanField( default=False, verbose_name=_("Prise en charge unique"), help_text=_( "Si activé, dès qu'un destinataire marque cette notification comme lue, " "elle est automatiquement archivée pour tous les autres destinataires." ), ) source_discussion = models.ForeignKey( 'Discussion', null=True, blank=True, on_delete=models.SET_NULL, related_name='notifications', verbose_name=_("Discussion liée"), ) def __str__(self): return f"[{self.get_notification_type_display()}] {self.title}" @property def type_icon(self): return self.TYPE_META.get(self.notification_type, {}).get('icon', 'bi-bell') @property def type_color(self): return self.TYPE_META.get(self.notification_type, {}).get('color', 'secondary') @property def is_expired(self): if self.expires_at is None: return False return timezone.now() > self.expires_at class Meta: verbose_name = _("Notification") verbose_name_plural = _("Notifications") ordering = ['-created_at'] class NotificationRecipient(models.Model): """Lie une notification à un utilisateur spécifique (notification personnelle).""" notification = models.ForeignKey( Notification, on_delete=models.CASCADE, related_name='recipients', verbose_name=_("Notification"), ) user = models.ForeignKey( User, on_delete=models.CASCADE, related_name='notification_recipients', verbose_name=_("Destinataire"), ) read_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Lu le")) archived_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Archivé le")) reminder_sent_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Rappel envoyé le")) @property def is_read(self): return self.read_at is not None @property def is_archived(self): return self.archived_at is not None def mark_read(self): if not self.read_at: self.read_at = timezone.now() self.save(update_fields=['read_at']) # Prise en charge unique : archiver la notification chez tous les autres destinataires if self.notification.is_single_claim: now = timezone.now() NotificationRecipient.objects.filter( notification=self.notification, read_at__isnull=True, ).exclude(pk=self.pk).update( read_at=now, archived_at=now, ) def archive(self): if not self.archived_at: if not self.read_at: self.read_at = timezone.now() self.archived_at = timezone.now() self.save(update_fields=['read_at', 'archived_at']) def __str__(self): return f"{self.notification} → {self.user.username}" class Meta: verbose_name = _("Destinataire de notification") verbose_name_plural = _("Destinataires de notification") unique_together = [('notification', 'user')] ordering = ['-notification__created_at'] class TeamNotificationTarget(models.Model): """Lie une notification à une équipe (notification de groupe).""" notification = models.ForeignKey( Notification, on_delete=models.CASCADE, related_name='team_targets', verbose_name=_("Notification"), ) team = models.ForeignKey( Team, on_delete=models.CASCADE, related_name='received_notifications', verbose_name=_("Équipe"), ) def __str__(self): return f"{self.notification} → {self.team.name}" class Meta: verbose_name = _("Notification d'équipe") verbose_name_plural = _("Notifications d'équipe") unique_together = [('notification', 'team')] class NotificationAttachment(models.Model): """Pièce jointe (photo ou document) liée à une notification.""" def _upload_to(self, filename): return f'notifications/{self.notification_id}/{filename}' notification = models.ForeignKey( Notification, on_delete=models.CASCADE, related_name='attachments', verbose_name=_("Notification"), ) file = models.FileField( upload_to=_upload_to, verbose_name=_("Fichier"), ) name = models.CharField( max_length=255, blank=True, verbose_name=_("Nom du fichier"), ) uploaded_by = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, blank=True, related_name='notification_attachments', verbose_name=_("Ajouté par"), ) uploaded_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Ajouté le")) def save(self, *args, **kwargs): if not self.name and self.file: import os self.name = os.path.basename(self.file.name) super().save(*args, **kwargs) @property def is_image(self): import os ext = os.path.splitext(self.file.name)[1].lower() return ext in ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp') def __str__(self): return self.name or str(self.file) class Meta: verbose_name = _("Pièce jointe") verbose_name_plural = _("Pièces jointes") ordering = ['uploaded_at'] class TeamNotificationRead(models.Model): """Suivi de la lecture des notifications d'équipe par utilisateur.""" team_notification = models.ForeignKey( TeamNotificationTarget, on_delete=models.CASCADE, related_name='reads', verbose_name=_("Notification d'équipe"), ) user = models.ForeignKey( User, on_delete=models.CASCADE, related_name='team_notification_reads', verbose_name=_("Utilisateur"), ) read_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Lu le")) class Meta: verbose_name = _("Lecture de notification d'équipe") verbose_name_plural = _("Lectures de notifications d'équipe") unique_together = [('team_notification', 'user')] def __str__(self): return f"{self.user.username} lu {self.team_notification}" # ───────────────────────────────────────────────────────────────────────────── # Discussion / Messaging # ───────────────────────────────────────────────────────────────────────────── class Discussion(models.Model): """Fil de discussion, éventuellement lié à n'importe quel objet du système.""" title = models.CharField(max_length=255, blank=True, verbose_name=_("Titre")) created_by = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, blank=True, related_name='discussions_created', verbose_name=_("Créé par"), ) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Créé le")) updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Mis à jour le")) participants = models.ManyToManyField( User, related_name='discussions', blank=True, verbose_name=_("Participants"), ) is_closed = models.BooleanField(default=False, verbose_name=_("Clôturée")) # Lien générique vers n'importe quel objet du système content_type = models.ForeignKey( ContentType, null=True, blank=True, on_delete=models.SET_NULL, verbose_name=_("Type d'objet lié"), ) object_id = models.PositiveIntegerField( null=True, blank=True, verbose_name=_("ID objet lié") ) content_object = GenericForeignKey('content_type', 'object_id') # URL et libellé pré-calculés pour éviter de résoudre le FK générique en template object_url = models.CharField( max_length=500, blank=True, verbose_name=_("URL objet lié") ) object_label = models.CharField( max_length=255, blank=True, verbose_name=_("Libellé objet lié") ) def __str__(self): return self.title or f"Discussion #{self.pk}" def get_absolute_url(self): from django.urls import reverse return reverse('notifications:discussion_detail', args=[self.pk]) def last_message(self): return self.messages.filter(is_deleted=False).order_by('-created_at').first() def unread_count_for(self, user): return NotificationRecipient.objects.filter( user=user, notification__source_discussion=self, read_at__isnull=True, archived_at__isnull=True, ).count() @property def type_info(self): """Retourne les classes de style, l'icône et le libellé du type d'objet lié.""" model_name = self.content_type.model.lower() if self.content_type else '' lbl = (self.object_label or '').lower() title_low = (self.title or '').lower() if model_name == 'intervention' or lbl.startswith('intervention') or title_low.startswith('intervention'): return { 'icon': 'bi-tools', 'bg_class': 'bg-primary-subtle', 'text_class': 'text-primary', 'border_class': 'border-primary-subtle', 'label': _("Intervention"), } elif model_name == 'observation' or lbl.startswith('observation') or title_low.startswith('observation'): return { 'icon': 'bi-eye-fill', 'bg_class': 'bg-warning-subtle', 'text_class': 'text-warning-emphasis', 'border_class': 'border-warning-subtle', 'label': _("Observation"), } elif model_name == 'preparationorder' or 'préparation' in lbl or 'preparation' in lbl: return { 'icon': 'bi-box-seam-fill', 'bg_class': 'bg-info-subtle', 'text_class': 'text-info-emphasis', 'border_class': 'border-info-subtle', 'label': _("Ordre de préparation"), } elif model_name == 'project' or lbl.startswith('projet') or title_low.startswith('projet'): return { 'icon': 'bi-kanban-fill', 'bg_class': 'bg-success-subtle', 'text_class': 'text-success-emphasis', 'border_class': 'border-success-subtle', 'label': _("Projet"), } else: return { 'icon': 'bi-chat-dots-fill', 'bg_class': 'bg-indigo-subtle', 'text_class': 'text-primary', 'border_class': 'border-primary-subtle', 'label': _("Générale"), } class Meta: verbose_name = _("Discussion") verbose_name_plural = _("Discussions") ordering = ['-updated_at'] def _message_attachment_upload(instance, filename): return f'discussions/{instance.message.discussion_id}/{filename}' class Message(models.Model): """Message au sein d'une discussion.""" discussion = models.ForeignKey( Discussion, on_delete=models.CASCADE, related_name='messages', verbose_name=_("Discussion"), ) author = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, blank=True, related_name='discussion_messages', verbose_name=_("Auteur"), ) content = models.TextField(verbose_name=_("Contenu")) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Envoyé le")) is_deleted = models.BooleanField(default=False, verbose_name=_("Supprimé")) def __str__(self): author_name = self.author.username if self.author else 'système' return f"Message de {author_name} dans {self.discussion}" class Meta: verbose_name = _("Message") verbose_name_plural = _("Messages") ordering = ['created_at'] # ───────────────────────────────────────────────────────────────────────────── # Règles de notification (configurable par l'administrateur) # ───────────────────────────────────────────────────────────────────────────── class NotificationRule(models.Model): """ Règle de notification configurable par un administrateur. Permet de déclencher automatiquement l'envoi de notifications à des équipes ou utilisateurs définis lorsqu'un événement particulier se produit dans l'application (ex. : annulation d'une intervention). Les modèles de titre et contenu acceptent des variables Python entre accolades, par exemple : « Intervention {intervention_code} annulée ({cancellation_type_label}) » """ # ── Événements supportés ────────────────────────────────────────────────── EVENT_INTERVENTION_CANCELED = 'intervention_canceled' TRIGGER_EVENTS = [ (EVENT_INTERVENTION_CANCELED, _('Intervention annulée')), ] # Choix de type d'annulation, partagés avec interventions.models CANCELLATION_TYPE_CHOICES = [ ('', _('Tous (pas de filtre)')), ('duplicate', _('Doublon')), ('to_redirect', _('A rediriger')), ('not_our_assets', _('Pas sous notre gestion')), ('non_relevant', _('Non-relevant')), ('other', _('Autre')), ] # ── Identification ──────────────────────────────────────────────────────── name = models.CharField(max_length=200, verbose_name=_("Nom de la règle")) is_active = models.BooleanField(default=True, verbose_name=_("Active")) # ── Déclencheur ─────────────────────────────────────────────────────────── trigger_event = models.CharField( max_length=50, choices=TRIGGER_EVENTS, verbose_name=_("Événement déclencheur"), ) trigger_cancellation_type = models.CharField( max_length=50, blank=True, choices=CANCELLATION_TYPE_CHOICES, verbose_name=_("Filtre : type d'annulation"), help_text=_( "Laisser vide pour déclencher sur tout type d'annulation. " "Sélectionner une valeur pour n'agir que sur ce type précis." ), ) # ── Contenu de la notification ──────────────────────────────────────────── notification_type = models.CharField( max_length=20, choices=Notification.TYPES, default=Notification.TYPE_ALERT, verbose_name=_("Type de notification"), ) title_template = models.CharField( max_length=255, verbose_name=_("Modèle de titre"), help_text=_( "Variables disponibles : {intervention_code}, {intervention_title}, " "{cancellation_type_label}" ), ) content_template = models.TextField( blank=True, verbose_name=_("Modèle de contenu"), help_text=_( "Variables disponibles : {intervention_code}, {intervention_title}, " "{cancellation_type_label}, {cancellation_reason}" ), ) # ── Destinataires ───────────────────────────────────────────────────────── recipient_teams = models.ManyToManyField( 'contracts.CompanyTeam', blank=True, related_name='notification_rules', verbose_name=_("Équipes destinataires"), help_text=_("Les notifications seront envoyées à tous les membres (CompanyMember) de ces équipes."), ) recipient_users = models.ManyToManyField( User, blank=True, related_name='notification_rules', verbose_name=_("Utilisateurs destinataires supplémentaires"), ) # ── Comportement ──────────────────────────────────────────────────────── single_claim = models.BooleanField( default=False, verbose_name=_("Prise en charge unique"), help_text=_( "Si activé, dès qu'un destinataire marque la notification comme lue (prise en charge), " "elle est automatiquement archivée pour tous les autres destinataires." ), ) # ── Métadonnées ──────────────────────────────────────────────────────── created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Créée le")) updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Mise à jour le")) def __str__(self): return self.name class Meta: verbose_name = _("Règle de notification") verbose_name_plural = _("Règles de notification") ordering = ['name'] class MessageAttachment(models.Model): """Pièce jointe liée à un message de discussion.""" message = models.ForeignKey( Message, on_delete=models.CASCADE, related_name='attachments', verbose_name=_("Message"), ) file = models.FileField( upload_to=_message_attachment_upload, verbose_name=_("Fichier"), ) name = models.CharField(max_length=255, blank=True, verbose_name=_("Nom du fichier")) uploaded_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Ajouté le")) def save(self, *args, **kwargs): if not self.name and self.file: import os self.name = os.path.basename(self.file.name) super().save(*args, **kwargs) @property def is_image(self): import os ext = os.path.splitext(self.file.name)[1].lower() return ext in ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp') def __str__(self): return self.name or str(self.file) class Meta: verbose_name = _("Pièce jointe de message") verbose_name_plural = _("Pièces jointes de messages") ordering = ['uploaded_at']