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 intervention_detail(request, intervention_id): # Redirection mobile si User-Agent correspond et pas de force_desktop user_agent = request.META.get('HTTP_USER_AGENT', '').lower() # Mots-clés courants pour mobiles et tablettes mobile_patterns = [r'android', r'iphone', r'ipad', r'ipod', r'mobile', r'blackberry', r'opera mini', r'iemobile'] if not request.GET.get('force_desktop') and any(re.search(pattern, user_agent) for pattern in mobile_patterns): return redirect('interventions:intervention_detail_mobile', intervention_id=intervention_id) intervention = get_object_or_404(Intervention, pk=intervention_id) intervention.prep_state = intervention.get_preparation_orders_status() # user_config = get_object_or_404(UserConfig, user=request.user) user_config = (UserConfig.objects .select_related('user') .prefetch_related('roles', Prefetch('userthematics', queryset=UserThematics.objects.select_related('thematic'))) .filter(user=request.user).first()) if not user_config: return HttpResponseForbidden(_("You do not have access to this page.")) lang = get_language() contract = intervention.contract thematic = intervention.thematic allowed_update_fields = get_allowed_update_fields(request.user, intervention) can_edit_notes = 'notes' in allowed_update_fields allowed_transitions = get_allowed_transitions(request, intervention) # Posts of the contract contract_posts = None total_general = None total_validated = None can_view_posts = False orders = None quote_remaining_available = False user_contract_access = None can_check_intervention = False if contract: # Get the user's access to the contract user_contract_access = UserContractAccess.objects.filter(user_config=user_config, contract=contract).first() # Droit de vérification : contrat avec needs_checking=True ET utilisateur avec can_check_interventions if user_contract_access and getattr(contract, 'needs_checking', False) and user_contract_access.can_check_interventions: can_check_intervention = True # Filter the positions associated with this intervention if user_contract_access and user_contract_access.can_view_posts: can_view_posts = True contract_posts = list( InterventionContractPost.objects.filter(intervention=intervention) .select_related('contract_post') ) quote_remaining_available = False if intervention.order: quote = getattr(intervention.order, 'quote', None) if quote: quote_remaining_available = True remaining_items = quote.get_remaining_items( exclude_intervention_id=intervention.id, include_zero=True, include_current_intervention_periods=intervention.id, ) remaining_map = { item['contract_post'].id: { 'remaining_quantity': item['remaining_quantity'], 'planned_quantity': item.get('planned_quantity', 0) } for item in remaining_items } for post in contract_posts: quote_data = remaining_map.get(post.contract_post_id, {}) post.quote_remaining_quantity = quote_data.get('remaining_quantity') post.quote_planned_quantity = quote_data.get('planned_quantity') else: for post in contract_posts: post.quote_remaining_quantity = None post.quote_planned_quantity = None else: for post in contract_posts: post.quote_remaining_quantity = None post.quote_planned_quantity = None # Calculating the grand total total_general = sum(item.total_price for item in contract_posts) total_validated = sum(item.total_price for item in contract_posts if item.control_status == 'validated') # Vérifier si au moins un poste est multipliable par coefficient has_coefficient_posts = any( item.contract_post.is_coefficient_multipliable for item in contract_posts ) else: has_coefficient_posts = False orders = ContractOrder.objects.filter(contract=contract).exclude(order_status__in=['cancelled', 'paid']) else: has_coefficient_posts = False contract_id = intervention.order.contract.id if intervention.order else None # Limiter les providers et contracts selon les permissions de l'utilisateur if user_config.is_intern: # Utilisateurs internes : accès à tous les prestataires et contrats providers = Company.objects.all() if intervention.thematic: providers = providers.filter(contracts__thematics=intervention.thematic).distinct() contracts_qs = Contract.objects.filter(company=intervention.assigned_provider) # Filtrer par thématique si l'intervention en a une if intervention.thematic: contracts_qs = contracts_qs.filter(thematics=intervention.thematic) contracts = contracts_qs.distinct().values("id", "contract_number") else: # Utilisateurs externes (external_managers) : limiter aux contrats accessibles accessible_contracts_ids = UserContractAccess.objects.filter( user_config=user_config, can_view_interventions=True ).values_list('contract_id', flat=True) accessible_contracts_qs = Contract.objects.filter(id__in=accessible_contracts_ids) if intervention.thematic: accessible_contracts_qs = accessible_contracts_qs.filter(thematics=intervention.thematic) accessible_contracts_qs = accessible_contracts_qs.distinct() # Prestataires limités à ceux des contrats accessibles providers = Company.objects.filter( contracts__id__in=accessible_contracts_qs.values_list('id', flat=True) ).distinct() # Contrats limités à ceux accessibles ET associés au prestataire actuel if intervention.assigned_provider: contracts_qs = accessible_contracts_qs.filter(company=intervention.assigned_provider) else: contracts_qs = accessible_contracts_qs contracts = contracts_qs.distinct().values("id", "contract_number") projects = Project.objects.filter(thematics=intervention.thematic) if intervention.thematic else None project_interv = ProjectIntervention.objects.filter(intervention=intervention).first() project = project_interv.project if project_interv else None # Assets liés via la table générique linked_assets = InterventionAsset.objects.filter(intervention=intervention) linked_assets = sorted( linked_assets, key=lambda ia: (ia.asset.get_name() if ia.asset and hasattr(ia.asset, 'get_name') else ia.asset.code if ia.asset else '').lower() ) linked_groups = _build_link_groups(intervention) operations = list( intervention.operations.all() .select_related('status', 'measurement_definition', 'asset_content_type') .order_by('asset_content_type_id', 'asset_object_id', 'template_group', 'order') ) operations_grouped = _group_operations_by_asset_and_template(operations, linked_assets) general_operations_group = next((group for group in operations_grouped if not group["has_asset"]), None) asset_operations_groups = [group for group in operations_grouped if group["has_asset"]] # Trier les groupes d'opérations équipement par le nom de l'équipement asset_operations_groups.sort( key=lambda g: (g["asset"].get_name() if g["asset"] and hasattr(g["asset"], 'get_name') else g["asset"].code if g["asset"] else '').lower() ) # Notes current_time = now() note_priority_case = Case( When(note_type=InterventionNote.NoteType.EQUIPMENT, then=Value(0)), When(note_type=InterventionNote.NoteType.IMPORTANT_INFO, then=Value(1)), When(note_type=InterventionNote.NoteType.ORIGIN, then=Value(2)), default=Value(3), output_field=IntegerField(), ) notes = ( InterventionNote.objects .select_related('note_author', 'intervention', 'intervention__thematic') .filter(intervention=intervention) .annotate(type_priority_order=note_priority_case) .order_by('type_priority_order', 'note_time') ) editable_note_ids = {n.id for n in notes if can_edit_note(request.user, user_config, n)} deletable_document_ids = {d.id for d in intervention.documents.all() if can_delete_document(request.user, user_config, d)} occupations = (InterventionOccupation.objects .select_related('member__user', 'created_by', 'updated_by') .prefetch_related('member__teams__company') .filter(intervention=intervention) .order_by('created_at')) total_occupation_minutes = intervention.get_total_occupation_minutes() can_manage_occupation_entries = can_manage_occupations(request.user, intervention, user_config=user_config) show_occupations = getattr(intervention.contract, 'uses_occupations', False) occupations_required = ( getattr(intervention.contract, 'occupations_required', False) and intervention.maintain_type == 'corrective' ) occupation_members_qs = (_get_available_occupation_members(request.user, intervention) if can_manage_occupation_entries else CompanyMember.objects.none()) occupation_members = list(occupation_members_qs) occupation_form = (InterventionOccupationForm(members_queryset=occupation_members_qs, user=request.user) if can_manage_occupation_entries else None) can_control = can_control_intervention(request.user, intervention) categories_qs = AssetCategory.objects.all() allowed_category_ids = _get_allowed_asset_category_ids(intervention) if allowed_category_ids: categories_qs = categories_qs.filter(id__in=allowed_category_ids) elif intervention.thematic_id: categories_qs = categories_qs.filter( Q(thematic=intervention.thematic) | Q(thematic__isnull=True) ) order_by_field = 'name_nl' if lang == 'nl' else 'name_fr' categories_qs = categories_qs.order_by(order_by_field) is_draft = intervention.status in ['in_preparation', 'to_be_approved'] recommended_next_status = None if is_draft and intervention.maintain_type == 'preventive': recommended_next_status = 'to_be_planned' elif is_draft and intervention.maintain_type == 'corrective': recommended_next_status = 'to_be_processed' if intervention.symptom: recommended_next_status = intervention.symptom.recommended_next_status or recommended_next_status elif intervention.status == 'to_be_planned': recommended_next_status = 'to_be_processed' is_user_intern = user_config.is_intern geom_geojson = None if intervention.geom: geom_4326 = intervention.geom.transform(4326, clone=True) if not geom_4326.empty: point_4326 = geom_4326.centroid intervention.lon = point_4326.x intervention.lat = point_4326.y else: intervention.lon = None intervention.lat = None geom_geojson = { "type": "FeatureCollection", "features": [ { "type": "Feature", "geometry": json.loads(geom_4326.geojson), "properties": { "code": intervention.code, "title": intervention.title } } ] } can_create_repair = ( intervention.maintain_type != 'corrective' and can_add_repair_intervention(request.user, intervention) ) can_manage_links = can_edit_intervention(request.user, intervention, user_config=user_config) can_send_reminder = bool(user_config and user_config.is_intern and can_manage_links and intervention.status in ['to_be_processed',]) # Historique des interventions liées aux mêmes localisations (3 derniers mois) locations_qs = list( InterventionLocation.objects .filter(intervention=intervention) .select_related('content_type') ) location_history_interventions = [] if locations_qs: three_months_ago = now() - timedelta(days=90) location_pairs = [ (loc.content_type_id, loc.object_id) for loc in locations_qs ] if location_pairs: # Trouver les interventions liées aux mêmes localisations related_intervention_ids = ( InterventionLocation.objects .filter( content_type_id__in=[p[0] for p in location_pairs], object_id__in=[p[1] for p in location_pairs], ) .exclude(intervention_id=intervention.id) .values_list('intervention_id', flat=True) .distinct() ) # Récupérer les interventions des 3 derniers mois related_interventions = ( Intervention.objects .annotate( effective_end=Coalesce( 'end_time', 'planned_end_time', 'expected_end_time', 'creation_time', output_field=DateTimeField(), ), ) .filter( id__in=related_intervention_ids, effective_end__gte=three_months_ago, ) .exclude(status='canceled') .select_related('thematic', 'contract', 'assigned_provider') .order_by('-effective_end')[:20] ) # Vérifier les permissions pour chaque intervention for related_intv in related_interventions: can_view = can_view_intervention(request.user, related_intv) can_view_summary = can_view_intervention_summary(request.user, related_intv) location_history_interventions.append({ 'intervention': related_intv, 'can_view': can_view, 'can_view_summary': can_view_summary, }) # Gestion des droits pour la validation par périodes can_manage_period_validation = False if user_config and user_config.is_intern and user_config.roles.filter(name__in=['admin', 'manager', 'controller']).exists(): can_manage_period_validation = True future_planned_events = list(intervention.planned_events.all()) future_realized_events = list(intervention.realized_events.all()) # Filtrer les équipes du prestataire assigné par thématique available_teams = [] if intervention.assigned_provider: if intervention.thematic: # Filtrer les équipes associées à la thématique de l'intervention available_teams = list( intervention.assigned_provider.teams .filter(thematics=intervention.thematic) .prefetch_related('members') ) else: # Si pas de thématique, afficher toutes les équipes du prestataire available_teams = list( intervention.assigned_provider.teams .all() .prefetch_related('members') ) # Choix de type de note pour l'ajout (sans "origine" qui est réservé à la création) add_note_type_choices = [ (value, label) for value, label in InterventionNote.NoteType.choices if value != InterventionNote.NoteType.ORIGIN ] # Filtrer les contrôles visibles par l'utilisateur visible_controls = [ control for control in intervention.related_controls.all() if user_can_view_control(request.user, control) ] # Bons de préparation liés à l'intervention: # - admin # - ou droit d'édition sur l'intervention + droit de gérer les bons de préparation sur au moins un entrepôt can_create_preparation_order = bool( user_config and ( user_config.has_role('admin') or (can_manage_links and user_config.can_manage_preparation_orders_any()) ) ) preparation_orders = intervention.preparation_orders.all().order_by('-date') if hasattr(intervention, 'preparation_orders') else [] # Config desktop pour le modal de liaison d'assets (même mécanique que mobile) desktop_assets_config = None desktop_assets_location_labels: List[str] = [] if can_manage_links: location_payload_desktop = [] for loc_link in locations_qs: label = _format_location_label(getattr(loc_link, 'location', None)) if not label: continue location_payload_desktop.append({ "id": loc_link.id, "label": label, "content_type_id": loc_link.content_type_id, "object_id": loc_link.object_id, }) desktop_assets_location_labels = [entry['label'] for entry in location_payload_desktop] existing_pairs_desktop = [] for ia in linked_assets: if not ia.content_type_id or not ia.object_id: continue asset_obj = getattr(ia, 'asset', None) display_label = str(asset_obj) if asset_obj else '' existing_pairs_desktop.append({ "content_type_id": ia.content_type_id, "object_id": ia.object_id, "model": ia.content_type.model if ia.content_type else '', "display_label": display_label, "code": getattr(asset_obj, 'code', None), }) desktop_assets_config = { "interventionId": intervention.id, "searchUrl": reverse("interventions:mobile_intervention_assets_search", args=[intervention.id]), "filtersUrl": reverse("interventions:mobile_intervention_assets_filters", args=[intervention.id]), "syncUrl": reverse("interventions:mobile_intervention_assets_sync", args=[intervention.id]), "locationsUrl": reverse("interventions:mobile_intervention_assets_locations", args=[intervention.id]), "maxResults": MAX_MOBILE_ASSET_RESULTS, "existingPairs": existing_pairs_desktop, "locations": location_payload_desktop, "hasLocations": bool(location_payload_desktop), } # Observations liées à l'intervention linked_observations = [] obs_access_context = get_observation_access_context(request.user, user_config=user_config) if obs_access_context: all_observations = intervention.observations.select_related('thematic', 'category').order_by('-created_at') linked_observations = [obs for obs in all_observations if obs_access_context.allows(obs)] # Observation source (si la source est une observation) source_observation = None if intervention.source_category and intervention.source_ref: sc_names = [getattr(intervention.source_category, a, '') or '' for a in ('name_fr', 'name_nl')] if any(n.strip().lower() == 'observation' for n in sc_names): try: obs = ObservationModel.objects.get(pk=int(intervention.source_ref)) if obs_access_context and obs_access_context.allows(obs): source_observation = obs except (ObservationModel.DoesNotExist, ValueError, TypeError): pass # Discussion liée à cette intervention discussion = None discussion_messages = [] can_post_discussion_message = False try: from django.contrib.contenttypes.models import ContentType from notifications.models import Discussion from notifications.services import mark_discussion_seen, can_post_intervention_message ct = ContentType.objects.get_for_model(intervention) discussion = Discussion.objects.filter( content_type=ct, object_id=intervention.pk ).prefetch_related('messages__attachments', 'participants').first() if discussion: discussion_messages = list( discussion.messages.filter(is_deleted=False) .prefetch_related('attachments') .order_by('created_at') ) mark_discussion_seen(discussion, user=request.user) can_post_discussion_message = can_post_intervention_message(request.user, intervention) except Exception: pass # Localisations & copy targets for copy-document modal localisations = resolve_intervention_localisations(intervention) _copy_targets = [] for _loc in localisations: _copy_targets.append({ "type": "location", "ct_id": _loc["ct_id"], "obj_id": _loc["obj_id"], "label": _loc["name"], }) for _ia in linked_assets: if _ia.asset: _label = _ia.asset.code _name = _ia.asset.get_name() if hasattr(_ia.asset, 'get_name') else None if _name: _label = f"{_ia.asset.code} — {_name}" _copy_targets.append({ "type": "asset", "ct_id": _ia.content_type_id, "obj_id": _ia.object_id, "label": _label, }) import json as _json_mod copy_targets_json = _json_mod.dumps(_copy_targets) context = { "intervention": intervention, "status_choices" : [{"key": key, "label": label} for key, label in STATUS_CHOICES.items()], "recommended_next_status": recommended_next_status, "notes": notes, "editable_note_ids": editable_note_ids, "contract_posts": contract_posts, "total_general": total_general, "total_validated": total_validated, 'can_view_prices': user_contract_access.can_view_prices if user_contract_access else False, 'geom_geojson': geom_geojson, 'allowed_transitions': json.dumps(allowed_transitions), 'add_note_form': InterventionNoteForm(), 'note_type_choices': InterventionNote.NoteType.choices, 'add_note_type_choices': add_note_type_choices, 'default_note_type': InterventionNote.NoteType.COMMENT, 'add_document_form': InterventionDocumentForm(), 'deletable_document_ids': deletable_document_ids, 'linked_assets': linked_assets, 'operations_grouped': operations_grouped, 'operations_general_group': general_operations_group, 'operations_asset_groups': asset_operations_groups, 'orders': orders, 'contract_id': contract_id, 'can_control': can_control, 'is_draft': is_draft, 'providers': providers, 'contracts': contracts, 'maintain_choices': MAINTAIN_CHOICES, 'type_choices': TYPE_CHOICES, 'origin_type_choices': ORIGIN_TYPE_CHOICES, 'priority_choices': Intervention._meta.get_field('priority').choices, 'symptoms': Symptom.objects.filter(is_active=True, thematic=intervention.thematic).order_by('name_fr'), 'categories': categories_qs, 'source_categories': SourceCategory.objects.filter(is_active=True).order_by('name_fr'), 'occupations': occupations, 'show_occupations': show_occupations, 'occupations_required': occupations_required, # Avertissement si aucune prestation au passage à 'Traité' 'contract_warn_no_prestation_on_processed': bool(contract and getattr(contract, 'warn_if_no_prestation_on_processed', False)), 'has_prestations_encoded': intervention.interv_contract_posts.exists(), 'total_occupation_minutes': total_occupation_minutes, 'can_manage_occupations': can_manage_occupation_entries, 'occupation_members': occupation_members, 'occupation_form': occupation_form, 'projects': projects, 'project': project, 'is_user_intern': is_user_intern, 'allowed_update_fields': allowed_update_fields, 'can_edit_notes': can_edit_notes, 'can_manage_posts': 'posts' in allowed_update_fields, 'can_view_posts': can_view_posts, 'can_manage_posts': 'posts' in allowed_update_fields, 'can_edit_operation_comments': 'operations' in allowed_update_fields, 'quote_remaining_available': quote_remaining_available, 'has_coefficient_posts': has_coefficient_posts, "localisations": localisations, "copy_targets_json": copy_targets_json, 'hide_map_by_default': user_config.hide_intervention_map_by_default, 'pause_reason_choices': PAUSE_REASON_CHOICES, 'linked_groups': linked_groups, 'can_manage_links': can_manage_links, 'link_existing_form': LinkExistingInterventionsForm(), 'can_create_repair': can_create_repair, 'can_send_reminder': can_send_reminder, # Historique des interventions aux mêmes localisations 'location_history_interventions': location_history_interventions, # Period validation 'can_manage_period_validation': can_manage_period_validation, 'period_validation_enabled': getattr(intervention, 'period_validation_enabled', False), 'period_number': getattr(intervention, 'period_number', 1), 'period_is_final': getattr(intervention, 'period_is_final', False), 'has_period_quantities': intervention.period_quantities.exists(), 'future_planned_events': future_planned_events, 'future_realized_events': future_realized_events, # Historique des envois d'emails (ordre chronologique) 'email_logs': intervention.email_logs.order_by('sent_at'), # Operation editing data (same as mobile) 'operation_status_sets': OperationStatusSet.objects.all(), 'measurement_definitions': MeasurementDefinition.objects.all(), # Badge "créé par le prestataire" 'show_created_by_provider_badge': should_show_created_by_provider_badge(request.user, intervention), # Rôles de l'utilisateur pour filtrer les boutons de transition (desktop) 'user_roles': list(user_config.roles.values_list('name', flat=True)) if user_config else [], # Droit d'édition sur l'intervention (pour prise en charge par admin/manager/controller) 'can_edit_intervention': can_manage_links, # Droit de prise en charge : nécessite un contrat ET un accès au contrat 'can_take_over': bool(contract and user_contract_access), # Contrôles visibles par l'utilisateur 'visible_controls': visible_controls, # Équipes filtrées par thématique 'available_teams': available_teams, # Permission de voir les messages de correction 'can_view_correction_messages': can_view_correction_messages(request.user), # Bons de préparation 'can_create_preparation_order': can_create_preparation_order, 'preparation_orders': preparation_orders, # Config modal liaison assets (desktop) 'desktop_assets_enabled': can_manage_links, 'desktop_assets_config': desktop_assets_config, 'desktop_assets_location_labels': desktop_assets_location_labels, 'desktop_assets_max_results': MAX_MOBILE_ASSET_RESULTS, # Droit de vérification des interventions (can_check_interventions + needs_checking) 'can_check_intervention': can_check_intervention, # Photos obligatoires pour terminer l'intervention 'photos_required': bool(intervention.assigned_team and intervention.assigned_team.photos_required), 'photos_required_since': (lambda s: s.isoformat() if s else '')( _get_photos_required_since(intervention) ) if (intervention.assigned_team and intervention.assigned_team.photos_required) else '', # Observations liées 'linked_observations': linked_observations, # Observation source 'source_observation': source_observation, # Discussion 'discussion_messages': discussion_messages, 'can_post_discussion_message': can_post_discussion_message, # Document permissions 'can_delete_documents': 'documents' in allowed_update_fields, 'can_copy_documents': 'documents' in allowed_update_fields and copy_targets_json != '[]', # Guided operation template 'guided_template': intervention.guided_operation_template, 'guided_operation_data': getattr(intervention, 'guided_operation_data', None), 'guided_data_json': getattr(getattr(intervention, 'guided_operation_data', None), 'data', None) or {}, 'save_guided_url': reverse('interventions:save_guided_operation_ajax', kwargs={'intervention_id': intervention_id}), 'upload_guided_media_url': reverse('interventions:upload_guided_operation_media', kwargs={'intervention_id': intervention_id}), 'guided_media_json': _build_guided_media_json(getattr(intervention, 'guided_operation_data', None)), } from interventions.models import InterventionSubscription context['is_subscribed'] = InterventionSubscription.objects.filter(user=request.user, intervention=intervention).exists() return render(request, "interventions/intervention_detail.html", context) def link_existing_interventions(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: return HttpResponseForbidden(_("Vous n'avez pas la permission de modifier cette intervention.")) if not can_edit_intervention(request.user, intervention, user_config=user_config): return HttpResponseForbidden(_("Vous n'avez pas la permission de modifier cette intervention.")) form = LinkExistingInterventionsForm(request.POST) if not form.is_valid(): for error in form.errors.get('codes', []): messages.error(request, error) return redirect('interventions:intervention_detail', intervention_id=intervention.id) codes = form.cleaned_data['codes'] found_map = { obj.code.upper(): obj for obj in Intervention.objects.filter(code__in=codes) } created_codes = [] already_codes = [] missing_codes = [] restricted_codes = [] self_codes = [] for code in codes: if code == intervention.code: self_codes.append(code) continue target = found_map.get(code) if not target: missing_codes.append(code) continue if not can_view_intervention(request.user, target): restricted_codes.append(code) continue link, created = InterventionLink.link(intervention, target, InterventionLink.LinkType.RELATED) if created: created_codes.append(target.code) else: already_codes.append(target.code) if created_codes: messages.success( request, ngettext( "L'intervention %(codes)s a été liée à %(origin)s.", "Les interventions %(codes)s ont été liées à %(origin)s.", len(created_codes) ) % { 'codes': ", ".join(created_codes), 'origin': intervention.code, } ) if already_codes: messages.info( request, ngettext( "L'intervention %(codes)s était déjà liée à %(origin)s.", "Les interventions %(codes)s étaient déjà liées à %(origin)s.", len(already_codes) ) % { 'codes': ", ".join(already_codes), 'origin': intervention.code, } ) if restricted_codes: messages.warning( request, ngettext( "Vous n'avez pas accès à l'intervention %(codes)s.", "Vous n'avez pas accès aux interventions %(codes)s.", len(restricted_codes) ) % { 'codes': ", ".join(restricted_codes), } ) if missing_codes: messages.error( request, ngettext( "Aucune intervention trouvée pour le code %(codes)s.", "Aucune intervention trouvée pour les codes %(codes)s.", len(missing_codes) ) % { 'codes': ", ".join(missing_codes), } ) if self_codes: messages.warning( request, ngettext( "Le code %(codes)s correspond à l'intervention actuelle et a été ignoré.", "Les codes %(codes)s correspondent à l'intervention actuelle et ont été ignorés.", len(self_codes) ) % { 'codes': ", ".join(self_codes), } ) return redirect('interventions:intervention_detail', intervention_id=intervention.id) def create_repair_intervention(request, intervention_id): intervention = get_object_or_404(Intervention, pk=intervention_id) if not can_add_repair_intervention(request.user, intervention): return HttpResponseForbidden(_("Vous n'avez pas la permission de créer une intervention de réparation pour cette thématique.")) variant = request.POST.get('variant') if "/mobile/" in request.path: variant = 'mobile' assigned_member = CompanyMember.objects.filter(user=request.user).prefetch_related('teams__company').first() if assigned_member and assigned_member.teams.all(): assigned_team = assigned_member.teams.all()[0] assigned_provider = assigned_team.company else: assigned_member = intervention.assigned_member assigned_team = intervention.assigned_team assigned_provider = intervention.assigned_provider repair_type = 'repair_post_preventive' with transaction.atomic(): new_intervention = Intervention( title=f"REPAIR ({intervention.title})", description=f"{_('Réparation à effectuer')} ({_('origine')}: {intervention.code})", maintain_type='corrective', type=repair_type, origin_type=intervention.origin_type, priority=intervention.priority, thematic=intervention.thematic, asset_category=intervention.asset_category, symptom=intervention.symptom, contract=intervention.contract, order=None, assigned_provider=assigned_provider, assigned_team=assigned_team, assigned_member=assigned_member, lon=intervention.lon, lat=intervention.lat, address=intervention.address, location_code=intervention.location_code, geom=intervention.geom, source_category=None, source_detail=intervention.code, source_ref=intervention.code, init_description=f"{_('Réparation à effectuer')} ({_('origine')}: {intervention.code})", status='to_be_processed', status_order=STATUS_ORDERS['to_be_processed'], created_by=request.user, ) new_intervention.save() # Auto-vérification si l'utilisateur a le droit can_check_interventions auto_check_if_eligible(request.user, new_intervention) for location in intervention.locations.all(): InterventionLocation.objects.get_or_create( intervention=new_intervention, content_type=location.content_type, object_id=location.object_id, ) # Copier uniquement les équipements sélectionnés (ou tous si aucun filtre fourni) if request.POST.get('assets_filter_enabled'): selected_asset_ids = [int(v) for v in request.POST.getlist('selected_assets') if v.isdigit()] assets_to_copy = intervention.interventionasset_set.filter(pk__in=selected_asset_ids) else: assets_to_copy = intervention.interventionasset_set.all() for asset in assets_to_copy: InterventionAsset.objects.get_or_create( intervention=new_intervention, content_type=asset.content_type, object_id=asset.object_id, ) for project_link in ProjectIntervention.objects.filter(intervention=intervention): ProjectIntervention.objects.create( intervention=new_intervention, project=project_link.project, phase=project_link.phase, row_number=project_link.row_number, ) InterventionLink.link(intervention, new_intervention, InterventionLink.LinkType.REPAIR) InterventionTimeLine.objects.create( intervention=new_intervention, event_user=request.user, event_time=now(), event_type='creation', from_status=None, to_status=new_intervention.status, ) messages.success( request, _("Une intervention de réparation %(code)s a été créée à partir de %(origin)s.") % { 'code': new_intervention.code, 'origin': intervention.code, } ) if variant == 'mobile': return redirect('mobile:intervention_detail_mobile', intervention_id=new_intervention.id) return redirect('interventions:intervention_detail', intervention_id=intervention.id) def add_repair_intervention_modal(request): """ Vue AJAX pour créer une intervention de réparation via modale desktop. Retourne du JSON pour permettre la réponse AJAX. """ from django.http import JsonResponse from django.views.decorators.http import require_http_methods # Vérifier les permissions try: user_config = UserConfig.objects.get(user=request.user) except UserConfig.DoesNotExist: return JsonResponse({ 'success': False, 'error': _("Configuration utilisateur non trouvée.") }, status=403) # Vérifier que l'utilisateur a la thématique structures par défaut default_thematic = user_config.default_thematic if not default_thematic or default_thematic.code != 'structures': return JsonResponse({ 'success': False, 'error': _("Cette fonctionnalité est réservée à la thématique structures.") }, status=403) # Vérifier que l'utilisateur est technicien avec droits d'édition is_technician = user_config.roles.filter(name='technician').exists() if not is_technician: return JsonResponse({ 'success': False, 'error': _("Cette fonctionnalité est réservée aux techniciens.") }, status=403) has_edit_rights = UserThematics.objects.filter( user_config=user_config, thematic=default_thematic, can_edit_interventions=True ).exists() if not has_edit_rights: return JsonResponse({ 'success': False, 'error': _("Vous n'avez pas les droits pour créer des interventions.") }, status=403) # Récupérer les données du formulaire location_id = request.POST.get('location_id') category_id = request.POST.get('category_id') asset_id = request.POST.get('asset_id') title = request.POST.get('title', '').strip() # Validation errors = [] if not location_id: errors.append(_("La localisation est obligatoire.")) if not category_id: errors.append(_("La catégorie est obligatoire.")) if not title: errors.append(_("Le titre est obligatoire.")) if errors: return JsonResponse({ 'success': False, 'error': ' '.join(errors) }, status=400) # Récupérer la localisation try: location = StructureLocation.objects.select_related('structure').get(id=location_id) except StructureLocation.DoesNotExist: return JsonResponse({ 'success': False, 'error': _("Localisation non trouvée.") }, status=400) # Récupérer la catégorie try: category = AssetCategory.objects.get(id=category_id) except AssetCategory.DoesNotExist: return JsonResponse({ 'success': False, 'error': _("Catégorie non trouvée.") }, status=400) # Récupérer les coordonnées de la structure ou de la localisation structure = location.structure lon = None lat = None address = None if structure: if structure.main_location: main_loc = structure.main_location if main_loc.lon and main_loc.lat: lon = main_loc.lon lat = main_loc.lat address = main_loc.address if hasattr(main_loc, 'address') else None # Déterminer l'équipe et le prestataire assigned_team = None assigned_provider = None assigned_member = None company_member = CompanyMember.objects.filter(user=request.user).prefetch_related('teams__company').first() if company_member: m_teams = company_member.teams.all() assigned_team = m_teams[0] if m_teams else None assigned_provider = assigned_team.company if assigned_team else None assigned_member = company_member # Chercher un contrat actif pour structures contract = None if structure: structure_contract = StructureContract.objects.filter( structure=structure, contract__is_active=True ).select_related('contract').first() if structure_contract: contract = structure_contract.contract with transaction.atomic(): new_intervention = Intervention( title=title, description=_("Intervention de réparation créée depuis l'application web."), maintain_type='corrective', type='repair', origin_type='damage', priority='3', thematic=default_thematic, asset_category=category, contract=contract, assigned_provider=assigned_provider, assigned_team=assigned_team, assigned_member=assigned_member, lon=lon, lat=lat, address=address, location_code=location.code if location else None, status='to_be_processed', status_order=STATUS_ORDERS['to_be_processed'], created_by=request.user, ) new_intervention.save() # Auto-vérification si l'utilisateur a le droit can_check_interventions auto_check_if_eligible(request.user, new_intervention) # Ajouter la localisation location_ct = ContentType.objects.get_for_model(StructureLocation) InterventionLocation.objects.create( intervention=new_intervention, content_type=location_ct, object_id=location.id, ) # Ajouter l'équipement si spécifié if asset_id: from assets.models import StructureGeoAsset try: asset = StructureGeoAsset.objects.get(id=asset_id) asset_ct = ContentType.objects.get_for_model(StructureGeoAsset) InterventionAsset.objects.create( intervention=new_intervention, content_type=asset_ct, object_id=asset.id, ) except StructureGeoAsset.DoesNotExist: pass # Créer l'entrée dans la timeline InterventionTimeLine.objects.create( intervention=new_intervention, event_user=request.user, event_time=now(), event_type='creation', from_status=None, to_status=new_intervention.status, ) return JsonResponse({ 'success': True, 'code': new_intervention.code, 'id': new_intervention.id, }) def choose_thematic_before_add(request): intervention_type = request.GET.get('type', 'corrective') # 'corrective' | 'preventive' | 'ameliorative' symptoms = [] # Paramètres additionnels éventuellement propagés (commande/projet/redirection) order_id = request.GET.get('order_id') project_id = request.GET.get('project_id') redirect_to_order = request.GET.get('redirect_to_order') # 1) Récupère les thématiques éditables pour l'utilisateur if not getattr(request.user, 'config', None): return HttpResponseForbidden(_("Aucune configuration utilisateur n'est associée à votre compte.")) editable_links_qs = ( UserThematics.objects .filter(user_config=request.user.config, can_edit_interventions=True) .select_related('thematic') ) # Filtrage optionnel si on reçoit une liste de thématiques autorisées (pour un projet donné) allowed_codes = request.GET.getlist('allowed_thematic') if allowed_codes: editable_links_qs = editable_links_qs.filter(thematic__code__in=allowed_codes) # Aucun droit d'édition -> 403 (ou un message dans la page si tu préfères) if not editable_links_qs.exists(): return HttpResponseForbidden(_("Vous n'avez pas de droit d'édition sur une thématique d'intervention.")) # 2) Bypass si une seule thématique if editable_links_qs.count() == 1: thematic = editable_links_qs.first().thematic base_params = {} if intervention_type == 'preventive': path = reverse('interventions:intervention_add_preventive', kwargs={'thematic_code': thematic.code}) else: path = reverse('interventions:intervention_add', kwargs={'thematic_code': thematic.code}) if intervention_type == 'ameliorative': base_params['maintain_type'] = 'ameliorative' else: # corrective base_params['maintain_type'] = 'corrective' if order_id: base_params['order_id'] = order_id if redirect_to_order is not None: base_params['redirect_to_order'] = redirect_to_order if project_id: base_params['project_id'] = project_id if base_params: return redirect(f"{path}?{urlencode(base_params)}") return redirect(path) # 3) Construit la grille des thématiques autorisées (nom selon langue, icône depuis Thematic.icon) lang = (get_language() or "fr").lower() allowed_thematics = [ut.thematic for ut in editable_links_qs] def build_url(thematic_code: str) -> str: base_params = {} if intervention_type == 'preventive': path = reverse('interventions:intervention_add_preventive', kwargs={'thematic_code': thematic_code}) else: path = reverse('interventions:intervention_add', kwargs={'thematic_code': thematic_code}) if intervention_type == 'ameliorative': base_params['maintain_type'] = 'ameliorative' else: # corrective base_params['maintain_type'] = 'corrective' if order_id: base_params['order_id'] = order_id if redirect_to_order is not None: base_params['redirect_to_order'] = redirect_to_order if project_id: base_params['project_id'] = project_id if base_params: return f"{path}?{urlencode(base_params)}" return path thematics = [] for th in allowed_thematics: thematics.append({ 'name': th.name, # property -> FR/NL selon langue active 'url': build_url(th.code), 'icon': th.icon or 'bi-collection', # fallback si pas d’icône 'color': getattr(th, 'color', '#000000'), 'code': th.code, }) thematics = sorted( thematics, key=lambda t: t['name'].lower() if t['name'] else "" ) # 4) Symptômes (uniquement pour le correctif, comme avant), limités aux thématiques autorisées if intervention_type == 'corrective': allowed_codes = [th.code for th in allowed_thematics] qs = (Symptom.objects .filter(is_active=True, thematic__code__in=allowed_codes) .select_related("thematic")) # affichage selon la langue (NL => fallback FR, sinon FR) if lang.startswith("nl"): qs = qs.annotate(display_name=Coalesce(F("name_nl"), F("name_fr"), output_field=CharField())) else: qs = qs.annotate(display_name=F("name_fr")) # tri ergonomique qs = qs.order_by(Lower("display_name"), "thematic__id", "order_number") for s in qs: symptoms.append({ "label": f"{s.display_name} — {s.thematic.name}", "value": s.display_name, "id": s.id, "thematic": s.thematic.code, }) return render( request, 'interventions/add/choose_thematic.html', { 'thematics': thematics, 'intervention_type': intervention_type, 'symptoms': symptoms, } ) def check_assets_locations_status(request): """ AJAX endpoint to check if selected assets/locations have active status. POST parameters: - locations: JSON array [{"ct": content_type_id, "obj": object_id}, ...] - assets: JSON array [{"ct": content_type_id, "obj": object_id}, ...] - maintain_type: 'corrective', 'preventive', 'ameliorative' Returns: { "inactive_items": [ { "type": "location" | "asset", "model_name": "trafficlightintersection", "object_id": 123, "status": "inactive", "display_name": "TL-001", "status_label": "Désactivé" }, ... ] } """ try: locations_json = request.POST.get('locations', '[]') assets_json = request.POST.get('assets', '[]') maintain_type = request.POST.get('maintain_type', 'corrective') # Only check for corrective interventions if maintain_type != 'corrective': return JsonResponse({'inactive_items': []}) inactive_items = [] # Parse location and asset pairs location_pairs = _parse_pairs_json(locations_json) asset_pairs = _parse_pairs_json(assets_json) # Fallback: flux mono-sélection quand les champs JSON sont vides. if not location_pairs: location_ct = request.POST.get('location_ct') location_id = request.POST.get('location_id') if location_ct and location_id: location_pairs = [(int(location_ct), int(location_id))] if not asset_pairs: equipment_ct = request.POST.get('equipment_ct') equipment_id = request.POST.get('equipment_id') if equipment_ct and equipment_id: asset_pairs = [(int(equipment_ct), int(equipment_id))] # Check locations (only TrafficLightIntersection has a status field) for ct_id, obj_id in location_pairs: try: ct = ContentType.objects.get_for_id(ct_id) Model = ct.model_class() # Only check TrafficLightIntersection for now (it's the only location with status) if Model.__name__ == 'TrafficLightIntersection': obj = Model.objects.filter(pk=obj_id).first() if obj and hasattr(obj, 'status') and obj.status != 'active': status_label = obj.get_status_display() if hasattr(obj, 'get_status_display') else obj.status inactive_items.append({ 'type': 'location', 'model_name': ct.model, 'object_id': obj_id, 'status': obj.status, 'display_name': obj.code if hasattr(obj, 'code') else str(obj), 'status_label': str(status_label), }) except Exception as e: # Silently skip errors pass # Check assets (all assets have a status field) for ct_id, obj_id in asset_pairs: try: ct = ContentType.objects.get_for_id(ct_id) Model = ct.model_class() obj = Model.objects.filter(pk=obj_id).first() if obj and hasattr(obj, 'status') and obj.status != 'active': status_label = obj.get_status_display() if hasattr(obj, 'get_status_display') else obj.status inactive_items.append({ 'type': 'asset', 'model_name': ct.model, 'object_id': obj_id, 'status': obj.status, 'display_name': obj.code if hasattr(obj, 'code') else str(obj), 'status_label': str(status_label), }) except Exception as e: # Silently skip errors pass return JsonResponse({'inactive_items': inactive_items}) except Exception as e: return JsonResponse({'inactive_items': [], 'error': str(e)}, status=400) def interventions_add(request, thematic_code): thematic = Thematic.objects.filter(code=thematic_code).first() inspection_config = None inspection_config_id = request.GET.get('inspection_config_id') or request.POST.get('inspection_config_id') if inspection_config_id: from interventions.models import InspectionConfiguration inspection_config = InspectionConfiguration.objects.select_related('default_category').filter(pk=inspection_config_id).first() allowed_couples = [] if inspection_config: from interventions.permissions import can_user_create_inspection_for_contract # Récupérer les couples autorisés pour cet utilisateur for cp in inspection_config.contracts_providers.select_related('contract', 'provider').all(): if can_user_create_inspection_for_contract(request.user, cp.contract): allowed_couples.append(cp) # Si l'utilisateur n'est pas admin et qu'aucun couple n'est autorisé try: is_admin = request.user.config.roles.filter(name='admin').exists() except Exception: is_admin = False if not is_admin and not allowed_couples and inspection_config.contracts_providers.exists(): from django.http import HttpResponseForbidden return HttpResponseForbidden(_("Vous n'avez pas la permission de créer cette inspection pour les contrats associés.")) if inspection_config: maintain_type = inspection_config.maintain_type else: maintain_type = request.GET.get('maintain_type', 'corrective') symptom_id = request.GET.get('symptom_id') order_id = request.GET.get('order_id') or request.POST.get('order_id') project_id_from_params = request.GET.get('project_id') or request.POST.get('project_id') redirect_to_project = request.GET.get('redirect_to_project') or request.POST.get('redirect_to_project') if not thematic: return redirect('/interventions/choose-thematic/') # Redirect if thematic unknown # Sublocation and per-thematic scripts are now inlined via {% if %} conditionals # in the shared partials (_intervention_add_form.html, _intervention_add_scripts.html). # The old per-thematic child templates (intervention_add_structures.html, etc.) # are no longer used. symptoms = Symptom.objects.filter(thematic=thematic, is_active=True).prefetch_related('asset_categories') user_config = request.user.config # Construire la liste des symptômes avec leurs données lang = (get_language() or "fr").lower() symptoms_data = [] for s in symptoms: cat_ids = [ac.id for ac in s.asset_categories.all()] symptoms_data.append({ "id": s.id, "name": s.name_nl if lang.startswith("nl") and s.name_nl else s.name_fr, "order_number": s.order_number, "require_equipment_selection": bool(s.require_equipment_selection), # Uniquement les IDs des catégories directement liées au symptôme. # Ainsi, quand une sous-catégorie est sélectionnée, seuls les symptômes # explicitement liés à cette sous-catégorie apparaissent. "asset_categories": cat_ids, "priority": s.priority, "origin_type": s.origin_type or '', "check_priority": s.check_priority, }) # Trier: d'abord par order_number (si défini), puis alphabétiquement par nom def symptom_sort_key(s): has_order = 0 if s["order_number"] is not None else 1 order_num = s["order_number"] if s["order_number"] is not None else 999999 name = (s["name"] or "").lower() return (has_order, order_num, name, s["id"]) symptoms_data.sort(key=symptom_sort_key) # Données des catégories de source pour le JavaScript source_categories_data = [ { "id": sc.id, "name": sc.getName(), "has_reference": sc.has_reference, } for sc in SourceCategory.objects.filter(is_active=True) ] if request.method == 'POST': maintain_type = request.POST.get('maintain_type', 'corrective') form = InterventionForm(request.POST, request.FILES, thematic=thematic, user=request.user) if form.is_valid(): intervention = form.save(commit=False) intervention.thematic = thematic intervention.created_by = request.user intervention.lon = float(request.POST.get('lon')) if request.POST.get('lon') else None intervention.lat = float(request.POST.get('lat')) if request.POST.get('lat') else None geom_json = request.POST.get('geom') if geom_json: # Si une géométrie est fournie (en GeoJSON) try: geom = GEOSGeometry(geom_json, srid=4326) # GeoJSON est en WGS84 geom.transform(3812) intervention.geom = geom except Exception as e: raise ValueError(f"{_("Géométrie invalide")} : {e}") else: # Crée un buffer autour du point (lon, lat) if intervention.lon is not None and intervention.lat is not None: point = Point(intervention.lon, intervention.lat, srid=4326) point.transform(3812) buffer = point.buffer(1) # 1 mètre intervention.geom = MultiPolygon(buffer) intervention.expected_begin_time = now() + timedelta(hours=2) # Lien éventuel avec une commande passée dans les paramètres if order_id: try: order = ContractOrder.objects.select_related('contract').prefetch_related('projects').get(pk=order_id) if not user_has_access_to_contract_order(request.user, order.id): return HttpResponseForbidden(_("Vous n'avez pas accès à cette commande.")) intervention.order = order intervention.contract = order.contract intervention.assigned_provider = order.contract.company # Si la commande a un seul projet, on l'associe à l'intervention if order.projects.count() == 1: intervention.project = order.projects.first() except ContractOrder.DoesNotExist: pass if inspection_config: intervention.type = 'inspection' resolved_cp = None if allowed_couples: # Tenter de faire correspondre avec le contrat de la commande si présent if intervention.contract: resolved_cp = next((cp for cp in allowed_couples if cp.contract == intervention.contract), None) if not resolved_cp: resolved_cp = allowed_couples[0] elif is_admin and inspection_config.contracts_providers.exists(): resolved_cp = inspection_config.contracts_providers.first() if resolved_cp: intervention.contract = resolved_cp.contract intervention.assigned_provider = resolved_cp.provider intervention.save() # Si aucun contrat encore défini via la commande, on utilise celui du symptôme if not intervention.contract and intervention.symptom: intervention.contract = intervention.symptom.contract if intervention.symptom and not intervention.assigned_provider: intervention.assigned_provider = intervention.symptom.provider # Si le statut est "assigned" (pris en charge) et aucun membre n'est assigné, assigner l'utilisateur créateur et sa première équipe if intervention.status == 'assigned' and not getattr(intervention, 'assigned_member_id', None): from contracts.models import CompanyMember company_member = CompanyMember.objects.filter(user=request.user).first() if company_member: intervention.assigned_member = company_member first_team = company_member.teams.first() if first_team: intervention.assigned_team = first_team intervention.save() redirect_to_project = False # Lien au projet (paramètre explicite ou champ du formulaire) project_param_id = project_id_from_params or request.POST.get('project') if project_param_id: project = Project.objects.filter(id=project_param_id).first() if project: ProjectIntervention.objects.create( intervention=intervention, project=project ) # Récupérer redirect_to_project depuis GET ou POST redirect_to_project = ( request.GET.get('redirect_to_project') == 'true' or request.POST.get('redirect_to_project') == 'true' ) if project.osiris_number and not intervention.osiris: intervention.osiris = project.osiris_number intervention.save(update_fields=['osiris']) init_note = request.POST.get('init_description') if init_note: note = InterventionNote.objects.create( intervention=intervention, note_author=request.user, note_time=now(), content=request.POST.get('init_description', ''), note_type=InterventionNote.NoteType.ORIGIN, ) event = InterventionTimeLine.objects.create( intervention=intervention, event_user=request.user, event_time=now(), event_type='creation', from_status=None, to_status=intervention.status ) expected_begin = InterventionPlanificationTimeLine.objects.create( intervention=intervention, event_user=request.user, event_time=now() + timedelta(hours=2), event_type='expected_begin', event_description=_("Début prévu initial.") ) files = form.cleaned_data["documents"] for file in files: InterventionDocument.objects.create(intervention=intervention, file=file, uploaded_by=request.user) mismatches = getattr(form, "fms_filename_mismatch", []) reference = getattr(form, "fms_filename_reference", None) if mismatches and reference: documents_list = ", ".join(mismatches) messages.warning( request, _( "Le numéro FMS %(fms)s n'apparaît pas dans le nom des documents suivants : %(documents)s" ) % {"fms": reference, "documents": documents_list}, ) # -------- Localisations -------- # Priorité au JSON 'locations', sinon pair unique 'location_ct'/'location_id' location_pairs = _parse_pairs_json(request.POST.get('locations')) if not location_pairs: ct = request.POST.get('location_ct') oid = request.POST.get('location_id') if ct and oid: location_pairs = [(int(ct), int(oid))] first_loc_obj = None for ct_id, obj_id in location_pairs: ct = ContentType.objects.get_for_id(ct_id) InterventionLocation.objects.get_or_create( intervention=intervention, content_type=ct, object_id=obj_id, ) # mémoriser le 1er objet pour updater location_code if first_loc_obj is None: Model = ct.model_class() first_loc_obj = Model.objects.filter(pk=obj_id).only('code').first() if first_loc_obj and hasattr(first_loc_obj, 'code'): intervention.location_code = first_loc_obj.code intervention.save(update_fields=['location_code']) # -------- Recherche contrat -------- # Si thématique 'structures', recherche d'une règle de routage spécifique dans contract_routing.json if thematic.code == 'structures': structure_location_ct = ContentType.objects.get_for_model(StructureLocation) structure_loc = InterventionLocation.objects.filter( intervention=intervention, content_type=structure_location_ct ).first() if structure_loc: structure_location = StructureLocation.objects.filter(id=structure_loc.object_id).only('code').first() structure = structure_location.structure if structure_location else None if structure: from interventions.models import DirectContractRouting # 1. Tenter de trouver une règle avec le symptôme spécifique routing = DirectContractRouting.objects.filter( thematic='structures', asset_code=structure.code, symptom=intervention.symptom ).select_related('contract').first() # 2. Si non trouvé, tenter de trouver la règle par défaut pour cet asset if not routing: routing = DirectContractRouting.objects.filter( thematic='structures', asset_code=structure.code, symptom__isnull=True ).select_related('contract').first() if routing and routing.contract: contract = routing.contract intervention.contract = contract intervention.assigned_provider = contract.company intervention.save(update_fields=['contract', 'assigned_provider']) # Si pas de contrat défini et que le symptôme a un maintenance_type, # chercher le contrat actif lié à ce type de maintenance. if ( not intervention.contract and intervention.symptom and intervention.symptom.maintenance_type ): contract = None if thematic.code == 'trafficlights': # Récupérer l'intersection depuis les localisations tl_intersection_ct = ContentType.objects.get_for_model(TrafficLightIntersection) intersection_location = InterventionLocation.objects.filter( intervention=intervention, content_type=tl_intersection_ct ).first() if intersection_location: # Chercher le TrafficLightContract correspondant au maintenance_type du symptôme contract = TrafficLightContract.objects.filter( intersection_id=intersection_location.object_id, maintenance_type=intervention.symptom.maintenance_type, status='active' ).select_related('contract__company').first() elif thematic.code == 'structures': structure_location_ct = ContentType.objects.get_for_model(StructureLocation) structure_loc = InterventionLocation.objects.filter( intervention=intervention, content_type=structure_location_ct ).first() structure_location = StructureLocation.objects.filter(id=structure_loc.object_id).first() if structure_loc else None structure = structure_location.structure if structure_location else None if structure: contract = StructureContract.objects.filter( structure_id=structure.id, maintenance_type=intervention.symptom.maintenance_type, status='active' ).select_related('contract__company').first() elif thematic.code == 'clean': location_ct = ContentType.objects.get_for_model(CleanLocation) clean_loc = InterventionLocation.objects.filter( intervention=intervention, content_type=location_ct ).first() clean_location = CleanLocation.objects.filter(id=clean_loc.object_id).first() if clean_loc else None if clean_location: contract = CleanLocationContract.objects.filter( location_id=clean_location.id, maintenance_type=intervention.symptom.maintenance_type, status='active' ).select_related('contract__company').first() if contract: intervention.contract = contract.contract if not intervention.assigned_provider: intervention.assigned_provider = contract.contract.company intervention.save(update_fields=['contract', 'assigned_provider']) # -------- Assets (optionnel) -------- # 1) JSON 'assets' = [{ct,obj}] (recommandé) asset_pairs = _parse_pairs_json(request.POST.get('assets')) # 2) Fallbacks: equipment_ct/equipment_id ou equipment_type/equipment_id if not asset_pairs: equip_ct = request.POST.get('equipment_ct') equip_id = request.POST.get('equipment_id') equip_type = request.POST.get('equipment_type') # ancien flux if equip_ct and equip_id: asset_pairs = [(int(equip_ct), int(equip_id))] elif equip_type and equip_id: # ATTENTION: .get(model=...) suppose unicité du model name à travers les apps # Préfère envoyer equip_ct depuis le front si possible. ct = ContentType.objects.get(model=equip_type.lower()) asset_pairs = [(ct.id, int(equip_id))] for ct_id, obj_id in asset_pairs: ct = ContentType.objects.get_for_id(ct_id) InterventionAsset.objects.get_or_create( intervention=intervention, content_type=ct, object_id=obj_id, ) if inspection_config: templates = list(inspection_config.operation_templates.all()) if templates: intervention.equipment_operation_templates.add(*templates) _assign_guided_template(intervention, templates) _add_operations_from_templates(intervention, templates, only_generic=True) if asset_pairs: resolved_assets = _resolve_assets_from_pairs(asset_pairs) _create_equipment_operations_for_assets(intervention, resolved_assets, ignore_filters=True) # -------- Recherche contrat ITS (après sauvegarde des assets) -------- if ( thematic.code == 'its' and not intervention.contract and intervention.symptom and intervention.symptom.maintenance_type ): its_equipment_ct = ContentType.objects.get_for_model(ITSGeoAsset) its_equipment = InterventionAsset.objects.filter( intervention=intervention, content_type=its_equipment_ct ).first() if its_equipment: its_contract = ITSGeoAssetContract.objects.filter( asset_id=its_equipment.object_id, maintenance_type=intervention.symptom.maintenance_type, status='active' ).select_related('contract__company').first() if its_contract: intervention.contract = its_contract.contract if not intervention.assigned_provider: intervention.assigned_provider = its_contract.contract.company intervention.save(update_fields=['contract', 'assigned_provider']) # -------- Forcer le statut 'à approuver' pour les utilisateurs externes -------- # Applicable si limit_interventions_to_contracts est activé et que l'utilisateur # n'a pas accès au contrat résolu (ou à celui du symptôme). if user_config and not user_config.is_intern and user_config.limit_interventions_to_contracts: _force_approved = False _symptom = intervention.symptom if _symptom: if _symptom.contract_id: # Cas 1 : le symptôme est explicitement lié à un contrat # → l'utilisateur doit avoir accès à ce contrat pour éviter to_be_approved _has_access = UserContractAccess.objects.filter( user_config=user_config, contract_id=_symptom.contract_id, ).exists() if not _has_access: _force_approved = True elif _symptom.maintenance_type: # Cas 2 : pas de contrat sur le symptôme mais maintenance_type défini # → vérifier si le contrat résolu (depuis la localisation/asset) est accessible if intervention.contract_id: _has_access = UserContractAccess.objects.filter( user_config=user_config, contract_id=intervention.contract_id, ).exists() if not _has_access: _force_approved = True else: # Aucun contrat résolu : la localisation/asset n'est pas liée # à un contrat de l'utilisateur → approbation requise _force_approved = True if _force_approved and intervention.status != 'to_be_approved': intervention.status = 'to_be_approved' intervention.save(update_fields=['status', 'status_order']) # Mettre à jour l'entrée de timeline créée lors de la création event.to_status = 'to_be_approved' event.save(update_fields=['to_status']) # Determine redirect URL based on whether we came from a project or order if redirect_to_project and project_param_id: messages.success(request, _("L'intervention %(code)s a été créée avec succès et liée au projet.") % {'code': intervention.code}) return redirect(reverse('projects:projects_detail', kwargs={'project_id': project_param_id})) elif order_id and request.GET.get('redirect_to_order') is not None: messages.success(request, _("L'intervention %(code)s a été créée avec succès et liée à la commande.") % {'code': intervention.code}) return redirect(reverse('contracts:contracts_orders_detail', kwargs={'order_id': order_id})) else: # Si on vient d'une observation (observation_id en query), lier l'observation à l'intervention créée # et copier SEULEMENT les photos cochées par l'utilisateur. obs_id = request.GET.get('observation_id') or request.POST.get('observation_id') if obs_id: try: from observations.models import Observation, ObservationPhoto, ObservationComment obs = Observation.objects.get(pk=int(obs_id)) obs.intervention = intervention obs.save(update_fields=['intervention']) # Mettre l'observation à 'in_progress' quand l'intervention est créée if obs.status != 'processed' and obs.status != 'archived': obs.status = 'in_progress' obs.save(update_fields=['status']) # Consigner un commentaire automatique try: ObservationComment.objects.create( observation=obs, author=request.user if request.user.is_authenticated else None, comment=f"Observation mise en cours via création de l'intervention {intervention.code}", ) except Exception: pass # Copier uniquement les photos sélectionnées dans le formulaire selected_ids = request.POST.getlist('selected_obs_photos') if selected_ids: from django.core.files.base import ContentFile from os.path import basename photos_qs = ObservationPhoto.objects.filter(observation=obs, id__in=selected_ids) for photo in photos_qs: try: if not photo.photo: continue photo.photo.open('rb') InterventionDocument.objects.create( intervention=intervention, file=ContentFile(photo.photo.read(), name=basename(photo.photo.name)), uploaded_by=request.user, ) finally: try: photo.photo.close() except Exception: pass except Exception: pass # Redirect to mobile detail if coming from mobile, otherwise desktop detail if '/mobile/' in request.path or 'add-mobile' in request.path: return redirect(reverse('mobile:intervention_detail_mobile', kwargs={'intervention_id': intervention.id})) return redirect(reverse('interventions:intervention_detail', kwargs={'intervention_id': intervention.id})) else: initial = {"maintain_type": maintain_type} if inspection_config: lang = get_language() name = inspection_config.name_nl if lang == 'nl' and inspection_config.name_nl else inspection_config.name_fr import re cleaned_name = name cleaned_name = re.sub(r'^(inspection|inspectie)\b\s*[-–—]?\s*', '', cleaned_name, flags=re.IGNORECASE) cleaned_name = re.sub(r'\s*[-–—]?\s*\b(inspection|inspectie)$', '', cleaned_name, flags=re.IGNORECASE) cleaned_name = cleaned_name.strip() page_title = _("Nouvelle inspection - %(name)s") % {'name': cleaned_name} initial.update({ "type": "inspection", "status": "assigned", "title": page_title }) if inspection_config.default_category_id: if inspection_config.default_category.parent_id: initial["asset_category"] = inspection_config.default_category.parent_id else: initial["asset_category"] = inspection_config.default_category_id if request.method == "GET": # Support both 'symptom' and 'symptom_id' selected_symptom_id = request.GET.get('symptom') or symptom_id if selected_symptom_id: initial["symptom"] = selected_symptom_id # Le formulaire gère le pré-remplissage de asset_category selon le nombre de catégories # Hydrate initial values from GET for known fields for key in [ 'title', 'lat', 'lon', 'address', 'asset_category', 'status', 'source_category', 'source_ref', 'source_detail', 'init_description', 'type', 'origin_type', 'priority' ]: val = request.GET.get(key) if val not in (None, ""): initial[key] = val # Pre-fill project_id if coming from a project project_id_param = request.GET.get('project_id') if project_id_param: initial["project"] = project_id_param form = InterventionForm(thematic=thematic, user=request.user, initial=initial) # Extract initial sub-category ID from GET params (e.g. when coming from an observation) initial_interv_sub_category_id = request.GET.get('interv_sub_category_id') if request.method == 'GET' else None if not initial_interv_sub_category_id and request.method == 'GET' and inspection_config and inspection_config.default_category_id: if inspection_config.default_category.parent_id: initial_interv_sub_category_id = str(inspection_config.default_category_id) # Pre-load subcategories server-side so the sub-category block renders immediately (no AJAX needed) initial_subcategories = [] if initial_interv_sub_category_id: try: sub_cat_obj = AssetCategory.objects.select_related('parent').get(pk=int(initial_interv_sub_category_id)) if sub_cat_obj.parent_id: lang = (get_language() or 'fr').lower() order_field = 'name_nl' if lang.startswith('nl') else 'name_fr' siblings = AssetCategory.objects.filter(parent_id=sub_cat_obj.parent_id).order_by(order_field) initial_subcategories = [ {'id': s.id, 'name': s.get_name(), 'selected': s.id == sub_cat_obj.id} for s in siblings ] except (AssetCategory.DoesNotExist, ValueError): pass # GET branch: ajouter observation et photos pour affichage si présent observation = None observation_photos = [] if request.method == 'GET': obs_id_ctx = request.GET.get('observation_id') if obs_id_ctx: try: from observations.models import Observation observation = Observation.objects.select_related('created_by').prefetch_related('photos').get(pk=int(obs_id_ctx)) # Construire les URLs des photos pour le private storage observation_photos = [ { 'id': photo.id, 'url': build_private_url('observations', 'ObservationPhoto', photo.pk, 'photo') } for photo in observation.photos.all() ] except Exception: observation = None observation_photos = [] page_title = None if inspection_config: lang = get_language() name = inspection_config.name_nl if lang == 'nl' and inspection_config.name_nl else inspection_config.name_fr import re cleaned_name = name cleaned_name = re.sub(r'^(inspection|inspectie)\b\s*[-–—]?\s*', '', cleaned_name, flags=re.IGNORECASE) cleaned_name = re.sub(r'\s*[-–—]?\s*\b(inspection|inspectie)$', '', cleaned_name, flags=re.IGNORECASE) cleaned_name = cleaned_name.strip() page_title = _("Nouvelle inspection - %(name)s") % {'name': cleaned_name} # Render mobile template when accessed via the add-mobile URL is_mobile_form = 'add-mobile' in request.path chosen_template = 'interventions/add/intervention_add_mobile.html' if is_mobile_form else 'interventions/add/intervention_add.html' return render(request, chosen_template, { 'form': form, 'thematic': thematic, 'symptoms_data': json.dumps(symptoms_data), 'source_categories_data': json.dumps(source_categories_data), 'maintain_type': maintain_type, 'observation': observation, 'observation_photos': observation_photos, 'redirect_to_project': redirect_to_project, 'initial_interv_sub_category_id': initial_interv_sub_category_id, 'initial_subcategories': initial_subcategories, 'inspection_config': inspection_config, 'page_title': page_title, 'structure_location_ct_id': ContentType.objects.get_for_model(StructureLocation).id, 'traffic_light_intersection_ct_id': ContentType.objects.get_for_model(TrafficLightIntersection).id, 'control_center_location_ct_id': ContentType.objects.get_for_model(apps.get_model('assets', 'ControlCenterLocation')).id if apps.is_installed('assets') else None, }) def intervention_link_assets(request, intervention_id): intervention = get_object_or_404(Intervention, id=intervention_id) form = AssetsLinkForm(request.POST) if not form.is_valid(): messages.error(request, form.errors.as_text()) return redirect("interventions:detail", intervention_id) normalized = form.cleaned_data["assets"] # [{'model', 'object_id'}, ...] # ContentTypes en une fois model_names = sorted({a['model'] for a in normalized}) cts = {ct.model: ct for ct in ContentType.objects.filter(model__in=model_names)} missing = [m for m in model_names if m not in cts] if missing: messages.error(request, f"{_("Modèles inconnus")}: {', '.join(missing)}") return redirect("interventions:intervention_detail", intervention_id) # Paires cibles target_pairs = {(cts[a['model']].id, a['object_id']) for a in normalized} # Existant existing_qs = InterventionAsset.objects.filter(intervention=intervention) existing_pairs = set(existing_qs.values_list('content_type_id', 'object_id')) # Diff to_add = target_pairs - existing_pairs to_del = existing_pairs - target_pairs created_count = 0 deleted_count = 0 operations_deleted = 0 assets_with_values = 0 try: with transaction.atomic(): if to_del: q = Q() for ct_id, obj_id in to_del: q |= Q(content_type_id=ct_id, object_id=obj_id) deleted_count = existing_qs.filter(q).delete()[0] operations_deleted, assets_with_values = _delete_operations_for_assets( intervention, to_del, ) if to_add: objs = [ InterventionAsset( intervention=intervention, content_type_id=ct_id, object_id=obj_id ) for (ct_id, obj_id) in to_add ] InterventionAsset.objects.bulk_create(objs) created_count = len(objs) except Exception as e: messages.error(request, f"{_("Erreur de synchro")}: {e}") return redirect("interventions:intervention_detail", intervention_id) operations_created = 0 # For preventive interventions, store newly linked assets in session for template selection # For corrective interventions, create operations automatically if to_add: assets_with_ct = _resolve_assets_from_pairs(to_add) if intervention.maintain_type == 'preventive': # Store newly linked asset IDs in session for template selection modal new_asset_keys = [f"{ct_id}:{obj_id}" for ct_id, obj_id in to_add] session_key = f'new_linked_assets_{intervention_id}' request.session[session_key] = new_asset_keys request.session.modified = True else: # Corrective: create operations automatically as before operations_created = _create_equipment_operations_for_assets(intervention, assets_with_ct) base_message = _( "Assets synchronisés. Ajoutés: %(created)d • Supprimés: %(deleted)d • Total: %(total)d" ) % { "created": created_count, "deleted": deleted_count, "total": len(target_pairs), } if operations_deleted: base_message += _(" • Opérations supprimées: %(ops)d") % {"ops": operations_deleted} if operations_created: base_message += _(" • Opérations ajoutées: %(ops)d") % {"ops": operations_created} messages.success(request, base_message) if assets_with_values: warning_message = ngettext( "Certaines opérations n'ont pas été supprimées car des valeurs ont été encodées pour %(count)d asset.", "Certaines opérations n'ont pas été supprimées car des valeurs ont été encodées pour %(count)d assets.", assets_with_values, ) % {"count": assets_with_values} messages.warning(request, warning_message) # Redirige vers la page finale attendue (détail, liste, etc.) return redirect("interventions:intervention_detail", intervention_id) def intervention_link_order(request, intervention_id): data = json.loads(request.body) try: intervention = Intervention.objects.get(pk=intervention_id) allowed_update_fields = get_allowed_update_fields(request.user, intervention) if data['order_id'] == '': intervention.order = None else: intervention.order = ContractOrder.objects.get(pk=data['order_id']) intervention.save() quote_items = [] quote_total = None if intervention.order and 'posts' in allowed_update_fields: quote = getattr(intervention.order, 'quote', None) if quote and quote.is_finalized and quote.items.exists(): lang = get_language() def format_decimal(value): return f"{Decimal(value):.2f}" for item in quote.get_remaining_items( exclude_intervention_id=intervention.id, include_zero=True, include_current_intervention_periods=intervention.id ): post = item['contract_post'] description = post.description_fr if lang and lang.startswith('nl'): description = post.description_nl or description quote_items.append({ 'contract_post_id': post.id, 'order_number': post.order_number, 'code': post.code, 'description': description, 'unit': post.unit, 'planned_quantity': format_decimal(item['planned_quantity']), 'consumed_quantity': format_decimal(item['consumed_quantity']), 'remaining_quantity': format_decimal(item['remaining_quantity']), }) quote_total = format_decimal(intervention.order.get_quote_total_amount()) return JsonResponse({ 'success': True, 'contract_id': intervention.order.contract.id if intervention.order else None, 'quote_items': quote_items, 'quote_total': quote_total, 'can_manage_posts': 'posts' in allowed_update_fields, }) except (Intervention.DoesNotExist, ContractOrder.DoesNotExist) as e: return JsonResponse({'success': False, 'message': str(e)}) def intervention_apply_quote(request, intervention_id): intervention = get_object_or_404(Intervention, pk=intervention_id) allowed_update_fields = get_allowed_update_fields(request.user, intervention) if 'posts' not in allowed_update_fields: return JsonResponse({'success': False, 'message': _('Vous ne pouvez pas modifier les postes de cette intervention.')}, status=403) if not intervention.order: return JsonResponse({'success': False, 'message': _('Aucune commande n\'est associée à cette intervention.')}, status=400) quote = getattr(intervention.order, 'quote', None) if not quote or not quote.items.exists(): return JsonResponse({'success': False, 'message': _('Aucun devis n\'est disponible pour la commande liée.')}, status=404) if not quote.is_finalized: return JsonResponse({'success': False, 'message': _('Le devis doit être figé avant de pouvoir être appliqué.')}, status=400) items = quote.get_remaining_items( exclude_intervention_id=intervention.id, include_zero=True, include_current_intervention_periods=intervention.id ) created_count = 0 with transaction.atomic(): for item in items: remaining = item['remaining_quantity'] # if remaining <= Decimal('0'): # continue post = item['contract_post'] _, created = InterventionContractPost.objects.get_or_create( intervention=intervention, contract_post=post, ) if created: created_count += 1 return JsonResponse({'success': True, 'created': created_count, 'updated': created_count}) def update_contract_post(request, intervention_id): user_config = get_object_or_404(UserConfig, user=request.user) if request.method == "POST": data = json.loads(request.body) try: intervention = Intervention.objects.get(pk=intervention_id) contract_post = ContractPost.objects.get(pk=data['contract_post_id']) defaults = {} if quantity := data.get('quantity'): # Check if the user has the right roles to update contract posts # Only users with 'admin' or 'external_manager' roles can update if not user_config.roles.filter(name__in=['admin', 'external_manager']).exists(): return JsonResponse({'status': 'error', 'message': 'Permission denied'}, status=403) defaults['quantity'] = quantity if comment := data.get('comment'): # Check if the user has the right roles to update contract posts # Only users with 'admin' or 'external_manager' roles can update if not user_config.roles.filter(name__in=['admin', 'external_manager']).exists(): return JsonResponse({'status': 'error', 'message': 'Permission denied'}, status=403) defaults['comment'] = comment if control_comment := data.get('control_comment'): # Check if the user has the right roles to update contract posts # Only users with 'admin', 'manager', or 'controller' roles can update if not user_config.roles.filter(name__in=['admin', 'manager', 'controller']).exists(): return JsonResponse({'status': 'error', 'message': 'Permission denied'}, status=403) defaults['control_comment'] = control_comment post, created = InterventionContractPost.objects.update_or_create( intervention=intervention, contract_post=contract_post, defaults=defaults ) quote_remaining_quantity = None if intervention.order: quote = getattr(intervention.order, 'quote', None) if quote: remaining_items = quote.get_remaining_items( exclude_intervention_id=intervention.id, include_zero=True, include_current_intervention_periods=intervention.id, ) for item in remaining_items: if item['contract_post'].id == contract_post.id: quote_remaining_quantity = item['remaining_quantity'] break return JsonResponse({ 'status': 'updated' if not created else 'created', 'quote_remaining_quantity': str(quote_remaining_quantity) if quote_remaining_quantity is not None else None, }) except Exception as e: return JsonResponse({'status': 'error', 'error': str(e)}, status=400) elif request.method == "DELETE": # Check if the user has the right roles to update contract posts # Only users with 'admin', 'manager', or 'external_manager' roles can update if not user_config.roles.filter(name__in=['admin', 'external_manager']).exists(): return JsonResponse({'status': 'error', 'message': 'Permission denied'}, status=403) data = json.loads(request.body) try: post = InterventionContractPost.objects.get( intervention_id=intervention_id, contract_post_id=data['contract_post_id'] ) post.delete() return JsonResponse({'status': 'deleted'}) except InterventionContractPost.DoesNotExist: return JsonResponse({'status': 'not_found'}, status=404) return JsonResponse({'status': 'invalid'}, status=405) def update_contract_post_status(request, intervention_id): post_id = request.POST.get('post_id') status = request.POST.get('status') user_config = get_object_or_404(UserConfig, user=request.user) # Check if the user has the right roles to update contract posts # Only users with 'admin', 'manager', or 'controller' roles can update the status if not user_config.roles.filter(name__in=['admin', 'manager', 'controller']).exists(): return JsonResponse({'status': 'error', 'message': 'Permission denied'}, status=403) try: post = InterventionContractPost.objects.get(pk=post_id) post.control_status = status post.save() # Si le statut passe à 'validated', mettre à jour les périodes 'pending' de ce poste if status == 'validated': InterventionPostPeriodQuantity.objects.filter( intervention=post.intervention, contract_post=post.contract_post, status='pending' ).update(status='validated') return JsonResponse({'success': True}) except InterventionContractPost.DoesNotExist: return JsonResponse({'success': False, 'error': 'Post not found'}, status=404) def intervention_posts_bulk_update(request, intervention_id): """ Mise à jour en masse des postes de contrat d'une intervention. Permet de mettre à jour quantités, commentaires, statuts de contrôle et remarques. Format JSON attendu: { "posts": [ { "contract_post_id": 123, "quantity": "10.50", // optionnel "comment": "...", // optionnel "control_status": "validated", // optionnel: validated, pending, refused "control_comment": "...", // optionnel "delete": true // optionnel: si true, supprime la ligne existante }, ... ] } """ try: data = json.loads(request.body) posts_data = data.get('posts', []) if not posts_data: return JsonResponse({ 'success': False, 'message': _('Aucune donnée à mettre à jour') }, status=400) intervention = get_object_or_404(Intervention, pk=intervention_id) user_config = get_object_or_404(UserConfig, user=request.user) # Vérifier les permissions selon le type de modification can_update_posts = user_config.roles.filter(name__in=['admin', 'external_manager']).exists() can_control_posts = user_config.roles.filter(name__in=['admin', 'manager', 'controller']).exists() updated_count = 0 errors = [] with transaction.atomic(): for post_data in posts_data: try: contract_post_id = post_data.get('contract_post_id') if not contract_post_id: continue # Suppression directe si drapeau présent if post_data.get('delete'): if not can_update_posts: errors.append(_('Permission refusée pour la suppression de postes')) continue # Interdire la suppression si une quantité a été enregistrée dans une période précédente has_history = InterventionPostPeriodQuantity.objects.filter( intervention=intervention, contract_post_id=contract_post_id ).exists() if has_history: errors.append(_('Suppression interdite: une quantité a été enregistrée pour ce poste dans une période précédente.')) continue try: to_delete = InterventionContractPost.objects.get( intervention=intervention, contract_post__pk=contract_post_id ) to_delete.delete() updated_count += 1 except InterventionContractPost.DoesNotExist: # Rien à supprimer : ignorer silencieusement pass continue # Passer à l'élément suivant contract_post = get_object_or_404(ContractPost, pk=contract_post_id) # Préparer les données à mettre à jour defaults = {} quantity_value = None # Quantité et commentaire (nécessitent permission de gestion des postes) if 'quantity' in post_data: if not can_update_posts: errors.append(_('Permission refusée pour la mise à jour des quantités')) continue quantity = post_data['quantity'] if quantity not in (None, ''): try: quantity_value = Decimal(str(quantity)) defaults['quantity'] = quantity_value except (ValueError, InvalidOperation): errors.append(_('Quantité invalide pour le poste %(post)s') % {'post': contract_post.code}) continue if 'comment' in post_data: if not can_update_posts: errors.append(_('Permission refusée pour la mise à jour des commentaires')) continue defaults['comment'] = post_data['comment'] # Statut et remarque de contrôle (nécessitent permission de contrôle) if 'control_status' in post_data: if not can_control_posts: errors.append(_('Permission refusée pour la mise à jour des statuts')) continue defaults['control_status'] = post_data['control_status'] if 'control_comment' in post_data: if can_control_posts: defaults['control_comment'] = post_data['control_comment'] # Créer ou mettre à jour le poste if defaults: post_exists = InterventionContractPost.objects.filter( intervention=intervention, contract_post=contract_post ).exists() if not post_exists and (quantity_value is None or quantity_value == 0): # Ne pas créer de nouvelles lignes sans quantité non nulle continue post, created = InterventionContractPost.objects.update_or_create( intervention=intervention, contract_post=contract_post, defaults=defaults ) # Si le statut passe à 'validated', mettre à jour les périodes 'pending' de ce poste if defaults.get('control_status') == 'validated': InterventionPostPeriodQuantity.objects.filter( intervention=intervention, contract_post=contract_post, status='pending' ).update(status='validated') updated_count += 1 except ContractPost.DoesNotExist: errors.append(_('Poste de contrat non trouvé: %(id)s') % {'id': contract_post_id}) except Exception as e: errors.append(_('Erreur lors de la mise à jour du poste %(id)s: %(error)s') % { 'id': contract_post_id, 'error': str(e) }) if errors: return JsonResponse({ 'success': False, 'message': _('Des erreurs se sont produites lors de la mise à jour'), 'errors': errors, 'updated_count': updated_count }, status=400) return JsonResponse({ 'success': True, 'message': ngettext( '%(count)d prestation mise à jour avec succès.', '%(count)d prestations mises à jour avec succès.', updated_count ) % {'count': updated_count}, 'updated_count': updated_count }) except json.JSONDecodeError: return JsonResponse({ 'success': False, 'message': _('Données JSON invalides') }, status=400) except Exception as e: return JsonResponse({ 'success': False, 'message': _('Erreur lors de la mise à jour: %(error)s') % {'error': str(e)} }, status=500) def intervention_posts_period_quantities(request, intervention_id): """Retourne l'historique des quantités par période pour chaque poste d'une intervention. Réponse JSON: { success: true, periods: [1,2,...], items: [ { contract_post_id: 123, quantities: {"1": "5.00", "2": "3.50"} }, ... ] } """ intervention = get_object_or_404(Intervention, pk=intervention_id) # Récupérer toutes les lignes d'historique pour cette intervention hist = (InterventionPostPeriodQuantity.objects .filter(intervention=intervention) .select_related('contract_post') .order_by('period_number', 'contract_post__order_number')) periods = sorted(set(hist.values_list('period_number', flat=True))) by_post = defaultdict(dict) for h in hist: by_post[str(h.contract_post_id)][str(h.period_number)] = f"{h.quantity}" items = [ { 'contract_post_id': int(pid), 'quantities': qmap } for pid, qmap in by_post.items() ] return JsonResponse({ 'success': True, 'periods': periods, 'items': items, }) def update_intervention(request, intervention_id): try: intervention = Intervention.objects.get(pk=intervention_id) if not can_edit_intervention(request.user, intervention): return JsonResponse({'success': False, 'error': _('Vous n\'avez pas la permission de modifier cette intervention.')}, status=403) allowed_update_fields = get_allowed_update_fields(request.user, intervention) # Récupérer la configuration utilisateur pour vérifier les permissions sur les contrats user_config = UserConfig.objects.filter(user=request.user).first() # Check if request is JSON (CMS-style batch update) content_type = request.content_type if content_type and 'application/json' in content_type: try: data = json.loads(request.body) except json.JSONDecodeError: return JsonResponse({'success': False, 'error': _('Invalid JSON data')}, status=400) # Process multiple fields at once updated_fields = [] errors = [] permission_denied = False # Handle notes separately (special case) notes_data = data.pop('notes', None) for field, new_value in data.items(): if field not in allowed_update_fields: errors.append(_('You do not have permission to update field: %(field)s') % {'field': field}) permission_denied = True continue success, error = _update_intervention_field(intervention, field, new_value, user_config=user_config) if success: updated_fields.append(field) else: errors.append(error) if updated_fields: intervention.save() # Handle notes if provided if notes_data: notes_success, notes_errors = _handle_notes_update(request, intervention, notes_data) if notes_success: updated_fields.append('notes') if notes_errors: errors.extend(notes_errors) if errors: status_code = 403 if permission_denied and not updated_fields else 400 return JsonResponse({ 'success': len(updated_fields) > 0, 'updated_fields': updated_fields, 'errors': errors, 'message': _('%(count)s field(s) updated with some errors') % {'count': len(updated_fields)} }, status=status_code) return JsonResponse({ 'success': True, 'updated_fields': updated_fields, 'message': _('%(count)s field(s) updated successfully') % {'count': len(updated_fields)} }) # Legacy single-field update (POST form data) field = request.POST.get("field") new_value = request.POST.get("value") if field not in allowed_update_fields: return JsonResponse({'success': False, 'error': _('You do not have permission to update this field')}, status=403) # Handle special fields that require request object if field == "planification": return _handle_planification_update(request, intervention) elif field == "realization": return _handle_realization_update(request, intervention) # Handle standard fields success, error = _update_intervention_field(intervention, field, new_value, user_config=user_config) if not success: return JsonResponse({'success': False, 'error': error}, status=400) intervention.save() return JsonResponse({ 'success': True, 'title': intervention.title, 'assigned_provider': intervention.assigned_provider.name if intervention.assigned_provider else None, 'contract': intervention.contract.contract_number if intervention.contract else None, 'maintain_type': intervention.get_maintain_type_display(), 'type': intervention.get_type_display(), 'origin_type': intervention.get_origin_type_display() }) except Intervention.DoesNotExist: return JsonResponse({'success': False, 'error': _('Intervention not found')}, status=404) except Exception as e: logger.exception("Error updating intervention") return JsonResponse({'success': False, 'error': str(e)}, status=500) def add_intervention_location(request, intervention_id): """Ajoute un objet de localisation (InterventionLocation) à une intervention existante.""" intervention = get_object_or_404(Intervention, pk=intervention_id) allowed_fields = get_allowed_update_fields(request.user, intervention) if 'location' not in allowed_fields: return JsonResponse({'success': False, 'error': _('You do not have permission to update the location')}, status=403) try: ct_id = int(request.POST.get('ct_id', '')) obj_id = int(request.POST.get('obj_id', '')) except (ValueError, TypeError): return JsonResponse({'success': False, 'error': _('Invalid parameters')}, status=400) try: content_type = ContentType.objects.get(pk=ct_id) except ContentType.DoesNotExist: return JsonResponse({'success': False, 'error': _('Content type not found')}, status=404) model_class = content_type.model_class() if model_class is None or not model_class.objects.filter(pk=obj_id).exists(): return JsonResponse({'success': False, 'error': _('Location object not found')}, status=404) InterventionLocation.objects.get_or_create( intervention=intervention, content_type_id=ct_id, object_id=obj_id, ) # Mettre à jour les coordonnées et la géométrie depuis l'objet de localisation s'il en a coords_updated = False loc_obj = model_class.objects.get(pk=obj_id) if hasattr(loc_obj, 'lon') and hasattr(loc_obj, 'lat') and loc_obj.lon and loc_obj.lat: intervention.lon = loc_obj.lon intervention.lat = loc_obj.lat pt = Point(loc_obj.lon, loc_obj.lat, srid=4326) pt.transform(3812) intervention.geom = to_multipolygon(pt.buffer(1)) intervention.save(update_fields=['lon', 'lat', 'geom']) coords_updated = True localisations = resolve_intervention_localisations(intervention) return JsonResponse({ 'success': True, 'localisations': localisations, 'lon': intervention.lon, 'lat': intervention.lat, 'coords_updated': coords_updated, }) def remove_intervention_location(request, intervention_id, loc_id): """Supprime un lien InterventionLocation d'une intervention existante.""" intervention = get_object_or_404(Intervention, pk=intervention_id) allowed_fields = get_allowed_update_fields(request.user, intervention) if 'location' not in allowed_fields: return JsonResponse({'success': False, 'error': _('You do not have permission to update the location')}, status=403) loc = get_object_or_404(InterventionLocation, pk=loc_id, intervention=intervention) loc.delete() localisations = resolve_intervention_localisations(intervention) return JsonResponse({'success': True, 'localisations': localisations}) def get_contracts_by_provider(request): provider_id = request.GET.get("provider_id") thematic_id = request.GET.get("thematic_id") if not provider_id: return JsonResponse({"error": _("Missing provider_id")}, status=400) user_config = get_object_or_404(UserConfig, user=request.user) if not user_config.roles.filter(name__in=['admin', 'manager', 'operator', 'external_manager', 'controller']).exists(): return JsonResponse({"error": "Permission denied"}, status=403) if user_config.roles.filter(name__in=['external_manager']).exists(): accessible_contracts_ids = UserContractAccess.objects.filter( user_config=user_config, contract__company_id=provider_id, can_view_interventions=True ).values_list('contract__id', flat=True) contracts = Contract.objects.filter(id__in=accessible_contracts_ids) else: contracts = Contract.objects.filter(company_id=provider_id) # Filter by thematic if provided if thematic_id: contracts = contracts.filter(thematics__id=thematic_id) contracts = contracts.values("id", "contract_number") return JsonResponse(list(contracts), safe=False) def add_custom_operation(request, intervention_id): """Add a custom operation to an intervention (supports multiple assets)""" from django.http import JsonResponse from interventions.views.forms import CustomOperationForm from interventions.models import Operation from django.contrib.contenttypes.models import ContentType intervention = get_object_or_404(Intervention, pk=intervention_id) # Check permissions - only technician and external_manager can add operations user_config = UserConfig.objects.filter(user=request.user).first() if not user_config: return JsonResponse({'success': False, 'error': _("Accès non autorisé")}, status=403) allowed_update_fields = get_allowed_update_fields(request.user, intervention) if 'operations' not in allowed_update_fields: return JsonResponse({'success': False, 'error': _("Vous n'avez pas la permission d'ajouter des opérations.")}, status=403) form = CustomOperationForm(request.POST) if not form.is_valid(): errors = [] for field, error_list in form.errors.items(): for error in error_list: errors.append(f"{field}: {error}") return JsonResponse({'success': False, 'errors': errors}, status=400) data = form.cleaned_data operation_type = data['operation_type'] # Determine the next order number max_order = Operation.objects.filter(intervention=intervention).aggregate(Max('order'))['order__max'] or 0 # Get assets list (supports multiple selection) assets_params = request.POST.getlist('assets') # If no assets provided or only empty string, create one general operation if not assets_params or (len(assets_params) == 1 and assets_params[0] == ''): assets_to_create = [(None, None)] # One general operation else: assets_to_create = [] for asset_param in assets_params: if not asset_param: # Empty string = general operation assets_to_create.append((None, None)) else: # Format: "content_type_id:object_id" try: ct_id, obj_id = asset_param.split(':') asset_ct = ContentType.objects.get(pk=int(ct_id)) assets_to_create.append((asset_ct, int(obj_id))) except (ValueError, ContentType.DoesNotExist): pass # Create operations for each selected asset created_operations = [] for idx, (asset_ct, asset_id) in enumerate(assets_to_create): operation = Operation.objects.create( intervention=intervention, name_fr=data['name_fr'], name_nl=data['name_nl'], template_group=data.get('template_group') or _("Opérations personnalisées"), order=max_order + 1 + idx, is_custom=True, created_by=request.user, asset_content_type=asset_ct, asset_object_id=asset_id, ) if operation_type == 'status': operation.status_set = data['status_set'] elif operation_type == 'measurement': operation.measurement_definition = data['measurement_definition'] operation.save() created_operations.append(operation.id) # Build success message count = len(created_operations) if count == 1: message = _("Opération personnalisée ajoutée avec succès") else: message = _("%(count)s opérations personnalisées ajoutées avec succès") % {'count': count} return JsonResponse({ 'success': True, 'message': message, 'operation_ids': created_operations, 'count': count }) def add_operation_photo(request, operation_id): """Add a photo to an operation""" from interventions.models import OperationPhoto operation = get_object_or_404(Operation, pk=operation_id) # Check permissions - only technician and external_manager can add photos to operations user_config = UserConfig.objects.filter(user=request.user).first() if not user_config: return JsonResponse({'success': False, 'error': _("Accès non autorisé")}, status=403) allowed_update_fields = get_allowed_update_fields(request.user, operation.intervention) if 'operations' not in allowed_update_fields: return JsonResponse({'success': False, 'error': _("Vous n'avez pas la permission d'ajouter des photos aux opérations.")}, status=403) photo_file = request.FILES.get('photo') if not photo_file: return JsonResponse({'success': False, 'error': _("Aucune photo fournie")}, status=400) description = request.POST.get('description', '') try: photo = OperationPhoto.objects.create( operation=operation, photo=photo_file, uploaded_by=request.user, description=description ) photo_url = build_private_url("interventions", "OperationPhoto", photo.id, "photo") if photo.photo else None return JsonResponse({ 'success': True, 'message': _("Photo ajoutée avec succès"), 'photo_id': photo.id, 'photo_url': photo_url }) except Exception: import logging logger = logging.getLogger(__name__) logger.exception("Error adding operation photo") return JsonResponse({ 'success': False, 'error': _("Erreur lors de l'ajout de la photo") }, status=500) def delete_operation_photo(request, photo_id): """Delete an operation photo""" from interventions.models import OperationPhoto photo = get_object_or_404(OperationPhoto, pk=photo_id) # Check permissions - only technician and external_manager can delete operation photos user_config = UserConfig.objects.filter(user=request.user).first() if not user_config: return JsonResponse({'success': False, 'error': _("Accès non autorisé")}, status=403) allowed_update_fields = get_allowed_update_fields(request.user, photo.operation.intervention) if 'operations' not in allowed_update_fields: return JsonResponse({'success': False, 'error': _("Vous n'avez pas la permission de supprimer des photos d'opérations.")}, status=403) if photo.uploaded_by != request.user and not request.user.is_staff: return JsonResponse({'success': False, 'error': _("Vous ne pouvez supprimer que vos propres photos")}, status=403) try: photo.delete() return JsonResponse({ 'success': True, 'message': _("Photo supprimée avec succès") }) except Exception: import logging logger = logging.getLogger(__name__) logger.exception("Error deleting operation photo") return JsonResponse({ 'success': False, 'error': _("Erreur lors de la suppression de la photo") }, status=500) def save_guided_operation_ajax(request, intervention_id): """ Sauvegarde les données JSON saisies via un template d'opérations guidées. Body JSON: { "data": { ... } } """ intervention = get_object_or_404(Intervention, pk=intervention_id) if not intervention.guided_operation_template: return JsonResponse({'success': False, 'error': _("Cette intervention n'a pas de template d'opérations guidées.")}, status=400) allowed_update_fields = get_allowed_update_fields(request.user, intervention) if 'operations' not in allowed_update_fields: return JsonResponse({'success': False, 'error': _("Vous n'avez pas la permission de modifier les opérations.")}, status=403) try: body = json.loads(request.body) except json.JSONDecodeError: return JsonResponse({'success': False, 'error': _("Format JSON invalide")}, status=400) incoming_data = body.get('data') if not isinstance(incoming_data, dict): return JsonResponse({'success': False, 'error': _("Le champ 'data' doit être un objet JSON.")}, status=400) guided_data, _created = GuidedOperationData.objects.get_or_create( intervention=intervention, defaults={'guided_template': intervention.guided_operation_template} ) guided_data.data = incoming_data guided_data.save(update_fields=['data', 'updated_at']) return JsonResponse({'success': True, 'message': _("Opérations guidées enregistrées.")}) def autocomplete_giep_assets_ajax(request, intervention_id): """Autocomplete search for NatureRWIAsset.""" from assets.models import NatureRWIAsset from django.db.models import Q q = (request.GET.get('q') or '').strip() if len(q) < 1: return JsonResponse([], safe=False) qs = NatureRWIAsset.objects.exclude(status='removed') qs = qs.filter( Q(code__icontains=q) | Q(structure_type__icontains=q) | Q(comment__icontains=q) ).order_by('code')[:20] results = [] for asset in qs: label = f"{asset.code}" if asset.structure_type: label += f" ({asset.get_structure_type_display()})" if asset.comment: label += f" — {asset.comment[:40]}" results.append({ 'id': asset.id, 'code': asset.code, 'label': label, 'structure_type': asset.structure_type, 'lon': asset.lon, 'lat': asset.lat, }) return JsonResponse(results, safe=False) def nearby_giep_assets_ajax(request, intervention_id): """Fetch nearby GIEP assets for the current intervention.""" from assets.models import NatureRWIAsset from django.contrib.gis.db.models.functions import Distance from django.contrib.gis.geos import Point intervention = get_object_or_404(Intervention, pk=intervention_id) lon = intervention.lon lat = intervention.lat if lon is None or lat is None: qs = NatureRWIAsset.objects.exclude(status='removed').order_by('code')[:30] else: user_point = Point(float(lon), float(lat), srid=4326) user_point.transform(3812) qs = ( NatureRWIAsset.objects.exclude(status='removed') .filter(geom__isnull=False) .annotate(distance=Distance('geom', user_point)) .order_by('distance')[:20] ) results = [] for asset in qs: dist_m = None if hasattr(asset, 'distance') and asset.distance is not None: dist_m = int(asset.distance.m) label = f"{asset.code}" if asset.structure_type: label += f" ({asset.get_structure_type_display()})" results.append({ 'id': asset.id, 'code': asset.code, 'label': label, 'distance': dist_m, 'structure_type': asset.structure_type, 'lon': asset.lon, 'lat': asset.lat, }) return JsonResponse(results, safe=False) def nearby_giep_assets_geojson_ajax(request, intervention_id): """Fetch nearby GIEP assets in GeoJSON format for the map picker.""" import json from assets.models import NatureRWIAsset from django.contrib.gis.db.models.functions import Distance from django.contrib.gis.geos import Point from django.db.models import Q intervention = get_object_or_404(Intervention, pk=intervention_id) lon = intervention.lon lat = intervention.lat # Extract selected asset IDs from the GuidedOperationData model selected_asset_ids = [] guided_data = GuidedOperationData.objects.filter(intervention=intervention).first() if guided_data and isinstance(guided_data.data, dict): data = guided_data.data if 'rwi_asset_id' in data and data['rwi_asset_id']: selected_asset_ids.append(data['rwi_asset_id']) if 'ouvrages' in data and isinstance(data['ouvrages'], list): for ouv in data['ouvrages']: if isinstance(ouv, dict) and ouv.get('rwi_asset_id'): selected_asset_ids.append(ouv['rwi_asset_id']) selected_q = Q(id__in=selected_asset_ids) if selected_asset_ids else Q(pk__in=[]) if lon is None or lat is None: qs = ( NatureRWIAsset.objects.exclude(status='removed') .filter(Q(geom__isnull=False) | selected_q) .order_by('code')[:100] ) else: user_point = Point(float(lon), float(lat), srid=4326) user_point.transform(3812) # Select all GIEP assets within 2000m OR explicitly selected to display on the map qs = ( NatureRWIAsset.objects.exclude(status='removed') .filter(geom__isnull=False) .annotate(distance=Distance('geom', user_point)) .filter(Q(distance__lte=2000) | selected_q) .order_by('distance') ) features = [] for asset in qs: if asset.geom: try: # Transform geometry from SRID 3812 to 4326 geom_4326 = asset.geom.transform(4326, clone=True) geojson_geom = json.loads(geom_4326.geojson) except Exception: continue features.append({ "type": "Feature", "id": asset.id, "geometry": geojson_geom, "properties": { "id": asset.id, "code": asset.code, "structure_type": asset.structure_type or "", "structure_type_display": asset.get_structure_type_display() if asset.structure_type else "", } }) response = JsonResponse({ "type": "FeatureCollection", "features": features, }, safe=False) response['Cache-Control'] = 'no-cache, no-store, must-revalidate' return response def upload_guided_operation_media(request, intervention_id): """ Upload d'un fichier (photo ou document) attaché à une réponse guidée. POST: multipart/form-data - file: le fichier - field_key: identifiant de la question (vide = général) - media_type: 'photo' | 'document' - description: texte libre (optionnel) """ intervention = get_object_or_404(Intervention, pk=intervention_id) if not intervention.guided_operation_template: return JsonResponse({'success': False, 'error': _("Cette intervention n'a pas de template d'opérations guidées.")}, status=400) allowed_update_fields = get_allowed_update_fields(request.user, intervention) if 'operations' not in allowed_update_fields: return JsonResponse({'success': False, 'error': _("Vous n'avez pas la permission de modifier les opérations.")}, status=403) uploaded_file = request.FILES.get('file') if not uploaded_file: return JsonResponse({'success': False, 'error': _("Aucun fichier fourni.")}, status=400) field_key = request.POST.get('field_key', '').strip()[:100] media_type = request.POST.get('media_type', 'photo') if media_type not in ('photo', 'document'): media_type = 'photo' description = request.POST.get('description', '').strip()[:255] guided_data, _created = GuidedOperationData.objects.get_or_create( intervention=intervention, defaults={'guided_template': intervention.guided_operation_template} ) media = GuidedOperationMedia.objects.create( guided_data=guided_data, field_key=field_key, file=uploaded_file, media_type=media_type, description=description, uploaded_by=request.user, ) # Build private URL for response from common.private_files.helpers import build_private_url file_url = build_private_url('interventions', 'guidedoperationmedia', media.pk, 'file') return JsonResponse({ 'success': True, 'media_id': media.id, 'file_url': file_url, 'media_type': media.media_type, 'field_key': media.field_key, 'description': media.description, }) def delete_guided_operation_media(request, media_id): """ Supprime un média d'opérations guidées. """ media = get_object_or_404(GuidedOperationMedia, pk=media_id) intervention = media.guided_data.intervention allowed_update_fields = get_allowed_update_fields(request.user, intervention) if 'operations' not in allowed_update_fields: return JsonResponse({'success': False, 'error': _("Vous n'avez pas la permission de supprimer ce fichier.")}, status=403) try: media.file.delete(save=False) media.delete() return JsonResponse({'success': True}) except Exception: import logging logging.getLogger(__name__).exception("Error deleting guided operation media") return JsonResponse({'success': False, 'error': _("Erreur lors de la suppression.")}, status=500) def save_operations_ajax(request, intervention_id): """ Save operations via AJAX without page reload. Expects JSON payload with operations data. """ intervention = get_object_or_404(Intervention, pk=intervention_id) # Check if user has permission to edit operations (only technician and external_manager) allowed_update_fields = get_allowed_update_fields(request.user, intervention) if 'operations' not in allowed_update_fields: return JsonResponse({'success': False, 'error': _("Vous n'avez pas la permission de modifier les opérations.")}, status=403) try: data = json.loads(request.body) except json.JSONDecodeError: return JsonResponse({'success': False, 'error': _("Format JSON invalide")}, status=400) operations_data = data.get('operations', []) saved_count = 0 errors = [] for op_data in operations_data: op_id = op_data.get('id') if not op_id: continue try: operation = Operation.objects.get(pk=op_id, intervention=intervention) # Handle "Not Applicable" flag if 'is_not_applicable' in op_data: is_na = op_data['is_not_applicable'] if is_na: operation.is_not_applicable = True operation.measured_value = None operation.measured_duration = None operation.measured_datetime = None operation.status = None operation.save() saved_count += 1 continue else: operation.is_not_applicable = False # Handle measurement value if 'measured_value' in op_data: value = op_data['measured_value'] if value is not None and value != '': try: operation.measured_value = Decimal(str(value)) operation.measured_duration = None operation.measured_datetime = None operation.status = None operation.is_not_applicable = False except (InvalidOperation, ValueError): errors.append(_("évaleur de mesure invalide pour l'opération %(id)s") % {'id': op_id}) continue else: operation.measured_value = None # Handle duration value if 'measured_duration' in op_data: value = op_data['measured_duration'] if value is not None and value != '': try: parts = str(value).split(':') if len(parts) == 3: h, m, s = int(parts[0]), int(parts[1]), int(parts[2]) operation.measured_duration = timedelta(hours=h, minutes=m, seconds=s) operation.measured_value = None operation.measured_datetime = None operation.status = None operation.is_not_applicable = False else: errors.append(_("Format de durée invalide pour l'opération %(id)s") % {'id': op_id}) continue except (ValueError, AttributeError): errors.append(_("Valeur de durée invalide pour l'opération %(id)s") % {'id': op_id}) continue else: operation.measured_duration = None # Handle datetime value if 'measured_datetime' in op_data: value = op_data['measured_datetime'] if value is not None and value != '': try: dt = parse_datetime(str(value)) if dt is None: dt = datetime.fromisoformat(str(value)) if dt and is_naive(dt): dt = make_aware(dt, get_current_timezone()) operation.measured_datetime = dt operation.measured_value = None operation.measured_duration = None operation.status = None operation.is_not_applicable = False except (ValueError, TypeError): errors.append(_("Valeur de date invalide pour l'opération %(id)s") % {'id': op_id}) continue else: operation.measured_datetime = None # Handle status if 'status_id' in op_data: status_id = op_data['status_id'] if status_id: try: status = OperationStatus.objects.get(pk=status_id) operation.status = status operation.measured_value = None operation.is_not_applicable = False except OperationStatus.DoesNotExist: errors.append(_("Statut invalide pour l'opération %(id)s") % {'id': op_id}) continue else: operation.status = None operation.save() saved_count += 1 except Operation.DoesNotExist: errors.append(_("Opération %(id)s introuvable") % {'id': op_id}) except Exception as e: errors.append(_("Erreur pour l'opération %(id)s : %(err)s") % {'id': op_id, 'err': str(e)}) return JsonResponse({ 'success': True, 'saved_count': saved_count, 'errors': [str(e) for e in errors], 'message': _("%(count)s opération(s) sauvegardée(s)") % {'count': saved_count} }) def check_asset_templates_for_intervention(request, intervention_id): """ Vérifie si un asset a des templates d'opérations correspondants pour une intervention préventive. Si aucun template ne correspond, retourne la liste des templates disponibles pour l'intervention. Body JSON: { "asset_type": "structuregeoasset", # content_type.model "asset_id": 123 } Response JSON: { "success": true, "has_matching_templates": false, "available_templates": [ {"id": 1, "name": "Template 1", "description": "..."}, ... ] } """ intervention = get_object_or_404(Intervention, pk=intervention_id) # Vérifier que c'est une intervention préventive if intervention.maintain_type != 'preventive': return JsonResponse({ 'success': False, 'error': _("Cette fonctionnalité n'est disponible que pour les interventions préventives") }, status=400) try: data = json.loads(request.body) asset_type = data.get('asset_type') asset_id = data.get('asset_id') if not asset_type or not asset_id: return JsonResponse({ 'success': False, 'error': _("asset_type et asset_id requis") }, status=400) # Récupérer l'asset ct = ContentType.objects.get(model=asset_type) model_class = ct.model_class() if not model_class: return JsonResponse({ 'success': False, 'error': _("Type d'asset invalide") }, status=400) asset_obj = model_class.objects.get(pk=asset_id) # Récupérer les templates d'équipement de l'intervention templates = _get_equipment_templates_for_intervention(intervention) if not templates: return JsonResponse({ 'success': True, 'has_templates': False, 'available_templates': [], 'message': _("Cette intervention n'a pas de templates d'opérations équipement") }) # Retourner tous les templates disponibles pour que l'utilisateur choisisse # On n'applique pas les filtres car l'utilisateur a choisi manuellement d'ajouter cet asset available_templates_data = [ { 'id': t.id, 'name': t.name, 'description': t.description or '' } for t in templates ] return JsonResponse({ 'success': True, 'has_templates': True, 'available_templates': available_templates_data, 'message': _("Choisissez le(s) template(s) d'opérations à appliquer à cet équipement.") }) except ContentType.DoesNotExist: return JsonResponse({ 'success': False, 'error': _("Type d'asset non trouvé") }, status=404) except model_class.DoesNotExist: return JsonResponse({ 'success': False, 'error': _("Équipement non trouvé") }, status=404) except json.JSONDecodeError: return JsonResponse({ 'success': False, 'error': _("Format JSON invalide") }, status=400) except Exception as e: return JsonResponse({ 'success': False, 'error': str(e) }, status=500) def create_operations_for_linked_assets(request, intervention_id): """ Create operations for multiple newly linked assets from user-selected templates. Each asset can have different templates selected. """ try: data = json.loads(request.body) assets_with_templates = data.get('assets', []) # [{'content_type_id': X, 'object_id': Y, 'template_ids': [...]}, ...] if not isinstance(assets_with_templates, list) or len(assets_with_templates) == 0: return JsonResponse({ 'success': False, 'error': _('Liste d\'assets requise') }, status=400) intervention = get_object_or_404(Intervention, pk=intervention_id) total_operations = 0 total_assets_processed = 0 for asset_data in assets_with_templates: content_type_id = asset_data.get('content_type_id') object_id = asset_data.get('object_id') template_ids = asset_data.get('template_ids', []) if not content_type_id or not object_id or not template_ids: continue try: content_type = ContentType.objects.get(pk=content_type_id) asset = content_type.get_object_for_this_type(pk=object_id) # Get selected templates templates = intervention.equipment_operation_templates.filter( pk__in=template_ids ) if templates.exists(): # Create operations for this asset operations_created = _add_operations_from_templates( intervention, list(templates), asset_obj=asset, asset_ct=content_type ) total_operations += len(operations_created) total_assets_processed += 1 except (ContentType.DoesNotExist, Exception) as e: logger.warning(f"Error processing asset {content_type_id}:{object_id}: {e}") continue return JsonResponse({ 'success': True, 'assets_processed': total_assets_processed, 'operations_created': total_operations, 'message': ngettext( '%(ops)d opération créée pour %(assets)d équipement', '%(ops)d opérations créées pour %(assets)d équipements', total_operations ) % {'ops': total_operations, 'assets': total_assets_processed} }) except json.JSONDecodeError: return JsonResponse({ 'success': False, 'error': _('Format JSON invalide') }, status=400) except Exception as e: logger.exception("Error creating operations for linked assets") return JsonResponse({ 'success': False, 'error': str(e) }, status=500) def get_newly_linked_assets_for_templates(request, intervention_id): """ Get newly linked assets from session and available templates for each. Returns asset details and available operation templates. """ try: intervention = get_object_or_404(Intervention, pk=intervention_id) # Get newly linked assets from session session_key = f'new_linked_assets_{intervention_id}' new_asset_keys = request.session.get(session_key, []) if not new_asset_keys: return JsonResponse({ 'success': True, 'has_new_assets': False, 'assets': [] }) # Parse asset keys and get asset details assets_data = [] for key in new_asset_keys: try: ct_id, obj_id = key.split(':') content_type = ContentType.objects.get(pk=int(ct_id)) asset = content_type.get_object_for_this_type(pk=int(obj_id)) # Get asset display info asset_code = getattr(asset, 'code', '') asset_name = asset.get_name() if hasattr(asset, 'get_name') else str(asset) assets_data.append({ 'content_type_id': int(ct_id), 'object_id': int(obj_id), 'code': asset_code, 'name': asset_name, 'display': f"{asset_code} - {asset_name}" if asset_code else asset_name }) except (ValueError, ContentType.DoesNotExist, Exception) as e: logger.warning(f"Could not resolve asset from key {key}: {e}") continue # Get available templates (all templates for this intervention) templates = intervention.equipment_operation_templates.all() templates_data = [{ 'id': template.id, 'name': template.name, 'description': template.description or '', 'code': template.code or '' } for template in templates] # Clear session data after retrieving if session_key in request.session: del request.session[session_key] request.session.modified = True return JsonResponse({ 'success': True, 'has_new_assets': len(assets_data) > 0, 'assets': assets_data, 'templates': templates_data }) except Exception as e: logger.exception("Error getting newly linked assets") return JsonResponse({ 'success': False, 'error': str(e) }, status=500) def create_operations_from_selected_template(request, intervention_id): """ Crée des opérations pour un asset à partir d'un ou plusieurs templates sélectionnés manuellement. Utilisé quand l'utilisateur choisit manuellement les templates à appliquer. Body JSON: { "asset_type": "structuregeoasset", # content_type.model "asset_id": 123, "template_ids": [5, 7, 9] # Liste de template IDs } Response JSON: { "success": true, "operations_created": 12 } """ intervention = get_object_or_404(Intervention, pk=intervention_id) # Vérifier que c'est une intervention préventive if intervention.maintain_type != 'preventive': return JsonResponse({ 'success': False, 'error': _("Cette fonctionnalité n'est disponible que pour les interventions préventives") }, status=400) try: data = json.loads(request.body) asset_type = data.get('asset_type') asset_id = data.get('asset_id') template_ids = data.get('template_ids', []) if not asset_type or not asset_id or not template_ids: return JsonResponse({ 'success': False, 'error': _("asset_type, asset_id et template_ids requis") }, status=400) if not isinstance(template_ids, list) or len(template_ids) == 0: return JsonResponse({ 'success': False, 'error': _("template_ids doit être une liste non vide") }, status=400) # Récupérer l'asset ct = ContentType.objects.get(model=asset_type) model_class = ct.model_class() if not model_class: return JsonResponse({ 'success': False, 'error': _("Type d'asset invalide") }, status=400) asset_obj = model_class.objects.get(pk=asset_id) # Récupérer tous les templates sélectionnés templates = OperationTemplate.objects.filter(pk__in=template_ids) if templates.count() != len(template_ids): return JsonResponse({ 'success': False, 'error': _("Certains templates n'ont pas été trouvés") }, status=404) # Créer les opérations pour cet asset avec tous les templates sélectionnés created_ops = _add_operations_from_templates( intervention, list(templates), asset_obj=asset_obj, asset_ct=ct, ) template_names = ', '.join([t.name for t in templates]) return JsonResponse({ 'success': True, 'operations_created': len(created_ops), 'templates_applied': templates.count(), 'message': ngettext( '%(count)d opération créée avec succès depuis %(templates)d template', '%(count)d opérations créées avec succès depuis %(templates)d templates', len(created_ops) ) % {'count': len(created_ops), 'templates': templates.count()} }) except ContentType.DoesNotExist: return JsonResponse({ 'success': False, 'error': _("Type d'asset non trouvé") }, status=404) except model_class.DoesNotExist: return JsonResponse({ 'success': False, 'error': _("Équipement non trouvé") }, status=404) except OperationTemplate.DoesNotExist: return JsonResponse({ 'success': False, 'error': _("Un ou plusieurs templates non trouvés") }, status=404) except json.JSONDecodeError: return JsonResponse({ 'success': False, 'error': _("Format JSON invalide") }, status=400) except Exception as e: return JsonResponse({ 'success': False, 'error': str(e) }, status=500)