loko/loko/notifications/models.py

760 lines
27 KiB
Python

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')
# Statuts de suivi (Flags)
FLAG_NONE = ''
FLAG_RED = 'red'
FLAG_ORANGE = 'orange'
FLAG_GREEN = 'green'
FLAG_CHOICES = [
(FLAG_NONE, _('Aucun')),
(FLAG_RED, _('Important / À traiter')),
(FLAG_ORANGE, _('En cours')),
(FLAG_GREEN, _('Traité / Fait')),
]
FLAG_META = {
FLAG_RED: {
'label': _('Important / À traiter'),
'short_label': _('Important'),
'color': '#dc3545',
'color_class': 'danger',
'icon': 'bi-flag-fill',
'badge_class': 'bg-danger text-white',
'style': 'background-color: #dc3545 !important; color: #ffffff !important;',
},
FLAG_ORANGE: {
'label': _('En cours'),
'short_label': _('En cours'),
'color': '#fd7e14',
'color_class': 'warning',
'icon': 'bi-flag-fill',
'badge_class': 'text-white',
'style': 'background-color: #fd7e14 !important; color: #ffffff !important;',
},
FLAG_GREEN: {
'label': _('Traité / Fait'),
'short_label': _('Traité'),
'color': '#198754',
'color_class': 'success',
'icon': 'bi-flag-fill',
'badge_class': 'bg-success text-white',
'style': 'background-color: #198754 !important; color: #ffffff !important;',
},
}
flag = models.CharField(
max_length=20,
choices=FLAG_CHOICES,
default=FLAG_NONE,
blank=True,
verbose_name=_("Flag"),
)
flag_set_by = models.ForeignKey(
User,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='flagged_discussions',
verbose_name=_("Flag défini par"),
)
flag_set_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("Flag défini le"),
)
# 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}"
@property
def flag_info(self):
if not self.flag or self.flag not in self.FLAG_META:
return None
info = dict(self.FLAG_META[self.flag])
info['flag'] = self.flag
info['set_by'] = self.flag_set_by
info['set_at'] = self.flag_set_at
return info
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é"))
is_system = models.BooleanField(
default=False,
verbose_name=_("Message système"),
help_text=_("Indique si le message a été généré automatiquement par le système."),
)
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']
# ─────────────────────────────────────────────────────────────────────────────
# Notifications Push (Web Push API / VAPID)
# ─────────────────────────────────────────────────────────────────────────────
class WebPushSubscription(models.Model):
"""Abonnement d'un appareil aux notifications push pour un utilisateur."""
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='webpush_subscriptions',
verbose_name=_("Utilisateur"),
)
endpoint = models.TextField(
unique=True,
verbose_name=_("Endpoint Push"),
help_text=_("URL unique de réception push fournie par le navigateur."),
)
p256dh = models.CharField(
max_length=255,
verbose_name=_("Clé p256dh"),
help_text=_("Clé publique du client."),
)
auth = models.CharField(
max_length=100,
verbose_name=_("Secret auth"),
help_text=_("Secret d'authentification pour le chiffrement."),
)
user_agent = models.CharField(
max_length=500,
blank=True,
verbose_name=_("User Agent"),
)
browser_name = models.CharField(
max_length=100,
blank=True,
verbose_name=_("Navigateur"),
)
os_name = models.CharField(
max_length=100,
blank=True,
verbose_name=_("Système d'exploitation"),
)
device_name = models.CharField(
max_length=150,
blank=True,
verbose_name=_("Nom de l'appareil"),
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Enregistré le"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Mis à jour le"))
def __str__(self):
device = self.device_name or self.browser_name or _("Appareil inconnu")
return f"{self.user.username} ({device})"
class Meta:
verbose_name = _("Abonnement Web Push")
verbose_name_plural = _("Abonnements Web Push")
ordering = ['-created_at']