from django.http import HttpResponse, JsonResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseRedirect from django.shortcuts import render, get_object_or_404, redirect from django.template.loader import render_to_string from django.urls import reverse, NoReverseMatch from django.core.serializers import serialize from django.core.exceptions import FieldDoesNotExist from django.core.files.base import ContentFile from django.contrib.gis.db.models.functions import Transform, Centroid, AsGeoJSON, Distance from django.contrib.gis.geos import GEOSGeometry, Point, MultiPolygon from django.contrib.gis.measure import D from django.contrib.contenttypes.models import ContentType from django.contrib import messages from django.views.decorators.http import require_POST, require_GET, require_http_methods from django.contrib.auth.decorators import login_required from django.utils.timezone import now, localtime, make_aware, get_current_timezone, is_naive from django.utils import timezone as django_timezone from django.utils import timezone from django.utils.dateparse import parse_datetime from django.utils.dateformat import format as dj_format_date from django.utils.text import Truncator from django.utils.html import strip_tags from django.template.defaultfilters import linebreaksbr from django.utils.translation import get_language, gettext as _, gettext_lazy as _lazy, ngettext from django.apps import apps from django.conf import settings from django.core.mail import EmailMultiAlternatives from django.db.models import F, DateTimeField, Q, Case, When, IntegerField, CharField, Value, Count, Prefetch, Sum, Max, QuerySet, OuterRef, Subquery, Exists from django.db.models.functions import Coalesce, Lower, Cast from django.db.models.expressions import ExpressionWrapper from django.db.models import DurationField from django.db.models.fields.related import ForeignKey from django.db import transaction from collections import defaultdict, OrderedDict from decimal import Decimal, InvalidOperation import json import os import io import zipfile from fpdf import FPDF import pytz from urllib.parse import urlencode import re from html import unescape from typing import List, Iterable, Tuple, Dict, Optional, Set import mimetypes import csv from io import BytesIO from datetime import timedelta, datetime from dateutil.relativedelta import relativedelta from common.private_files.helpers import build_private_url from common.utils import get_short_name from interventions.models import (InterventionSubscription, Intervention, InterventionAsset, InterventionTimeLine, InterventionContractPost, InterventionDocument, InterventionNote, InterventionLocation, InterventionLink, ContractPauseReasonAssignment, Symptom, SourceCategory, InterventionPlanificationTimeLine, InterventionRealizationTimeLine, InterventionOccupation, Operation, OperationTemplate, OperationTemplateItem, OperationStatus, OperationStatusSet, MeasurementDefinition, GuidedOperationTemplate, GuidedOperationData, GuidedOperationMedia, InterventionPostPeriodQuantity, InterventionEmailLog, InterventionAssignment) from interventions.models import STATUS_CHOICES, STATUS_ORDERS, MAINTAIN_CHOICES, TYPE_CHOICES, ORIGIN_TYPE_CHOICES, PAUSE_REASON_CHOICES from interventions.views.forms import (InterventionForm, InterventionNoteForm, InterventionDocumentForm, InterventionOccupationForm, PreventiveInterventionForm, AssetsLinkForm, LinkExistingInterventionsForm) from interventions.permissions import (intervention_permission_required, intervention_add_permission_required, intervention_edit_permission_required, intervention_add_preventive_permission_required, get_allowed_statuses_for_user, get_allowed_update_fields, can_view_intervention, can_control_intervention, can_edit_intervention, can_view_intervention_summary, get_accessible_interventions_for_technician, filter_viewable_interventions_for_user, can_edit_note, can_delete_document, can_manage_occupations, can_add_intervention, can_add_repair_intervention, intervention_manage_occupations_required, get_contracts_for_intervention_creation, is_external_manager_with_contract_creation_rights, should_show_created_by_provider_badge, can_view_correction_messages) from interventions.permissions import ALLOWED_TRANSITIONS, get_allowed_transitions, user_allowed_transitions from interventions.views.drafts import InterventionDraftManager from interventions.utils import resolve_intervention_localisations from common.models import UserConfig, UserContractAccess, UserContractStatusPermission, Thematic, UserThematics from contracts.models import ContractOrder, ContractPost, Contract, Company, CompanyTeam, CompanyMember, UserCompanyTeamAccess, CompanyMemberHistory from contracts.permissions import get_allowed_contracts_for_user, user_has_access_to_contract_order from projects.models import Project, ProjectIntervention from assets.models import ( PublicLightingStreet, TrafficLightIntersection, TrafficLightContract, NatureLocation, StructureLocation, Structure, StructureContract, RoadStreet, SignStreet, AssetCategory, AbstractAsset, AbstractAssetModel, ITSGeoAsset, ITSGeoAssetContract, CleanLocation, CleanLocationContract, ) from assets.permissions import get_allowed_actions_for_asset from assets.views.locations import LOCATION_REGISTRY, registry_items_for_thematics, base_search_qs, SEARCH_LIMIT_PER_TYPE, SEARCH_LIMIT_TOTAL from common.utils import redirect_with_variant, measure_time, strip_accents_lower from observations.permissions import get_observation_access_context from observations.models import Observation as ObservationModel from controls.permissions import user_can_view_control from interventions.views.views_pdf_reporting import ( generate_intervention_pdf, preview_intervention_pdf, download_intervention_report_pdf, preview_intervention_report_pdf, bulk_execution_report_pdf, start_bulk_execution_report, poll_bulk_execution_report, download_bulk_execution_report, ) import logging import smtplib import socket import ssl logger = logging.getLogger(__name__) from interventions.views.helpers import * def update_intervention_status(request, intervention_id): new_status = request.POST.get('new_status') if not intervention_id or not new_status: return JsonResponse({'success': False, 'message': _("Paramètres manquants.")}, status=400) try: intervention = Intervention.objects.get(id=intervention_id) except Intervention.DoesNotExist: return JsonResponse({'success': False, 'message': _("Intervention introuvable.")}, status=404) pause_reason = request.POST.get('pause_reason') pause_reason_other = request.POST.get('pause_reason_other', '') cancellation_reason = request.POST.get('cancellation_reason', '') cancellation_type = request.POST.get('cancellation_type', '') success, message, email_failed = attempt_status_update( request, intervention, request.user, new_status, pause_reason=pause_reason, pause_reason_other=pause_reason_other, cancellation_reason=cancellation_reason, cancellation_type=cancellation_type, ) if not success: return JsonResponse({'success': False, 'message': str(message)}, status=403) response_data = {'success': True, 'message': message} if email_failed: response_data['email_failed'] = True response_data['email_warning'] = _("Le statut a été modifié mais l'envoi de l'email a échoué.") # Si l'intervention est liée à une observation et passe au statut 'to_be_processed', # vérifier s'il y a une URL de retour vers la liste des observations if new_status in ['to_be_approved', 'to_be_planned', 'to_be_processed']: from observations.models import Observation linked_observation = Observation.objects.filter(intervention_id=intervention_id).first() if linked_observation: session_key = f'observation_{linked_observation.id}_return_url' return_url = request.session.get(session_key) if return_url: # Nettoyer la session del request.session[session_key] response_data['redirect_url'] = return_url return JsonResponse(response_data) def update_multiple_intervention_statuses(request): data = json.loads(request.body) ids = data.get('ids', []) new_status = data.get('new_status') pause_reason = data.get('pause_reason') pause_reason_other = data.get('pause_reason_other', '') cancellation_reason = data.get('cancellation_reason', '') cancellation_type = data.get('cancellation_type', '') if not ids or not new_status: return JsonResponse({'error': _('Paramètres manquants.')}, status=400) invalid_ids = [] error_messages = {} email_failed_count = 0 for id in ids: try: intervention = Intervention.objects.get(id=id) except Intervention.DoesNotExist: invalid_ids.append(id) continue success, val, email_failed = attempt_status_update( request, intervention, request.user, new_status, pause_reason=pause_reason, pause_reason_other=pause_reason_other, cancellation_reason=cancellation_reason, cancellation_type=cancellation_type, ) if not success: invalid_ids.append(id) # Enregistrer le message d'erreur pour cette intervention error_messages[id] = str(val) elif email_failed: email_failed_count += 1 response_data = {'invalid_ids': invalid_ids, 'error_messages': error_messages} if email_failed_count > 0: response_data['email_failed_count'] = email_failed_count response_data['email_warning'] = _("%(count)s email(s) n'ont pas pu être envoyé(s).") % {'count': email_failed_count} return JsonResponse(response_data) def toggle_intervention_checked(request, intervention_id): """ Bascule l'état 'vérifié' (is_checked) d'une intervention. Seul un utilisateur ayant le droit can_check_interventions sur le contrat de l'intervention peut effectuer cette action. Le contrat doit par ailleurs avoir needs_checking=True. Ce bouton n'est pertinent que lorsque l'intervention est au statut 'to_be_approved' ou 'to_be_processed'. """ try: intervention = Intervention.objects.select_related('contract').get(pk=intervention_id) except Intervention.DoesNotExist: return JsonResponse({'success': False, 'message': _("Intervention introuvable.")}, status=404) # Vérifier que le contrat nécessite une vérification if not intervention.contract or not getattr(intervention.contract, 'needs_checking', False): return JsonResponse({'success': False, 'message': _("Ce contrat ne nécessite pas de vérification.")}, status=400) # Vérifier que le statut est adéquat if intervention.status not in ('to_be_approved', 'to_be_processed'): return JsonResponse({'success': False, 'message': _("La vérification n'est disponible que pour les statuts 'À approuver' et 'À traiter'.")}, status=400) # Vérifier le droit can_check_interventions user_config = UserConfig.objects.filter(user=request.user).first() if not user_config: return JsonResponse({'success': False, 'message': _("Configuration utilisateur introuvable.")}, status=403) has_right = UserContractAccess.objects.filter( user_config=user_config, contract=intervention.contract, can_check_interventions=True, ).exists() if not has_right: return JsonResponse({'success': False, 'message': _("Vous n'avez pas le droit de vérifier les interventions de ce contrat.")}, status=403) # Basculer l'état intervention.is_checked = not intervention.is_checked intervention.save(update_fields=['is_checked']) return JsonResponse({ 'success': True, 'is_checked': intervention.is_checked, 'message': _("Intervention marquée comme vérifiée.") if intervention.is_checked else _("Vérification annulée."), }) def toggle_follow_up_performed(request, intervention_id): """ Enregistre le résultat du contrôle (follow_up_performed) d'une intervention. Accepte une valeur JSON : {"value": "ok"}, {"value": "attention"}, {"value": "not_ok"} ou {"value": null}. Seul un utilisateur ayant le droit 'follow_up_reports' sur l'intervention peut effectuer cette action. """ try: intervention = Intervention.objects.get(pk=intervention_id) except Intervention.DoesNotExist: return JsonResponse({'success': False, 'message': _("Intervention introuvable.")}, status=404) user_config = UserConfig.objects.filter(user=request.user).first() if not user_config: return JsonResponse({'success': False, 'message': _("Configuration utilisateur introuvable.")}, status=403) allowed_fields = get_allowed_update_fields(request.user, intervention) if 'follow_up_reports' not in allowed_fields: return JsonResponse({'success': False, 'message': _("Vous n'avez pas le droit de modifier les contrôles de cette intervention.")}, status=403) try: data = json.loads(request.body) except (json.JSONDecodeError, AttributeError): return JsonResponse({'success': False, 'message': _("Format JSON invalide.")}, status=400) valid_values = {'ok', 'attention', 'not_ok', None} raw_value = data.get('value') if raw_value not in valid_values: return JsonResponse({'success': False, 'message': _("Valeur invalide.")}, status=400) intervention.follow_up_performed = raw_value intervention.save(update_fields=['follow_up_performed']) return JsonResponse({ 'success': True, 'follow_up_performed': intervention.follow_up_performed, }) def update_correction_reason(request, intervention_id): """ Sauvegarde la raison de correction quand le statut passe à 'à corriger' """ try: data = json.loads(request.body) reason = (data.get('reason_for_correction') or "").strip() except json.JSONDecodeError: return JsonResponse({'success': False, 'error': _("Format JSON invalide")}, status=400) if not reason: return JsonResponse({'success': False, 'error': _("La raison ne peut pas être vide")}, status=400) try: intervention = Intervention.objects.get(id=intervention_id) except Intervention.DoesNotExist: return JsonResponse({'success': False, 'error': _("Intervention introuvable")}, status=404) # Vérifier les permissions if not can_edit_intervention(request.user, intervention): return JsonResponse({'success': False, 'error': _("Pas de permission pour modifier cette intervention")}, status=403) # Vérifier que l'utilisateur a un rôle autorisé try: user_config = UserConfig.objects.get(user=request.user) allowed_roles = ['admin', 'manager', 'controller', 'external_manager'] user_roles = list(user_config.roles.values_list('name', flat=True)) if not any(role in allowed_roles for role in user_roles): return JsonResponse({'success': False, 'error': _("Permission insuffisante")}, status=403) except UserConfig.DoesNotExist: return JsonResponse({'success': False, 'error': _("Configuration utilisateur non trouvée")}, status=403) # Sauvegarder la raison intervention.reason_for_correction = reason intervention.last_modified_by = request.user intervention.save() return JsonResponse({'success': True, 'message': _("Raison de correction sauvegardée")}) def update_correction_details(request, intervention_id): """ Sauvegarde les détails de la correction quand le statut passe à 'corrigé' """ try: data = json.loads(request.body) details = (data.get('correction_details') or "").strip() except json.JSONDecodeError: return JsonResponse({'success': False, 'error': _("Format JSON invalide")}, status=400) if not details: return JsonResponse({'success': False, 'error': _("Les détails ne peuvent pas être vides")}, status=400) try: intervention = Intervention.objects.get(id=intervention_id) except Intervention.DoesNotExist: return JsonResponse({'success': False, 'error': _("Intervention introuvable")}, status=404) # Vérifier les permissions if not can_edit_intervention(request.user, intervention): return JsonResponse({'success': False, 'error': _("Pas de permission pour modifier cette intervention")}, status=403) # Vérifier que l'utilisateur a un rôle autorisé try: user_config = UserConfig.objects.get(user=request.user) allowed_roles = ['admin', 'manager', 'controller', 'external_manager'] user_roles = list(user_config.roles.values_list('name', flat=True)) if not any(role in allowed_roles for role in user_roles): return JsonResponse({'success': False, 'error': _("Permission insuffisante")}, status=403) except UserConfig.DoesNotExist: return JsonResponse({'success': False, 'error': _("Configuration utilisateur non trouvée")}, status=403) # Sauvegarder les détails intervention.correction_details = details intervention.last_modified_by = request.user intervention.save() return JsonResponse({'success': True, 'message': _("Détails de correction sauvegardés")}) def attempt_status_update(request, intervention, user, new_status, pause_reason=None, pause_reason_other="", cancellation_reason="", cancellation_type=""): current_status = intervention.status email_failed = False # Track email sending failure if not can_edit_intervention(user, intervention): return False, _("Pas de permission pour modifier cette intervention."), False if current_status == new_status: return True, _("Statut inchangé."), False # Photos obligatoires : si l'équipe assignée l'exige, au moins une photo post-'to_be_processed' est nécessaire if new_status == 'finished': assigned_team = intervention.assigned_team if assigned_team and assigned_team.photos_required: photos_since = _get_photos_required_since(intervention) if photos_since: has_photos = intervention.documents.filter(upload_date__gte=photos_since).exists() else: has_photos = intervention.documents.exists() if not has_photos: return False, _("Au moins une photo ajoutée après le passage au statut 'À traiter' est obligatoire pour terminer cette intervention."), False # Occupations obligatoires : si le contrat l'exige pour les interventions correctives, # au moins une occupation doit être enregistrée avant de passer au statut 'terminé' ou supérieur. if ( intervention.maintain_type == 'corrective' and STATUS_ORDERS.get(new_status, 0) >= STATUS_ORDERS.get('finished', 25) and new_status != 'canceled' # on exclut le cas où une intervention est annulée and intervention.contract and getattr(intervention.contract, 'occupations_required', False) ): if not intervention.occupations.exists(): return False, _("Au moins une occupation doit être enregistrée avant de terminer cette intervention corrective."), False # Si l'intervention est au statut 'to_be_approved' et qu'un contrat est renseigné, # vérifier que l'utilisateur possède le droit d'approbation sur ce contrat. if current_status == 'to_be_approved' and intervention.contract_id: from interventions.permissions import can_approve_intervention if not can_approve_intervention(user, intervention): return False, _("Vous n'avez pas le droit d'approuver les interventions de ce contrat."), False # Vérifie la transition dans la matrice # allowed_transitions = ALLOWED_TRANSITIONS.get(current_status, []) # if new_status not in allowed_transitions: # return False, f"Passage de '{current_status}' à '{new_status}' interdit.", False # Petit helper local pour enregistrer l'historique des quantités par période def _record_period_quantities(): try: period_no = getattr(intervention, 'period_number', 1) or 1 except Exception: period_no = 1 posts_qs = InterventionContractPost.objects.filter(intervention=intervention, control_status='validated') for post in posts_qs.select_related('contract_post'): qty = post.quantity or Decimal('0') # Enregistrer uniquement les quantités strictement définies (>= 0) try: InterventionPostPeriodQuantity.objects.update_or_create( intervention=intervention, contract_post=post.contract_post, period_number=period_no, defaults={ 'quantity': qty, 'validated_at': now(), 'validated_by': user, 'status': 'validated', # Les périodes enregistrées sont validées } ) except Exception as _: # Ne bloque pas le flux en cas de problème isolé logger.warning("Impossible d'enregistrer l'historique de période pour le poste %s (interv %s, période %s)", getattr(post.contract_post, 'id', None), getattr(intervention, 'id', None), period_no) # Cas spécial: validation par périodes if new_status == 'validated' and getattr(intervention, 'period_validation_enabled', False): # Si dernière période cochée -> on laisse passer la validation (contrôle plus bas) if not getattr(intervention, 'period_is_final', False): # Rollover vers 'to_be_planned' et incrément période si confirmation explicite confirm_rollover = request.POST.get('period_validation_rollover') in ['1', 'true', 'True'] or \ (request.content_type and 'application/json' in request.content_type and (request.body and b'period_validation_rollover' in request.body)) if not confirm_rollover: # Si pas de confirmation, refuser avec message clair côté API return False, _("Confirmation de passage à la période suivante requise."), False # Vérifier que toutes les quantités saisies ont été validées not_validated = InterventionContractPost.objects.filter(intervention=intervention).filter(Q(quantity__gt=0) & ~Q(control_status='validated')).exists() if not_validated: return False, _("Toutes les quantités enregistrées doivent être validées avant de clôturer la période."), False # Avant de passer à la période suivante, enregistrer l'instantané des quantités de la période courante _record_period_quantities() # Forcer le retour à 'to_be_planned' indépendamment de la matrice old_status = intervention.status intervention.status = 'to_be_planned' intervention.status_order = STATUS_ORDERS.get('to_be_planned', 5) intervention.assigned_team = None # déassigner l'équipe intervention.assigned_member = None # déassigner le membre # Incrémente la période try: intervention.period_number = (intervention.period_number or 1) + 1 except Exception: intervention.period_number = 2 intervention.save() # Nettoyer les postes pour la nouvelle période InterventionContractPost.objects.filter(intervention=intervention).update( quantity=None, comment='', control_status=None, control_comment='', ) InterventionTimeLine.objects.create( intervention=intervention, event_user=user, event_time=now(), event_type='status_change', from_status=old_status, to_status='validated', ) InterventionTimeLine.objects.create( intervention=intervention, event_user=user, event_time=now(), event_type='status_change', from_status=old_status, to_status='to_be_planned', ) return True, _("Passage à la période suivante: retour à 'A planifier'."), False # Flux normal: vérifier la transition autorisée allowed_transitions_for_user = user_allowed_transitions(user, current_status, intervention=intervention) if new_status not in allowed_transitions_for_user: return False, _("Transition non autorisée."), False # Contrôle spécifique : un prestataire (external_manager) ne peut annuler que les interventions # créées par un prestataire (created_by_provider=True) et pas encore au statut 'processed'. if new_status == 'canceled': try: from common.models import UserConfig as _UC _uc = _UC.objects.get(user=user) _roles = set(_uc.roles.values_list('name', flat=True)) except Exception: _roles = set() _is_external_manager_only = ( 'external_manager' in _roles and not _roles.intersection({'admin', 'manager', 'operator', 'controller'}) ) if _is_external_manager_only and not intervention.created_by_provider: return False, _("Vous ne pouvez annuler que les interventions créées par votre service."), False # Vérifier qu'un contrat est indiqué pour les statuts to_be_planned et to_be_processed # Exception : si la thématique autorise la planification sans contrat, la vérification # est levée pour le passage à 'to_be_planned' uniquement. Pour 'to_be_processed' et # les statuts suivants, le contrat reste obligatoire même dans ce cas. _thematic_allows_planning_without_contract = getattr(intervention.thematic, 'allow_planning_without_contract', False) if new_status == 'to_be_planned': if not intervention.contract and not _thematic_allows_planning_without_contract: return False, _("Un contrat doit être indiqué avant de passer à ce statut."), False elif new_status == 'to_be_processed': if not intervention.contract: return False, _("Un contrat doit être indiqué avant de passer à ce statut."), False if new_status == 'to_be_planned': # Réinitialise l'équipe/membre assigné intervention.assigned_team = None intervention.assigned_member = None # Si "libération" : passage de 'assigned' à 'to_be_processed', désassigner le membre if new_status == 'to_be_processed' and current_status == 'assigned': intervention.assigned_member = None # On peut garder l'équipe ou la réinitialiser aussi - ici on la garde # Si passage à 'assigned', assigner automatiquement le membre (CompanyMember) de l'utilisateur if new_status == 'assigned': from contracts.models import CompanyMember company_member = CompanyMember.objects.filter(user=user).first() if company_member: intervention.assigned_member = company_member # Si le membre a une équipe, l'assigner également first_team = company_member.teams.first() if first_team: intervention.assigned_team = first_team pause_description = "" if new_status == 'on_pause': pause_reason_other = (pause_reason_other or "").strip() if pause_reason not in PAUSE_REASON_MAP: return False, _("Veuillez sélectionner une raison de mise en pause."), False if pause_reason == 'other' and not pause_reason_other: return False, _("Veuillez préciser la raison de la mise en pause."), False pause_description = format_pause_description(pause_reason, pause_reason_other) intervention.pause_reason = pause_reason intervention.pause_reason_other = pause_reason_other if pause_reason == 'other' else None # Réassignation automatique selon le motif de pause configuré sur le contrat if intervention.contract_id: pause_assignment = ContractPauseReasonAssignment.objects.filter( contract_id=intervention.contract_id, pause_reason=pause_reason, ).select_related('assigned_member').first() if pause_assignment is not None: intervention.assigned_member = pause_assignment.assigned_member else: intervention.assigned_member = None # désassigner le membre en cas de pause else: intervention.assigned_member = None # désassigner le membre en cas de pause # Vérifie l'état des postes de contrat pour le statut 'validated' if new_status == 'validated': if intervention.interv_contract_posts.exclude(control_status='validated').exists(): return False, _("Tous les postes de contrat doivent être validés avant de passer à 'validé'."), False # Si validation par périodes active mais dernière période non cochée, empêcher sans rollover explicite if getattr(intervention, 'period_validation_enabled', False) and not getattr(intervention, 'period_is_final', False): return False, _("L'option de validation par périodes est active. Marquez la dernière période ou confirmez le passage à la période suivante."), False old_status = intervention.status intervention.status = new_status intervention.status_order = STATUS_ORDERS.get(new_status, 1000) intervention.last_modified_by = user if ( old_status == 'to_be_approved' and new_status != 'canceled' and intervention.contract and getattr(intervention.contract, 'uses_intervention_manager', False) ): intervention.intervention_manager = user if new_status == 'canceled': intervention.cancellation_type = cancellation_type or None intervention.save() # Si une intervention est annulée, et que l'origine est une observation et que celle-ci # est au statut 'en cours' ('in_progress'), il faut refaire passer cette observation au statut approprié : # - 'duplicate' si le motif d'annulation est 'doublon' # - 'to_process' sinon if new_status == 'canceled': from observations.models import Observation as Obs, ObservationComment is_duplicate_cancellation = (cancellation_type == 'duplicate') new_obs_status = 'duplicate' if is_duplicate_cancellation else 'to_process' for obs in Obs.objects.filter(intervention=intervention, status='in_progress'): obs.status = new_obs_status obs.save(update_fields=['status']) try: if is_duplicate_cancellation: comment_text = _("Observation marquée 'Doublon' suite à l'annulation de l'intervention %(code)s (motif : doublon)") % {'code': intervention.code} else: comment_text = _("Observation remise 'À traiter' suite à l'annulation de l'intervention %(code)s") % {'code': intervention.code} ObservationComment.objects.create( observation=obs, author=user if (user and user.is_authenticated) else None, comment=comment_text, ) except Exception: pass # Marquer les observations liées comme traitées quand l'intervention passe au statut # 'terminé' (finished, order=25) ou supérieur (hors annulation). if ( STATUS_ORDERS.get(new_status, 0) >= STATUS_ORDERS.get('finished', 25) and new_status != 'canceled' ): from observations.models import Observation as Obs Obs.objects.filter( intervention=intervention, ).exclude(status='processed').update(status='processed') # Déclencher les règles de notification configurées par l'administrateur if new_status == 'canceled': try: from notifications.services import process_notification_rules from interventions.models import CANCELLATION_TYPE_CHOICES cancellation_label = dict(CANCELLATION_TYPE_CHOICES).get(cancellation_type or '', '') process_notification_rules( event='intervention_canceled', context={ 'intervention_code': intervention.code, 'intervention_title': intervention.title, 'cancellation_type': cancellation_type or '', 'cancellation_type_label': cancellation_label, 'cancellation_reason': cancellation_reason or '', 'action_url': reverse('interventions:intervention_detail', args=[intervention.id]), 'action_label': str(_("Voir l'intervention")), }, source_app='interventions', ) except Exception: import logging logging.getLogger(__name__).exception( "Erreur lors du traitement des règles de notification (annulation intervention %s)", intervention.pk, ) # Auto-vérification si l'utilisateur a le droit can_check_interventions auto_check_if_eligible(user, intervention) # Timeline principale # Si validation finale et validation par périodes active → enregistrer les quantités de la dernière période if new_status == 'validated' and getattr(intervention, 'period_validation_enabled', False) and getattr(intervention, 'period_is_final', False): _record_period_quantities() # Statuts "jalons" qui doivent être enregistrés dans l'historique même si # l'utilisateur les saute grâce à ses droits multi-rôles. # On les liste dans l'ordre naturel croissant. MILESTONE_STATUSES = ['assigned', 'in_progress', 'finished'] # Trouver les jalons intermédiaires en s'appuyant sur STATUS_ORDERS : # on retient les jalons dont l'ordre est strictement entre old et new, # et uniquement quand on avance dans le flux (order croissant). # Exception : si la destination est 'canceled', on n'injecte aucun jalon # intermédiaire (une annulation directe ne doit pas laisser de trace de # prise en charge, démarrage ou fin fictifs). old_order = STATUS_ORDERS.get(old_status, 0) new_order = STATUS_ORDERS.get(new_status, 0) intermediates = [] if new_status == 'canceled' else [ s for s in MILESTONE_STATUSES if old_order < STATUS_ORDERS.get(s, 0) < new_order ] if intermediates: t = now() prev = old_status for i, mid in enumerate(intermediates): InterventionTimeLine.objects.create( intervention=intervention, event_user=user, event_time=t + timedelta(seconds=i), event_type='status_change', from_status=prev, to_status=mid, ) # Enregistrer les dates de réalisation pour les jalons intermédiaires sautés if mid == 'in_progress': InterventionRealizationTimeLine.objects.create( intervention=intervention, event_user=user, event_time=t + timedelta(seconds=i), event_type='real_begin', event_description='Démarrage (saut de statut)', ) if not intervention.begin_time: intervention.begin_time = t + timedelta(seconds=i) intervention.save(update_fields=['begin_time']) elif mid == 'finished': finish_time_mid = t + timedelta(seconds=i) InterventionRealizationTimeLine.objects.create( intervention=intervention, event_user=user, event_time=finish_time_mid, event_type='real_end', event_description='Fin de l\'intervention (saut de statut)', ) intervention.end_time = finish_time_mid intervention.save(update_fields=['end_time']) prev = mid # Transition finale vers new_status InterventionTimeLine.objects.create( intervention=intervention, event_user=user, event_time=t + timedelta(seconds=len(intermediates)), event_type='status_change', from_status=prev, to_status=new_status, ) else: direct_time = now() InterventionTimeLine.objects.create( intervention=intervention, event_user=user, event_time=direct_time, event_type='status_change', from_status=old_status, to_status=new_status, ) # Fixer les dates de réalisation pour les transitions directes if new_status == 'in_progress' and old_status != 'on_pause' and not intervention.begin_time: intervention.begin_time = direct_time intervention.save(update_fields=['begin_time']) elif new_status == 'finished' and not intervention.end_time: intervention.end_time = direct_time intervention.save(update_fields=['end_time']) if new_status == 'to_be_processed' and intervention.maintain_type == 'corrective': # Génération de la fiche PDF et envoi email if intervention.intervention_sheet: pass # déjà générée, ne rien faire elif intervention.created_by and intervention.created_by.config and (not intervention.created_by.config.is_intern) \ and intervention.assigned_provider and intervention.created_by.company == intervention.assigned_provider: pass # ne pas générer de fiche ni envoyer d'email pour les interventions créées par un utilisateur externe pour sa propre compagnie (évite les boucles d'envoi) else: buffer = io.BytesIO() intervention.transmission_time = now() intervention.save(update_fields=['transmission_time']) generate_intervention_pdf(intervention, buffer) pdf_bytes = buffer.getvalue() fiche_filename = f"fiche_{intervention.code}.pdf" intervention.intervention_sheet.save(fiche_filename, ContentFile(pdf_bytes)) intervention.save(update_fields=['intervention_sheet']) try: contract = intervention.contract # adaptez si la relation diffère except AttributeError: contract = None detail_url = request.build_absolute_uri( reverse("interventions:intervention_detail", args=[intervention.id]) ) recipients = get_contract_recipients(contract, intervention) if contract else [] cc_recipients = get_contract_cc_recipients(contract, intervention) if contract else [getattr(settings, 'EMAIL_TECH_SUPPORT', 'bm.perm.tech@sprb.brussels')] # Si le contrat utilise l'envoi groupé journalier, ne pas envoyer l'email individuel if recipients and not (contract and getattr(contract, 'send_daily_summary', False)): subject = f"Fiche d’intervention / Interventiefiche {intervention.code}" context = { "intervention": intervention, "contract": intervention.contract, "detail_url": detail_url, "notes_for_email": _collect_notes_plain_text(intervention), } # Corps texte (fallback) + HTML text_body = render_to_string("emails/new_intervention.txt", context) # html_body = render_to_string("emails/new_intervention.html", context) from_email = getattr(settings, "DEFAULT_FROM_EMAIL", 'StreetUp no-reply ') reply_to = [] # Récupérer les autres pièces jointes déjà liées à l'intervention existing_attachments = get_intervention_attachments( intervention, max_total_bytes=7 * 1024 * 1024, # ajustez selon votre politique exclude_filenames=(fiche_filename,), # éviter d'attacher deux fois le même PDF ) email = EmailMultiAlternatives( subject=subject, body=text_body, from_email=from_email, to=recipients, cc=cc_recipients, bcc=[getattr(settings, 'EMAIL_INTERNAL_FOLLOWUP', 'kdeterme@sprb.brussels')], # BCC fixe pour suivi interne reply_to=reply_to or None, headers={ # Utile pour signaler un envoi généré automatiquement (évite certains auto-répondeurs) "Auto-Submitted": "auto-generated", # Optionnel: tag interne pour votre MTA "X-Category": "new-intervention", } ) email.attach(fiche_filename, pdf_bytes, "application/pdf") # Attacher les documents existants for fname, content, ctype in existing_attachments: email.attach(fname, content, ctype) # email.attach_alternative(html_body, "text/html") # Check SMTP availability before sending if not is_smtp_available(): logger.warning("SMTP not available, skipping email for new intervention %s", intervention.code) email_failed = True # Log l'échec de l'envoi InterventionEmailLog.objects.create( intervention=intervention, email_type='transmission', sent_by=user, recipients=', '.join(recipients), subject=subject, success=False, error_message=_("Serveur SMTP non disponible"), ) else: try: email.send(fail_silently=False) # Log l'envoi réussi InterventionEmailLog.objects.create( intervention=intervention, email_type='transmission', sent_by=user, recipients=', '.join(recipients), subject=subject, success=True, ) except Exception as e: # Log the error but don't fail the status change logger.error(f"Failed to send intervention email: {str(e)}") email_failed = True # Log l'échec de l'envoi InterventionEmailLog.objects.create( intervention=intervention, email_type='transmission', sent_by=user, recipients=', '.join(recipients), subject=subject, success=False, error_message=str(e), ) else: # logger.warning("Aucun destinataire trouvé pour le contrat %s", getattr(contract, "id", None)) pass elif new_status == 'in_progress' and old_status == 'on_pause': InterventionRealizationTimeLine.objects.create( intervention=intervention, event_user=user, event_time=now(), event_type='real_begin', event_description='Reprise' ) elif new_status == 'in_progress': InterventionRealizationTimeLine.objects.create( intervention=intervention, event_user=user, event_time=now(), event_type='real_begin', event_description='Démarrage' ) if not intervention.begin_time: intervention.begin_time = now() intervention.save() elif new_status == 'on_pause': description = pause_description or _("Pause de l'intervention") InterventionRealizationTimeLine.objects.create( intervention=intervention, event_user=user, event_time=now(), event_type='real_pause', event_description=description ) elif new_status == 'finished': finish_time = now() InterventionRealizationTimeLine.objects.create( intervention=intervention, event_user=user, event_time=finish_time, event_type='real_end', event_description='Fin de l\'intervention' ) intervention.end_time = finish_time intervention.save(update_fields=['end_time']) elif new_status == 'canceled': # Valider le type d'annulation (obligatoire) from interventions.models import CANCELLATION_TYPE_CHOICES valid_types = [c[0] for c in CANCELLATION_TYPE_CHOICES] cancellation_type_val = (cancellation_type or "").strip() if not cancellation_type_val or cancellation_type_val not in valid_types: return False, _("Un type d'annulation doit être sélectionné."), False type_label = dict(CANCELLATION_TYPE_CHOICES).get(cancellation_type_val, "") cancellation_reason = (cancellation_reason or "").strip() # Le commentaire est obligatoire uniquement pour le motif 'Autre' if cancellation_type_val == 'other' and not cancellation_reason: return False, _("Un commentaire est obligatoire pour le motif 'Autre'."), False # Construire le contenu de la note if cancellation_reason: note_content = f"[{type_label}] {cancellation_reason}" if type_label else cancellation_reason else: note_content = type_label # Créer une note avec la raison d'annulation InterventionNote.objects.create( intervention=intervention, content=note_content, note_time=now(), note_type='comment', note_author=user, ) sheet_was_sent = ( bool(intervention.intervention_sheet) and InterventionEmailLog.objects.filter( intervention=intervention, email_type='transmission', success=True, ).exists() ) if STATUS_ORDERS.get(old_status) >= STATUS_ORDERS['to_be_processed'] and sheet_was_sent: buffer = io.BytesIO() generate_intervention_pdf( intervention, buffer, status_labels=("ANNULÉ", "GEANNULEERD"), ) pdf_bytes = buffer.getvalue() fiche_filename = f"fiche_{intervention.code}_annulee.pdf" intervention.intervention_sheet.save(fiche_filename, ContentFile(pdf_bytes)) intervention.save(update_fields=['intervention_sheet']) contract = getattr(intervention, 'contract', None) recipients = get_contract_recipients(contract, intervention) if contract else [] cc_recipients = get_contract_cc_recipients(contract, intervention) if contract else [getattr(settings, 'EMAIL_TECH_SUPPORT', 'bm.perm.tech@sprb.brussels')] if recipients: detail_url = request.build_absolute_uri( reverse("interventions:intervention_detail", args=[intervention.id]) ) context = { "intervention": intervention, "contract": contract, "detail_url": detail_url, "notes_for_email": _collect_notes_plain_text(intervention), } text_body = render_to_string("emails/intervention_canceled.txt", context) from_email = getattr(settings, "DEFAULT_FROM_EMAIL", 'StreetUp no-reply ') reply_to = [] existing_attachments = get_intervention_attachments( intervention, max_total_bytes=7 * 1024 * 1024, exclude_filenames=(fiche_filename,), ) email = EmailMultiAlternatives( subject=f"Annulation - Fiche d’intervention / Interventiefiche {intervention.code}", body=text_body, from_email=from_email, to=recipients, cc=cc_recipients, bcc=[getattr(settings, 'EMAIL_INTERNAL_FOLLOWUP', 'kdeterme@sprb.brussels')], # BCC fixe pour suivi interne reply_to=reply_to or None, headers={ "Auto-Submitted": "auto-generated", "X-Category": "intervention-canceled", }, ) email.attach(fiche_filename, pdf_bytes, "application/pdf") for fname, content, ctype in existing_attachments: email.attach(fname, content, ctype) # Check SMTP availability before sending email_subject = f"Annulation - Fiche d'intervention / Interventiefiche {intervention.code}" if not is_smtp_available(): logger.warning("SMTP not available, skipping cancellation email for intervention %s", intervention.code) email_failed = True InterventionEmailLog.objects.create( intervention=intervention, email_type='cancellation', sent_by=request.user if request.user.is_authenticated else None, recipients=', '.join(recipients), subject=email_subject, success=False, error_message=_("Serveur SMTP non disponible") ) else: try: email.send(fail_silently=False) InterventionEmailLog.objects.create( intervention=intervention, email_type='cancellation', sent_by=request.user if request.user.is_authenticated else None, recipients=', '.join(recipients), subject=email_subject, success=True ) except Exception as e: # Log the error but don't fail the status change logger.error(f"Failed to send cancellation email: {str(e)}") email_failed = True InterventionEmailLog.objects.create( intervention=intervention, email_type='cancellation', sent_by=request.user if request.user.is_authenticated else None, recipients=', '.join(recipients), subject=email_subject, success=False, error_message=str(e) ) return True, _("Statut mis à jour."), email_failed def toggle_period_validation(request, intervention_id): intervention = get_object_or_404(Intervention, pk=intervention_id) user_config = (UserConfig.objects .select_related('user') .prefetch_related('roles') .filter(user=request.user).first()) if not user_config or not user_config.is_intern or not user_config.roles.filter(name__in=['admin', 'manager', 'controller']).exists(): return JsonResponse({'success': False, 'message': _('Permission refusée')}, status=403) # Support both form-encoded and JSON bodies, and both 'enable'/'enabled' payload = {} if request.content_type == 'application/json': try: payload = json.loads(request.body or '{}') except json.JSONDecodeError: payload = {} enable_raw = request.POST.get('enable') if enable_raw is None: enable_raw = request.POST.get('enabled') if enable_raw is None: enable_raw = payload.get('enable') if enable_raw is None: enable_raw = payload.get('enabled') enable = str(enable_raw).lower() in ['1', 'true', 'yes', 'on'] if not enable: # Désactivation autorisée uniquement en période 1 if getattr(intervention, 'period_number', 1) != 1: return JsonResponse({'success': False, 'message': _('Impossible de désactiver après la période 1.')}, status=400) intervention.period_validation_enabled = False intervention.period_is_final = False intervention.period_number = 1 intervention.save(update_fields=['period_validation_enabled', 'period_is_final', 'period_number']) return JsonResponse({'success': True, 'enabled': False, 'period_number': intervention.period_number, 'is_final': intervention.period_is_final}) # Activation intervention.period_validation_enabled = True # Si jamais on l’active à nouveau, repartir de période 1 si aucune valeur if not intervention.period_number: intervention.period_number = 1 intervention.save(update_fields=['period_validation_enabled', 'period_number']) return JsonResponse({'success': True, 'enabled': True, 'period_number': intervention.period_number, 'is_final': intervention.period_is_final}) def set_last_period_flag(request, intervention_id): intervention = get_object_or_404(Intervention, pk=intervention_id) user_config = (UserConfig.objects .select_related('user') .prefetch_related('roles') .filter(user=request.user).first()) if not user_config or not user_config.is_intern or not user_config.roles.filter(name__in=['admin', 'manager', 'controller']).exists(): return JsonResponse({'success': False, 'message': _('Permission refusée')}, status=403) if not getattr(intervention, 'period_validation_enabled', False): return JsonResponse({'success': False, 'message': _("L'option de validation par périodes n'est pas activée.")}, status=400) # Accept JSON or form data; support 'is_final' and 'period_is_final' payload = {} if request.content_type == 'application/json': try: payload = json.loads(request.body or '{}') except json.JSONDecodeError: payload = {} is_final_raw = request.POST.get('is_final') if is_final_raw is None: is_final_raw = request.POST.get('period_is_final') if is_final_raw is None: is_final_raw = payload.get('is_final') if is_final_raw is None: is_final_raw = payload.get('period_is_final') is_final = str(is_final_raw).lower() in ['1', 'true', 'yes', 'on'] intervention.period_is_final = bool(is_final) intervention.save(update_fields=['period_is_final']) return JsonResponse({'success': True, 'is_final': intervention.period_is_final}) def confirm_call(request, intervention_id): try: intervention = Intervention.objects.get(pk=intervention_id) except Intervention.DoesNotExist: return JsonResponse({'success': False, 'error': 'Intervention not found'}, status=404) try: payload = json.loads(request.body.decode() or "{}") except json.JSONDecodeError: return JsonResponse({'success': False, 'error': _('Invalid payload.')} , status=400) call_received_raw = payload.get('call_received', True) if isinstance(call_received_raw, bool): call_received = call_received_raw elif isinstance(call_received_raw, str): call_received = call_received_raw.strip().lower() in {'1', 'true', 'yes', 'on'} else: call_received = bool(call_received_raw) call_datetime_str = payload.get('call_datetime') call_datetime = now() if call_datetime_str: parsed = parse_datetime(call_datetime_str) if parsed is None: return JsonResponse({'success': False, 'error': _('Invalid date format.')}, status=400) if is_naive(parsed): parsed = make_aware(parsed, get_current_timezone()) call_datetime = parsed intervention.init_call_confirmed_time = call_datetime intervention.init_call_received = call_received intervention.save(update_fields=["init_call_confirmed_time", "init_call_received"]) return JsonResponse({ 'success': True, "confirmed_at": intervention.init_call_confirmed_time.isoformat(), "call_received": intervention.init_call_received, }) def send_intervention_reminder(request, intervention_id): intervention = get_object_or_404(Intervention, pk=intervention_id) user_config = (UserConfig.objects .select_related('user') .prefetch_related('roles') .filter(user=request.user) .first()) if not user_config or not user_config.is_intern: return JsonResponse({ 'success': False, 'error': _("Seuls les utilisateurs internes peuvent envoyer un rappel pour cette intervention."), }, status=403) contract = intervention.contract if not contract: return JsonResponse({ 'success': False, 'error': _("Aucun contrat n'est associé à cette intervention."), }, status=400) recipients = get_contract_recipients(contract, intervention) cc_recipients = get_contract_cc_recipients(contract, intervention) if not recipients: return JsonResponse({ 'success': False, 'error': _("Aucun destinataire n'a été configuré pour le contrat associé."), }, status=400) buffer = io.BytesIO() generate_intervention_pdf(intervention, buffer, is_reminder=True) pdf_bytes = buffer.getvalue() fiche_filename = f"fiche_{intervention.code}_rappel.pdf" detail_url = request.build_absolute_uri( reverse("interventions:intervention_detail", args=[intervention.id]) ) context = { "intervention": intervention, "contract": contract, "detail_url": detail_url, } text_body = render_to_string("emails/intervention_reminder.txt", context) from_email = getattr(settings, "DEFAULT_FROM_EMAIL", 'StreetUp no-reply ') reply_to = [] existing_attachments = get_intervention_attachments( intervention, max_total_bytes=7 * 1024 * 1024, exclude_filenames=(fiche_filename,), ) email = EmailMultiAlternatives( subject=f"Rappel - Fiche d’intervention / Interventiefiche {intervention.code}", body=text_body, from_email=from_email, to=recipients, cc=cc_recipients, bcc=[getattr(settings, 'EMAIL_INTERNAL_FOLLOWUP', 'kdeterme@sprb.brussels')], # BCC fixe pour suivi interne reply_to=reply_to or None, headers={ "Auto-Submitted": "auto-generated", "X-Category": "intervention-reminder", }, ) email.attach(fiche_filename, pdf_bytes, "application/pdf") for fname, content, ctype in existing_attachments: email.attach(fname, content, ctype) # Check SMTP availability before sending email_subject = f"Rappel - Fiche d'intervention / Interventiefiche {intervention.code}" if not is_smtp_available(): logger.warning("SMTP not available, cannot send reminder for intervention %s", intervention.code) InterventionEmailLog.objects.create( intervention=intervention, email_type='reminder', sent_by=request.user if request.user.is_authenticated else None, recipients=', '.join(recipients), subject=email_subject, success=False, error_message=_("Serveur SMTP non disponible") ) return JsonResponse({ 'success': False, 'error': _("L'envoi du rappel a échoué : le serveur email n'est pas disponible."), }, status=500) try: email.send(fail_silently=False) InterventionEmailLog.objects.create( intervention=intervention, email_type='reminder', sent_by=request.user if request.user.is_authenticated else None, recipients=', '.join(recipients), subject=email_subject, success=True ) except Exception as exc: # pragma: no cover - dépend de la configuration d'email InterventionEmailLog.objects.create( intervention=intervention, email_type='reminder', sent_by=request.user if request.user.is_authenticated else None, recipients=', '.join(recipients), subject=email_subject, success=False, error_message=str(exc) ) return JsonResponse({ 'success': False, 'error': _("L'envoi du rappel a échoué : %(error)s") % {'error': str(exc)}, }, status=500) return JsonResponse({ 'success': True, 'message': _("Le rappel a été envoyé."), }) def send_intervention_modification(request, intervention_id): intervention = get_object_or_404(Intervention, pk=intervention_id) user_config = (UserConfig.objects .select_related('user') .prefetch_related('roles') .filter(user=request.user) .first()) if not user_config or not user_config.is_intern: return JsonResponse({ 'success': False, 'error': _("Seuls les utilisateurs internes peuvent envoyer une modification pour cette intervention."), }, status=403) try: payload = json.loads(request.body or "{}") except json.JSONDecodeError: payload = {} reason = (payload.get("reason") or "").strip() if not reason: return JsonResponse({ "success": False, "error": _("Veuillez préciser le motif de la modification."), }, status=400) contract = intervention.contract if not contract: return JsonResponse({ 'success': False, 'error': _("Aucun contrat n'est associé à cette intervention."), }, status=400) recipients = get_contract_recipients(contract, intervention) cc_recipients = get_contract_cc_recipients(contract, intervention) if not recipients: return JsonResponse({ 'success': False, 'error': _("Aucun destinataire n'a été configuré pour le contrat associé."), }, status=400) buffer = io.BytesIO() generate_intervention_pdf(intervention, buffer, modification_reason=reason) pdf_bytes = buffer.getvalue() fiche_filename = f"fiche_{intervention.code}_modification.pdf" intervention.intervention_sheet.save(fiche_filename, ContentFile(pdf_bytes)) intervention.save(update_fields=['intervention_sheet']) detail_url = request.build_absolute_uri( reverse("interventions:intervention_detail", args=[intervention.id]) ) context = { "intervention": intervention, "contract": contract, "detail_url": detail_url, "modification_reason": reason, } text_body = render_to_string("emails/intervention_modification.txt", context) from_email = getattr(settings, "DEFAULT_FROM_EMAIL", 'StreetUp no-reply ') reply_to = [] existing_attachments = get_intervention_attachments( intervention, max_total_bytes=7 * 1024 * 1024, exclude_filenames=(fiche_filename,), ) email = EmailMultiAlternatives( subject=f"Modification - Fiche d’intervention / Interventiefiche {intervention.code}", body=text_body, from_email=from_email, to=recipients, cc=cc_recipients, bcc=[getattr(settings, 'EMAIL_INTERNAL_FOLLOWUP', 'kdeterme@sprb.brussels')], # BCC fixe pour suivi interne reply_to=reply_to or None, headers={ "Auto-Submitted": "auto-generated", "X-Category": "intervention-modification", }, ) email.attach(fiche_filename, pdf_bytes, "application/pdf") for fname, content, ctype in existing_attachments: email.attach(fname, content, ctype) # Check SMTP availability before sending email_subject = f"Modification - Fiche d'intervention / Interventiefiche {intervention.code}" if not is_smtp_available(): logger.warning("SMTP not available, cannot send modification email for intervention %s", intervention.code) InterventionEmailLog.objects.create( intervention=intervention, email_type='modification', sent_by=request.user if request.user.is_authenticated else None, recipients=', '.join(recipients), subject=email_subject, detail=reason, success=False, error_message=_("Serveur SMTP non disponible") ) return JsonResponse({ 'success': False, 'error': _("L'envoi de la modification a échoué : le serveur email n'est pas disponible."), }, status=500) try: email.send(fail_silently=False) InterventionEmailLog.objects.create( intervention=intervention, email_type='modification', sent_by=request.user if request.user.is_authenticated else None, recipients=', '.join(recipients), subject=email_subject, detail=reason, success=True ) except Exception as exc: # pragma: no cover - dépend de la configuration d'email InterventionEmailLog.objects.create( intervention=intervention, email_type='modification', sent_by=request.user if request.user.is_authenticated else None, recipients=', '.join(recipients), subject=email_subject, detail=reason, success=False, error_message=str(exc) ) return JsonResponse({ 'success': False, 'error': _("L'envoi de la modification a échoué : %(error)s") % {'error': str(exc)}, }, status=500) return JsonResponse({ 'success': True, 'message': _("La modification a été envoyée."), }) def toggle_intervention_subscription(request, intervention_id): from django.http import JsonResponse from django.utils.translation import gettext as _ from interventions.models import InterventionSubscription, Intervention intervention = get_object_or_404(Intervention, pk=intervention_id) subscription, created = InterventionSubscription.objects.get_or_create( user=request.user, intervention=intervention ) if not created: subscription.delete() subscribed = False message = _("Vous ne suivez plus cette intervention.") else: subscribed = True message = _("Vous suivez maintenant cette intervention.") return JsonResponse({ 'success': True, 'subscribed': subscribed, 'message': message })