""" 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