669 lines
23 KiB
Python
669 lines
23 KiB
Python
"""
|
|
Notification services — point d'entrée pour créer des notifications depuis n'importe quelle app.
|
|
|
|
Usage depuis une autre app:
|
|
from notifications.services import notify_user, notify_team, notify_users
|
|
|
|
# Notification personnelle
|
|
notify_user(
|
|
recipient=user,
|
|
title="Nouvelle intervention assignée",
|
|
content="L'intervention #123 vous a été assignée.",
|
|
notification_type='task',
|
|
action_url='/interventions/123/',
|
|
action_label="Voir l'intervention",
|
|
source_app='interventions',
|
|
sender=request.user,
|
|
)
|
|
|
|
# Notification à une équipe
|
|
from notifications.models import Team
|
|
team = Team.objects.get(name='Équipe technique')
|
|
notify_team(
|
|
team=team,
|
|
title="Stock bas",
|
|
content="Le niveau de stock du produit X est critique.",
|
|
notification_type='alert',
|
|
source_app='stock',
|
|
)
|
|
"""
|
|
|
|
from django.contrib.auth.models import User
|
|
from django.utils import timezone
|
|
|
|
import re as _re
|
|
|
|
# Mention pattern: @[Display Name](u:123) or @[Team Name](t:456)
|
|
_MENTION_PATTERN = _re.compile(r'@\[([^\]]+)\]\(([ut]):(\d+)\)')
|
|
|
|
|
|
def _create_notification(
|
|
title,
|
|
content='',
|
|
notification_type='info',
|
|
action_url='',
|
|
action_label='',
|
|
source_app='',
|
|
sender=None,
|
|
expires_at=None,
|
|
is_system=False,
|
|
is_single_claim=False,
|
|
):
|
|
from .models import Notification
|
|
return Notification.objects.create(
|
|
title=title,
|
|
content=content,
|
|
notification_type=notification_type,
|
|
action_url=action_url,
|
|
action_label=action_label,
|
|
source_app=source_app,
|
|
sender=sender,
|
|
expires_at=expires_at,
|
|
is_system=is_system,
|
|
is_single_claim=is_single_claim,
|
|
)
|
|
|
|
|
|
def notify_user(
|
|
recipient,
|
|
title,
|
|
content='',
|
|
notification_type='info',
|
|
action_url='',
|
|
action_label='',
|
|
source_app='',
|
|
sender=None,
|
|
expires_at=None,
|
|
is_system=False,
|
|
):
|
|
"""
|
|
Envoie une notification personnelle à un utilisateur.
|
|
Retourne le NotificationRecipient créé.
|
|
"""
|
|
from .models import NotificationRecipient
|
|
notification = _create_notification(
|
|
title=title,
|
|
content=content,
|
|
notification_type=notification_type,
|
|
action_url=action_url,
|
|
action_label=action_label,
|
|
source_app=source_app,
|
|
sender=sender,
|
|
expires_at=expires_at,
|
|
is_system=is_system,
|
|
)
|
|
recipient_obj = NotificationRecipient.objects.create(
|
|
notification=notification,
|
|
user=recipient,
|
|
)
|
|
return recipient_obj
|
|
|
|
|
|
def notify_users(
|
|
recipients,
|
|
title,
|
|
content='',
|
|
notification_type='info',
|
|
action_url='',
|
|
action_label='',
|
|
source_app='',
|
|
sender=None,
|
|
expires_at=None,
|
|
is_system=False,
|
|
):
|
|
"""
|
|
Envoie une notification à plusieurs utilisateurs (crée un seul objet Notification
|
|
avec plusieurs NotificationRecipient).
|
|
Retourne la Notification créée.
|
|
"""
|
|
from .models import Notification, NotificationRecipient
|
|
notification = _create_notification(
|
|
title=title,
|
|
content=content,
|
|
notification_type=notification_type,
|
|
action_url=action_url,
|
|
action_label=action_label,
|
|
source_app=source_app,
|
|
sender=sender,
|
|
expires_at=expires_at,
|
|
is_system=is_system,
|
|
)
|
|
NotificationRecipient.objects.bulk_create([
|
|
NotificationRecipient(notification=notification, user=user)
|
|
for user in recipients
|
|
], ignore_conflicts=True)
|
|
return notification
|
|
|
|
|
|
def notify_team(
|
|
team,
|
|
title,
|
|
content='',
|
|
notification_type='info',
|
|
action_url='',
|
|
action_label='',
|
|
source_app='',
|
|
sender=None,
|
|
expires_at=None,
|
|
is_system=False,
|
|
):
|
|
"""
|
|
Envoie une notification à toute une équipe.
|
|
Retourne la TeamNotificationTarget créée.
|
|
"""
|
|
from .models import TeamNotificationTarget
|
|
notification = _create_notification(
|
|
title=title,
|
|
content=content,
|
|
notification_type=notification_type,
|
|
action_url=action_url,
|
|
action_label=action_label,
|
|
source_app=source_app,
|
|
sender=sender,
|
|
expires_at=expires_at,
|
|
is_system=is_system,
|
|
)
|
|
target = TeamNotificationTarget.objects.create(
|
|
notification=notification,
|
|
team=team,
|
|
)
|
|
return target
|
|
|
|
|
|
def get_user_notifications(user, include_archived=False):
|
|
"""
|
|
Retourne une liste unifiée de notifications pour un utilisateur,
|
|
combinant notifications personnelles et notifications d'équipe.
|
|
|
|
Chaque élément est un dict avec:
|
|
- notification: Notification
|
|
- is_read: bool
|
|
- is_archived: bool
|
|
- recipient_type: 'personal' | 'team'
|
|
- team_name: str (si team)
|
|
- recipient_id: int (NotificationRecipient.pk ou TeamNotificationTarget.pk)
|
|
"""
|
|
from .models import NotificationRecipient, TeamNotificationTarget, TeamNotificationRead
|
|
|
|
results = []
|
|
|
|
# --- Personal notifications ---
|
|
personal_qs = NotificationRecipient.objects.select_related(
|
|
'notification', 'notification__sender'
|
|
).prefetch_related('notification__attachments').filter(user=user)
|
|
if not include_archived:
|
|
personal_qs = personal_qs.filter(archived_at__isnull=True)
|
|
|
|
for nr in personal_qs:
|
|
if nr.notification.is_expired:
|
|
continue
|
|
results.append({
|
|
'notification': nr.notification,
|
|
'is_read': nr.is_read,
|
|
'is_archived': nr.is_archived,
|
|
'recipient_type': 'personal',
|
|
'team_name': None,
|
|
'recipient_id': nr.pk,
|
|
})
|
|
|
|
# --- Team notifications ---
|
|
user_team_ids = user.notification_teams.values_list('id', flat=True)
|
|
team_targets = TeamNotificationTarget.objects.select_related(
|
|
'notification', 'notification__sender', 'team'
|
|
).prefetch_related('notification__attachments').filter(team_id__in=user_team_ids)
|
|
|
|
read_ids = TeamNotificationRead.objects.filter(
|
|
user=user,
|
|
team_notification__in=team_targets,
|
|
).values_list('team_notification_id', flat=True)
|
|
|
|
for tt in team_targets:
|
|
if tt.notification.is_expired:
|
|
continue
|
|
results.append({
|
|
'notification': tt.notification,
|
|
'is_read': tt.pk in read_ids,
|
|
'is_archived': False,
|
|
'recipient_type': 'team',
|
|
'team_name': tt.team.name,
|
|
'recipient_id': tt.pk,
|
|
})
|
|
|
|
# Sort by created_at descending
|
|
results.sort(key=lambda x: x['notification'].created_at, reverse=True)
|
|
return results
|
|
|
|
|
|
def get_unread_count(user):
|
|
"""Retourne le nombre de notifications non lues pour un utilisateur."""
|
|
from .models import NotificationRecipient, TeamNotificationTarget, TeamNotificationRead
|
|
|
|
personal_unread = NotificationRecipient.objects.filter(
|
|
user=user,
|
|
read_at__isnull=True,
|
|
archived_at__isnull=True,
|
|
).count()
|
|
|
|
user_team_ids = user.notification_teams.values_list('id', flat=True)
|
|
team_targets = TeamNotificationTarget.objects.filter(team_id__in=user_team_ids)
|
|
team_read_ids = TeamNotificationRead.objects.filter(
|
|
user=user,
|
|
team_notification__in=team_targets,
|
|
).values_list('team_notification_id', flat=True)
|
|
team_unread = team_targets.exclude(pk__in=team_read_ids).count()
|
|
|
|
return personal_unread + team_unread
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# Discussion / Messaging services
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
def post_message(discussion, author, content, files=None):
|
|
"""
|
|
Poste un message dans une discussion.
|
|
|
|
- Crée l'objet Message (+ pièces jointes).
|
|
- Efface les notifications non lues liées à cette discussion pour tous les
|
|
participants (la réponse annule les alertes précédentes).
|
|
- Crée une nouvelle notification pour tous les participants sauf l'auteur.
|
|
- Met à jour discussion.updated_at pour la faire remonter dans les listes.
|
|
|
|
Retourne le Message créé.
|
|
"""
|
|
from .models import Discussion, Message, MessageAttachment, NotificationRecipient
|
|
|
|
message = Message.objects.create(discussion=discussion, author=author, content=content)
|
|
|
|
if files:
|
|
for f in files:
|
|
MessageAttachment.objects.create(message=message, file=f, name=f.name)
|
|
|
|
# Effacer les anciennes notifications non lues de cette discussion
|
|
NotificationRecipient.objects.filter(
|
|
notification__source_discussion=discussion,
|
|
read_at__isnull=True,
|
|
).update(read_at=timezone.now())
|
|
|
|
# Bump discussion.updated_at sans déclencher auto_now
|
|
Discussion.objects.filter(pk=discussion.pk).update(updated_at=timezone.now())
|
|
|
|
# Notifier les utilisateurs et équipes mentionnés dans le message
|
|
_notify_mentions(discussion, author, content)
|
|
|
|
return message
|
|
|
|
|
|
def _notify_mentions(discussion, author, content):
|
|
"""
|
|
Parse les mentions @[Name](u:id) et @[Name](t:id) dans le contenu d'un message
|
|
et envoie des notifications ciblées aux personnes/équipes mentionnées.
|
|
Ajoute également les utilisateurs mentionnés comme participants de la discussion.
|
|
"""
|
|
from .models import Notification, NotificationRecipient, TeamNotificationTarget, Team
|
|
|
|
mentioned_user_ids = set()
|
|
mentioned_team_ids = set()
|
|
|
|
for m in _MENTION_PATTERN.finditer(content):
|
|
kind = m.group(2)
|
|
obj_id = int(m.group(3))
|
|
if kind == 'u':
|
|
mentioned_user_ids.add(obj_id)
|
|
else:
|
|
mentioned_team_ids.add(obj_id)
|
|
|
|
if not mentioned_user_ids and not mentioned_team_ids:
|
|
return
|
|
|
|
action_url = discussion.object_url or discussion.get_absolute_url()
|
|
action_label = discussion.object_label or str(discussion)
|
|
author_name = author.get_full_name().strip() or author.username if author else "Système"
|
|
exclude_pk = author.pk if author else -1
|
|
|
|
title = f"{author_name} vous a mentionné — {discussion.title or str(discussion)}"
|
|
msg_preview = content[:200]
|
|
|
|
# Mentions d'utilisateurs individuels
|
|
users_to_notify = list(
|
|
User.objects.filter(pk__in=mentioned_user_ids).exclude(pk=exclude_pk)
|
|
)
|
|
if users_to_notify:
|
|
notif = Notification.objects.create(
|
|
title=title,
|
|
content=msg_preview,
|
|
notification_type=Notification.TYPE_MESSAGE,
|
|
sender=author,
|
|
action_url=action_url,
|
|
action_label=action_label,
|
|
source_app='discussions',
|
|
source_discussion=discussion,
|
|
)
|
|
NotificationRecipient.objects.bulk_create([
|
|
NotificationRecipient(notification=notif, user=u)
|
|
for u in users_to_notify
|
|
], ignore_conflicts=True)
|
|
# Ajouter les utilisateurs mentionnés comme participants
|
|
discussion.participants.add(*users_to_notify)
|
|
|
|
# Mentions d'équipes
|
|
if mentioned_team_ids:
|
|
teams = Team.objects.filter(pk__in=mentioned_team_ids).prefetch_related('members')
|
|
for team in teams:
|
|
notif = Notification.objects.create(
|
|
title=title,
|
|
content=msg_preview,
|
|
notification_type=Notification.TYPE_MESSAGE,
|
|
sender=author,
|
|
action_url=action_url,
|
|
action_label=action_label,
|
|
source_app='discussions',
|
|
source_discussion=discussion,
|
|
)
|
|
TeamNotificationTarget.objects.create(notification=notif, team=team)
|
|
# Ajouter les membres de l'équipe comme participants
|
|
discussion.participants.add(*team.members.all())
|
|
|
|
|
|
def mark_discussion_seen(discussion, user=None):
|
|
"""
|
|
Marque comme lues les notifications non lues liées à cette discussion.
|
|
Si `user` est fourni, seules les notifications de cet utilisateur sont marquées.
|
|
"""
|
|
from .models import NotificationRecipient
|
|
qs = NotificationRecipient.objects.filter(
|
|
notification__source_discussion=discussion,
|
|
read_at__isnull=True,
|
|
)
|
|
if user is not None:
|
|
qs = qs.filter(user=user)
|
|
qs.update(read_at=timezone.now())
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# Règles de notification automatiques
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
def process_notification_rules(event, context, source_app=''):
|
|
"""
|
|
Déclenche les règles de notification actives correspondant à *event*.
|
|
|
|
Arguments
|
|
---------
|
|
event : str
|
|
Identifiant de l'événement, ex. ``'intervention_canceled'``.
|
|
context : dict
|
|
Données contextuelles utilisées pour :
|
|
- filtrer les règles (ex. ``cancellation_type``) ;
|
|
- interpoler les modèles de titre/contenu (format Python ``{var}``) ;
|
|
- fournir ``action_url`` et ``action_label`` pour le lien.
|
|
source_app : str
|
|
Application source enregistrée sur les notifications créées.
|
|
|
|
Exemple d'appel (depuis interventions) ::
|
|
|
|
from notifications.services import process_notification_rules
|
|
|
|
process_notification_rules(
|
|
event='intervention_canceled',
|
|
context={
|
|
'intervention_code': intervention.code,
|
|
'intervention_title': intervention.title,
|
|
'cancellation_type': 'to_redirect',
|
|
'cancellation_type_label': 'A rediriger',
|
|
'cancellation_reason': reason_text,
|
|
'action_url': '/interventions/42/',
|
|
'action_label': "Voir l'intervention",
|
|
},
|
|
source_app='interventions',
|
|
)
|
|
"""
|
|
from .models import NotificationRule, NotificationRecipient
|
|
|
|
rules = NotificationRule.objects.filter(
|
|
is_active=True, trigger_event=event
|
|
).prefetch_related('recipient_teams', 'recipient_users')
|
|
|
|
cancellation_type = context.get('cancellation_type', '')
|
|
|
|
for rule in rules:
|
|
# Filtre conditionnel : si la règle précise un type d'annulation,
|
|
# on ne déclenche que si ça correspond.
|
|
if rule.trigger_cancellation_type and rule.trigger_cancellation_type != cancellation_type:
|
|
continue
|
|
|
|
# Préparer les variables sécurisées pour l'interpolation
|
|
safe_ctx = {k: (v or '') for k, v in context.items()}
|
|
|
|
try:
|
|
title = rule.title_template.format(**safe_ctx)
|
|
except (KeyError, ValueError):
|
|
title = rule.title_template
|
|
|
|
try:
|
|
content = rule.content_template.format(**safe_ctx) if rule.content_template else ''
|
|
except (KeyError, ValueError):
|
|
content = rule.content_template
|
|
|
|
notification = _create_notification(
|
|
title=title,
|
|
content=content,
|
|
notification_type=rule.notification_type,
|
|
action_url=safe_ctx.get('action_url', ''),
|
|
action_label=safe_ctx.get('action_label', ''),
|
|
source_app=source_app,
|
|
is_system=True,
|
|
is_single_claim=rule.single_claim,
|
|
)
|
|
|
|
# Collecter tous les utilisateurs destinataires :
|
|
# - membres (CompanyMember.user) des équipes CompanyTeam configurées
|
|
# - utilisateurs individuels configurés
|
|
from contracts.models import CompanyMember
|
|
recipient_user_ids = set()
|
|
for team in rule.recipient_teams.all():
|
|
team_user_ids = CompanyMember.objects.filter(
|
|
teams=team, user__isnull=False
|
|
).values_list('user_id', flat=True)
|
|
recipient_user_ids.update(team_user_ids)
|
|
for u in rule.recipient_users.all():
|
|
recipient_user_ids.add(u.pk)
|
|
|
|
if recipient_user_ids:
|
|
NotificationRecipient.objects.bulk_create(
|
|
[NotificationRecipient(notification=notification, user_id=uid)
|
|
for uid in recipient_user_ids],
|
|
ignore_conflicts=True,
|
|
)
|
|
|
|
|
|
def can_post_observation_message(user, observation):
|
|
"""
|
|
Retourne True si l'utilisateur peut poster un message dans la discussion
|
|
d'une observation. Critères :
|
|
- C'est le créateur de l'observation, OU
|
|
- Il est admin/superuser/operator, OU
|
|
- Il peut traiter l'observation (process_thematic_ids), OU
|
|
- Il est membre d'une équipe gestionnaire d'assets (is_assets_manager=True)
|
|
qui gère la catégorie de l'observation.
|
|
Cohérence : tout utilisateur disposant des actions peut aussi poster un message.
|
|
"""
|
|
if not user or not user.is_authenticated:
|
|
return False
|
|
if user.is_superuser:
|
|
return True
|
|
user_config = getattr(user, 'config', None)
|
|
if user_config and hasattr(user_config, 'has_role'):
|
|
if user_config.has_role('admin') or user_config.has_role('operator'):
|
|
return True
|
|
if observation.created_by_id and observation.created_by_id == user.pk:
|
|
return True
|
|
# Check if user can process this observation via process_thematic_ids
|
|
try:
|
|
from observations.permissions import get_observation_access_context
|
|
access_context = get_observation_access_context(user, user_config=user_config)
|
|
if access_context and access_context.can_process_observation(observation):
|
|
return True
|
|
except Exception:
|
|
pass
|
|
if observation.category_id:
|
|
try:
|
|
from contracts.models import CompanyMember
|
|
return CompanyMember.objects.filter(
|
|
user=user,
|
|
teams__is_assets_manager=True,
|
|
teams__asset_categories=observation.category,
|
|
).exists()
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
|
|
def get_or_create_observation_discussion(observation):
|
|
"""
|
|
Trouve ou crée la discussion associée à une observation.
|
|
|
|
Les participants sont : le créateur de l'observation + les membres des
|
|
équipes gestionnaires d'assets (is_assets_manager=True) pour la catégorie
|
|
concernée.
|
|
Retourne (discussion, created).
|
|
"""
|
|
from .models import Discussion
|
|
from django.contrib.contenttypes.models import ContentType as CT
|
|
from django.urls import reverse
|
|
|
|
ct = CT.objects.get_for_model(observation)
|
|
discussion = Discussion.objects.filter(
|
|
content_type=ct, object_id=observation.pk
|
|
).first()
|
|
|
|
if discussion:
|
|
return discussion, False
|
|
|
|
try:
|
|
obj_url = reverse('observations:observation_detail', args=[observation.pk])
|
|
except Exception:
|
|
obj_url = ''
|
|
|
|
obj_label = f"Observation {observation.code}"
|
|
|
|
discussion = Discussion.objects.create(
|
|
title=obj_label,
|
|
created_by=observation.created_by,
|
|
content_type=ct,
|
|
object_id=observation.pk,
|
|
object_url=obj_url,
|
|
object_label=obj_label,
|
|
)
|
|
|
|
participants = set()
|
|
if observation.created_by_id:
|
|
participants.add(observation.created_by)
|
|
|
|
if observation.category_id:
|
|
try:
|
|
from contracts.models import CompanyMember
|
|
from django.contrib.auth.models import User as AuthUser
|
|
manager_users = AuthUser.objects.filter(
|
|
companymember__teams__is_assets_manager=True,
|
|
companymember__teams__asset_categories=observation.category,
|
|
).distinct()
|
|
participants.update(manager_users)
|
|
except Exception:
|
|
pass
|
|
|
|
discussion.participants.set(participants)
|
|
return discussion, True
|
|
|
|
|
|
def can_post_intervention_message(user, intervention):
|
|
"""
|
|
Retourne True si l'utilisateur peut poster un message dans la discussion
|
|
d'une intervention. Critères :
|
|
- Il est superuser, OU
|
|
- Il est admin/operator, OU
|
|
- Il peut voir l'intervention (can_view_intervention).
|
|
"""
|
|
if not user or not user.is_authenticated:
|
|
return False
|
|
if user.is_superuser:
|
|
return True
|
|
user_config = getattr(user, 'config', None)
|
|
if user_config and hasattr(user_config, 'has_role'):
|
|
if user_config.has_role('admin') or user_config.has_role('operator'):
|
|
return True
|
|
try:
|
|
from interventions.permissions import can_view_intervention
|
|
return can_view_intervention(user, intervention)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def get_or_create_intervention_discussion(intervention):
|
|
"""
|
|
Trouve ou crée la discussion associée à une intervention.
|
|
|
|
Les participants sont : les utilisateurs ayant un accès contrat à cette
|
|
intervention + les membres de l'équipe assignée (si présente).
|
|
Retourne (discussion, created).
|
|
"""
|
|
from .models import Discussion
|
|
from django.contrib.contenttypes.models import ContentType as CT
|
|
from django.urls import reverse
|
|
|
|
ct = CT.objects.get_for_model(intervention)
|
|
discussion = Discussion.objects.filter(
|
|
content_type=ct, object_id=intervention.pk
|
|
).first()
|
|
|
|
if discussion:
|
|
return discussion, False
|
|
|
|
try:
|
|
obj_url = reverse('interventions:intervention_detail', args=[intervention.pk])
|
|
except Exception:
|
|
obj_url = ''
|
|
|
|
obj_label = f"Intervention {intervention.code}"
|
|
|
|
discussion = Discussion.objects.create(
|
|
title=obj_label,
|
|
created_by=intervention.created_by,
|
|
content_type=ct,
|
|
object_id=intervention.pk,
|
|
object_url=obj_url,
|
|
object_label=obj_label,
|
|
)
|
|
|
|
participants = set()
|
|
|
|
# Membres de l'équipe assignée
|
|
if intervention.assigned_team:
|
|
try:
|
|
from contracts.models import CompanyMember
|
|
from django.contrib.auth.models import User as AuthUser
|
|
team_users = AuthUser.objects.filter(
|
|
companymember__teams=intervention.assigned_team,
|
|
).distinct()
|
|
participants.update(team_users)
|
|
except Exception:
|
|
pass
|
|
|
|
# Utilisateurs avec accès contrat
|
|
if intervention.contract:
|
|
try:
|
|
from common.models import UserContractAccess
|
|
from django.contrib.auth.models import User as AuthUser
|
|
contract_users = AuthUser.objects.filter(
|
|
config__usercontractaccess__contract=intervention.contract,
|
|
config__usercontractaccess__can_view_interventions=True,
|
|
).distinct()
|
|
participants.update(contract_users)
|
|
except Exception:
|
|
pass
|
|
|
|
if participants:
|
|
discussion.participants.set(participants)
|
|
return discussion, True
|
|
|
|
|