loko/streetup/interventions/views/workflow.py
2026-07-22 14:48:40 +02:00

1486 lines
67 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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", 'no-reply@streetup.be')
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", 'no-reply@streetup.be')
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", 'no-reply@streetup.be')
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", 'no-reply@streetup.be')
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
})