loko/streetup/notifications/services.py
2026-07-22 14:48:40 +02:00

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