1486 lines
67 KiB
Python
1486 lines
67 KiB
Python
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
|
||
})
|
||
|