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 import views 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 import views 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 interventions_index_mobile(request): # Redirect if no filter parameters are provided in GET (and it's a standard, non-AJAX request) if not request.GET and request.headers.get('x-requested-with') != 'XMLHttpRequest': query_params = request.GET.copy() # Load user config to get saved filters user_config = None try: user_config = UserConfig.objects.get(user=request.user) except UserConfig.DoesNotExist: pass saved_filters = user_config.mobile_intervention_list_filters if user_config else {} if saved_filters: for key, value in saved_filters.items(): if isinstance(value, list): query_params.setlist(key, value) elif value is not None: query_params[key] = value else: # Default behavior if no preferences saved yet # 1. default statuses allowed_statuses = get_allowed_statuses_for_user(request.user) allowed_statuses = [status for status in allowed_statuses if status in STATUS_CHOICES] default_statuses = [ status for status in ('to_be_processed', 'assigned', 'in_progress', 'on_pause') if status in allowed_statuses ] if not default_statuses: default_statuses = allowed_statuses query_params.setlist('status', default_statuses) return HttpResponseRedirect(f"{request.path}?{query_params.urlencode()}") # permissions interventions = filter_viewable_interventions_for_user(request.user, limit_to_default_thematic=True) # Get user config and company member for maintain type tabs logic user_config = None company_member = None try: user_config = UserConfig.objects.get(user=request.user) except UserConfig.DoesNotExist: pass if user_config and not user_config.is_intern: company_member = CompanyMember.objects.prefetch_related('teams__company', 'visible_maintain_types').filter(user=request.user).first() # Save the current filters to user config if user_config and request.GET: filters_to_save = {} for key in request.GET: values = request.GET.getlist(key) if key == 'status': filters_to_save[key] = values else: filters_to_save[key] = values[0] if values else '' if filters_to_save and user_config.mobile_intervention_list_filters != filters_to_save: user_config.mobile_intervention_list_filters = filters_to_save user_config.save(update_fields=['mobile_intervention_list_filters']) # Check if user can add repair interventions for structures thematic (technicians) can_add_structures_repair = False # Check if user (internal admin/manager/controller) can add interventions across all thematics can_add_intervention_mobile = False if user_config: default_thematic = user_config.default_thematic if default_thematic and default_thematic.code == 'structures': # Check if user is a technician with edit permissions for structures is_technician = user_config.roles.filter(name='technician').exists() if is_technician: can_add_structures_repair = UserThematics.objects.filter( user_config=user_config, thematic=default_thematic, can_edit_interventions=True ).exists() # Utilisateur interne avec rôle admin, manager ou controller (toutes thématiques) if user_config.is_intern and user_config.roles.filter(name__in=['admin', 'manager', 'controller']).exists(): can_add_intervention_mobile = True allowed_statuses = get_allowed_statuses_for_user(request.user) allowed_statuses = [status for status in allowed_statuses if status in STATUS_CHOICES] allowed_statuses = sorted(set(allowed_statuses), key=lambda status: STATUS_ORDERS.get(status, 999)) selected_statuses = [] # Save base interventions for counting BEFORE any filtering base_interventions_for_counts = interventions if not allowed_statuses: interventions = interventions.none() else: default_statuses = [ status for status in ('to_be_processed', 'assigned', 'in_progress', 'on_pause') if status in allowed_statuses ] if not default_statuses: default_statuses = allowed_statuses selected_statuses = [ status for status in request.GET.getlist('status') if status in allowed_statuses ] if not selected_statuses: selected_statuses = default_statuses interventions = interventions.filter(status__in=selected_statuses) # Apply same status filter to base_interventions_for_counts if selected_statuses: base_interventions_for_counts = base_interventions_for_counts.filter(status__in=selected_statuses) # Find user's teams to filter team-assigned interventions user_teams = CompanyTeam.objects.filter(members__user=request.user) user_team_ids = list(user_teams.values_list('id', flat=True)) show_mine_only = request.GET.get('mine') == '1' if show_mine_only: interventions = interventions.filter( Q(assigned_member__user=request.user) | Q(assigned_team_id__in=user_team_ids) ) base_interventions_for_counts = base_interventions_for_counts.filter( Q(assigned_member__user=request.user) | Q(assigned_team_id__in=user_team_ids) ) q = request.GET.get('q') if q: interventions = interventions.filter(Q(code__icontains=q) | Q(title__icontains=q)) base_interventions_for_counts = base_interventions_for_counts.filter(Q(code__icontains=q) | Q(title__icontains=q)) # Needed by mobile badge rendering (stock icon next to status). interventions = annotate_preparation_status(interventions) # --- Tri personnalisé --- sort_field = request.GET.get("sort") prioritized_begin_expr = Coalesce( 'begin_time', 'planned_begin_time', 'expected_begin_time', 'creation_time', output_field=DateTimeField(), ) order_mapping = { "number_asc": ("code",), "number_desc": ("-code",), "title_asc": ("title",), "title_desc": ("-title",), "date_asc": ("list_begin_time", "id"), "date_desc": ("-list_begin_time", "-id"), } # Annotation pour priorité d'affichage interventions = interventions.annotate( custom_priority=Case( When(status='to_be_processed', maintain_type='corrective', then=0), When(status='assigned', maintain_type='corrective', then=1), When(status='in_progress', maintain_type='corrective', then=2), When(status='on_pause', maintain_type='corrective', then=3), When(status='to_be_processed', then=4), When(status='assigned', then=5), When(status='in_progress', then=6), When(status='on_pause', then=7), When(status='finished', then=8), default=9, output_field=IntegerField() ), mine_priority=Case( When(assigned_member__user=request.user, then=Value(0)), When(assigned_team_id__in=user_team_ids, then=Value(1)), default=Value(2), output_field=IntegerField() ), list_begin_time=prioritized_begin_expr, ) if sort_field: order_by_clause = order_mapping.get(sort_field, ("-creation_time",)) interventions = interventions.order_by(*order_by_clause) else: default_order = ('mine_priority', 'custom_priority', '-list_begin_time', '-id') interventions = interventions.order_by(*default_order) # --- Maintain type tabs logic --- # Determine if we should show maintain type tabs and which types are allowed show_maintain_type_tabs = False visible_maintain_types = None # None means all visible allowed_maintain_types = ['corrective', 'preventive', 'ameliorative'] # Default for internal users if user_config: if user_config.is_intern: # Internal users always see tabs show_maintain_type_tabs = True elif company_member: # External users: check if they have multiple visible maintain types visible_maintain_types = company_member.get_visible_maintain_type_codes() # If None (all visible) or more than 1 type, show tabs if visible_maintain_types is None: # All types visible show_maintain_type_tabs = True elif len(visible_maintain_types) > 1: # Filter to only show allowed types allowed_maintain_types = [t for t in allowed_maintain_types if t in visible_maintain_types] show_maintain_type_tabs = True # Calculate counts per maintain_type FIRST (before any maintain_type filtering) maintain_type_counts = {'corrective': 0, 'preventive': 0, 'ameliorative': 0} if show_maintain_type_tabs: # Count from the base queryset (before maintain_type filter but with status/mine/search filters) counts = base_interventions_for_counts.values('maintain_type').annotate(count=Count('id')) for item in counts: if item['maintain_type'] in maintain_type_counts: maintain_type_counts[item['maintain_type']] = item['count'] # NOW determine the selected maintain_type (after counting) selected_maintain_type = request.GET.get('maintain_type', '') if show_maintain_type_tabs and not selected_maintain_type: # Set corrective as default selected_maintain_type = 'corrective' if 'corrective' in allowed_maintain_types else allowed_maintain_types[0] # Apply maintain_type filter if selected if selected_maintain_type and selected_maintain_type in ('corrective', 'preventive', 'ameliorative'): interventions = interventions.filter(maintain_type=selected_maintain_type) # Apply limit (slice) at the very end, after all filtering interventions = interventions[:100] # Evaluate queryset once to avoid double DB round-trip interventions_list = list(interventions) # Build marker data for the map (only interventions with coordinates) markers_data = [ { 'id': i.id, 'code': i.code, 'title': i.title, 'status_label': i.get_status_display(), 'status': i.status, 'status_color': i.get_status_color(), 'maintain_type': i.maintain_type, 'lat': i.lat, 'lon': i.lon, 'url': reverse('mobile:intervention_detail_mobile', args=[i.id]), } for i in interventions_list if i.lat and i.lon ] if request.headers.get('x-requested-with') == 'XMLHttpRequest': html = render_to_string( 'interventions/_interventions_list_mobile.html', {'interventions': interventions_list}, request=request, ) return JsonResponse({'html': html, 'maintain_type_counts': maintain_type_counts, 'markers': markers_data}) return render( request, 'interventions/interventions_index_mobile.html', { 'interventions': interventions_list, 'show_mine_only': show_mine_only, 'status_filters': [ { 'key': status, 'label': STATUS_CHOICES.get(status, status), 'checked': status in selected_statuses, } for status in allowed_statuses ], 'selected_statuses': selected_statuses, 'current_sort': sort_field, 'show_maintain_type_tabs': show_maintain_type_tabs, 'maintain_type_counts': maintain_type_counts, 'selected_maintain_type': selected_maintain_type, 'allowed_maintain_types': allowed_maintain_types, 'can_add_structures_repair': can_add_structures_repair, 'can_add_intervention_mobile': can_add_intervention_mobile, 'markers_json': json.dumps(markers_data, ensure_ascii=False), } ) def intervention_detail_mobile(request, intervention_id): intervention = get_object_or_404(Intervention, pk=intervention_id) intervention.prep_state = intervention.get_preparation_orders_status() user_config = (UserConfig.objects .select_related('user') .prefetch_related('roles') .filter(user=request.user).first()) if not user_config: return HttpResponseForbidden(_("You do not have access to this page.")) # Gestion de l'historique de navigation (chaîné avec ">") # Format: "list>intervention:123>intervention:456" = Liste -> Intervention 123 -> Intervention 456 back_history = request.GET.get('back') or request.POST.get('back', '') if not back_history: # Essayer de détecter depuis le referer referer = request.META.get('HTTP_REFERER', '') if referer: from urllib.parse import urlparse parsed = urlparse(referer) path = parsed.path # Détecter le type de page d'origine if '/mobile/interventions/' in path and f'/interventions/{intervention_id}' not in path: # Vient d'une autre intervention mobile import re match = re.search(r'/interventions/(\d+)/?', path) if match: back_history = f'intervention:{match.group(1)}' elif '/projects/' in path: import re match = re.search(r'/projects/(\d+)/?', path) if match: back_history = f'project:{match.group(1)}' elif '/controls/' in path: import re match = re.search(r'/controls/(\d+)/?', path) if match: back_history = f'control:{match.group(1)}' elif '/observations/' in path: import re match = re.search(r'/observations/(\d+)/?', path) if match: back_history = f'observation:{match.group(1)}' # Si aucun historique n'est détecté, on considère qu'on vient de la liste par défaut if not back_history: back_history = 'list' if request.method == "POST": # 1. Enregistrement de la note note_content = request.POST.get("content", "").strip() note_id = request.POST.get("note_id", "").strip() if note_content: note_form = InterventionNoteForm(request.POST) if note_form.is_valid(): cleaned_data = note_form.cleaned_data note = None # Check if we're updating an existing note (created by auto-save) if note_id: try: note = InterventionNote.objects.get(pk=note_id, intervention=intervention) # Only allow updating if user is the author if note.note_author == request.user: note.content = cleaned_data['content'] note.note_type = cleaned_data['note_type'] note.updated_by = request.user note.save() else: messages.error(request, _("Vous n'êtes pas autorisé à modifier cette note")) note = None except InterventionNote.DoesNotExist: # Note doesn't exist, create a new one note = None # Create new note if no existing note was found/updated if not note: InterventionNote.objects.create( intervention=intervention, content=cleaned_data['content'], note_type=cleaned_data['note_type'], note_author=request.user, note_time=now() ) else: messages.error(request, _("Erreur lors de l'enregistrement de la note.")) # 2. Upload de documents (photos) for file in request.FILES.getlist("photos"): InterventionDocument.objects.create( intervention=intervention, file=file, uploaded_by=request.user ) # 3. Traitement des opérations for operation in intervention.operations.all(): measure_key = f"operation_measure_{operation.id}" duration_key = f"operation_duration_{operation.id}" datetime_key = f"operation_datetime_{operation.id}" status_key = f"operation_status_{operation.id}" na_key = f"operation_na_{operation.id}" # Check if operation is marked as "Non applicable" is_na = na_key in request.POST and request.POST.get(na_key) == '1' if is_na: # Mark as not applicable and clear any value/status operation.is_not_applicable = True operation.measured_value = None operation.measured_duration = None operation.measured_datetime = None operation.status = None operation.save() elif measure_key in request.POST: try: value = request.POST.get(measure_key) if value: operation.measured_value = Decimal(value) operation.measured_duration = None operation.measured_datetime = None operation.status = None operation.is_not_applicable = False operation.save() elif operation.is_not_applicable: # If previously N/A but now unchecked with no value, reset N/A flag operation.is_not_applicable = False operation.save() except Exception: pass # à remplacer par log si nécessaire elif duration_key in request.POST: try: value = request.POST.get(duration_key) if value: parts = str(value).split(':') if len(parts) == 3: from datetime import timedelta as _td h, m, s = int(parts[0]), int(parts[1]), int(parts[2]) operation.measured_duration = _td(hours=h, minutes=m, seconds=s) operation.measured_value = None operation.measured_datetime = None operation.status = None operation.is_not_applicable = False operation.save() elif operation.is_not_applicable: operation.is_not_applicable = False operation.save() except Exception: pass elif datetime_key in request.POST: try: value = request.POST.get(datetime_key) if value: dt = parse_datetime(value) if dt is None: dt = datetime.fromisoformat(value) if dt and is_naive(dt): dt = make_aware(dt, get_current_timezone()) if dt: operation.measured_datetime = dt operation.measured_value = None operation.measured_duration = None operation.status = None operation.is_not_applicable = False operation.save() elif operation.is_not_applicable: operation.is_not_applicable = False operation.save() except Exception: pass elif status_key in request.POST: try: status_id = request.POST.get(status_key) if status_id: status = OperationStatus.objects.get(pk=status_id) operation.status = status operation.measured_value = None operation.is_not_applicable = False operation.save() elif operation.is_not_applicable: # If previously N/A but now unchecked with no status, reset N/A flag operation.is_not_applicable = False operation.save() except OperationStatus.DoesNotExist: pass else: # Check if N/A was unchecked (no na_key in POST means unchecked) if operation.is_not_applicable: operation.is_not_applicable = False operation.save() # 4. Statut et actions réelles status_action = request.POST.get("status_action") realization_map = { "take_over": "real_other", "release": "real_other", "start": "real_begin", "pause": "real_pause", "finish": "real_end" } status_map = { "take_over": "assigned", "release": "to_be_processed", "start": "in_progress", "pause": "on_pause", "finish": "finished" } if status_action in status_map: new_status = status_map[status_action] old_status = intervention.status pause_reason_key = request.POST.get("pause_reason") if status_action == "pause" else None pause_reason_other = (request.POST.get("pause_reason_other") or "").strip() if status_action == "pause" else "" success, message, _email_failed = views.attempt_status_update( request, intervention, request.user, new_status, pause_reason=pause_reason_key, pause_reason_other=pause_reason_other, ) if not success: messages.error(request, message) else: # Rafraîchir l'intervention après la mise à jour (begin_time/end_time/status peuvent avoir changé) intervention.refresh_from_db() realization_event_time = now() realization_type = realization_map.get(status_action) if realization_type: if status_action == "take_over": realization_description = _("Prise en charge de l'intervention") elif status_action == "release": realization_description = _("Libération de l'intervention") elif status_action == "pause": pause_description = format_pause_description(pause_reason_key or "", pause_reason_other) realization_description = pause_description or _("Mise en pause de l'intervention") elif status_action == "start" and old_status == 'on_pause': realization_description = _("Reprise de l'intervention") elif status_action == "start": realization_description = _("Démarrage de l'intervention") elif status_action == "finish": realization_description = _("Fin de l'intervention") else: realization_description = _("Action '%(action)s' depuis l'interface mobile") % {"action": status_action} InterventionRealizationTimeLine.objects.create( intervention=intervention, event_user=request.user, event_time=realization_event_time, event_type=realization_type, event_description=realization_description, ) if status_action in ("release", "finish"): return redirect("mobile:interventions_index_mobile") return redirect("mobile:intervention_detail_mobile", intervention_id=intervention.id) assets = list( intervention.interventionasset_set.select_related('content_type').all() ) assets.sort( key=lambda link: (link.asset.get_name() if link.asset and hasattr(link.asset, 'get_name') else link.asset.code if link.asset else '').lower() ) # Enrich assets with model name for template access (no underscores allowed in templates) for link in assets: link.asset_model_name = link.content_type.model if link.content_type else None mobile_asset_replacement_data: Dict[str, Dict[str, object]] = {} # Get categories for the intervention's thematic only thematic_categories = list( AssetCategory.objects.filter(thematic_id=intervention.thematic_id).order_by('name_fr') ) categories_list = [{ "id": cat.id, "name": cat.get_name() } for cat in thematic_categories] if can_edit_intervention(request.user, intervention, user_config=user_config): for link in assets: asset_obj = getattr(link, 'asset', None) allowed_asset_actions = get_allowed_actions_for_asset(request.user, asset_obj) if asset_obj else [] link.can_replace_asset = 'replace' in allowed_asset_actions if not (link.can_replace_asset and asset_obj and link.content_type_id and link.object_id): continue key = f"{link.content_type_id}:{link.object_id}" # Get the category of the current asset asset_category_id = getattr(asset_obj, "category_id", None) asset_category_name = asset_obj.category.get_name() if hasattr(asset_obj, 'category') and asset_obj.category else "" mobile_asset_replacement_data[key] = { "asset_model": link.content_type.model if link.content_type else "", "asset_id": link.object_id, "default_model_id": getattr(asset_obj, "model_id", None), "default_category_id": asset_category_id, "default_category_name": asset_category_name, "models": _build_replacement_model_options(asset_obj, asset_category_id), "categories": categories_list, "serial_number": getattr(asset_obj, "serial_number", "") or "", "brand": getattr(asset_obj, "brand", "") or "", "label": str(asset_obj) if asset_obj else "", } else: for link in assets: link.can_replace_asset = False operations = list( intervention.operations.all() .select_related('status', 'measurement_definition', 'asset_content_type') .prefetch_related('photos') .order_by('asset_content_type_id', 'asset_object_id', 'template_group', 'order') ) operations_grouped = _group_operations_by_asset_and_template(operations, 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() ) linked_groups = _build_link_groups(intervention) locations_qs = list( InterventionLocation.objects .filter(intervention=intervention) .select_related('content_type') ) one_hour_ago = now() - timedelta(hours=1) is_admin = user_config.roles.filter(name='admin').exists() if user_config else False 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') .filter(intervention=intervention) .annotate(type_priority_order=note_priority_case) .order_by('type_priority_order', 'note_time') ) if is_admin: editable_note_ids = set(notes.values_list('id', flat=True)) else: editable_note_ids = set( notes.filter(note_author=request.user, note_time__gte=one_hour_ago) .values_list('id', flat=True) ) documents_qs = intervention.documents.all() if is_admin: deletable_document_ids = list(documents_qs.values_list('id', flat=True)) else: deletable_document_ids = list( documents_qs.filter(uploaded_by=request.user, upload_date__gte=one_hour_ago) .values_list('id', flat=True) ) 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) allowed_update_fields = get_allowed_update_fields(request.user, intervention) can_edit_notes = 'notes' in allowed_update_fields can_edit_mobile = can_edit_intervention(request.user, intervention, user_config) # Vérifier l'accès au contrat pour la prise en charge / démarrage contract = intervention.contract user_contract_access = None if contract: user_contract_access = UserContractAccess.objects.filter(user_config=user_config, contract=contract).first() can_take_over = bool(contract and user_contract_access) is_restricted_team = False company_member = CompanyMember.objects.filter(user=request.user).first() if company_member: user_roles = set(user_config.roles.values_list('name', flat=True)) if user_config else set() privileged_roles = {'admin', 'top_manager', 'manager', 'controller', 'operator', 'external_manager'} if not (user_roles & privileged_roles): matching_teams = company_member.teams.filter(company=intervention.assigned_provider) if matching_teams.exists() and not matching_teams.filter(restrict_to_assigned_interventions=False).exists(): is_restricted_team = True # --- Timeline mobile : transitions, statuts, rôles --- allowed_transitions = get_allowed_transitions(request, intervention) user_roles = list(user_config.roles.values_list('name', flat=True)) if user_config else [] is_technician = 'technician' in user_roles 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' mobile_assets_config = None mobile_asset_location_labels: List[str] = [] if can_edit_mobile: location_payload = [] for link in locations_qs: label = _format_location_label(getattr(link, 'location', None)) if not label: continue location_payload.append({ "id": link.id, "label": label, "content_type_id": link.content_type_id, "object_id": link.object_id, }) mobile_asset_location_labels = [entry['label'] for entry in location_payload] existing_pairs_payload = [] for link in assets: if not link.content_type_id or not link.object_id: continue asset_obj = getattr(link, 'asset', None) display_label = str(asset_obj) if asset_obj else '' existing_pairs_payload.append({ "content_type_id": link.content_type_id, "object_id": link.object_id, "model": link.content_type.model if link.content_type else '', "display_label": display_label, "code": getattr(asset_obj, 'code', None), }) mobile_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_payload, "locations": location_payload, "hasLocations": bool(location_payload), } can_create_repair = ( intervention.maintain_type != 'corrective' and can_add_repair_intervention(request.user, intervention) ) # 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) ] # Historique des interventions liées aux mêmes localisations (3 derniers mois) 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, }) # Construire les informations de navigation pour le bouton retour # L'historique est chaîné avec ">" : "list>intervention:123>intervention:456" back_url = reverse('mobile:interventions_index_mobile') # Par défaut : retour à la liste back_label = _("Retour à la liste") back_icon = "bi-list-ul" parent_history = "" # Historique à passer au parent lors du retour if back_history: # Séparer l'historique en éléments history_parts = back_history.split('>') # Le dernier élément est la destination de retour immédiate current_back = history_parts[-1] if history_parts else '' # Le reste est l'historique à passer au parent parent_history = '>'.join(history_parts[:-1]) if len(history_parts) > 1 else '' if current_back == 'list': pass # Valeurs par défaut (retour à la liste) elif current_back.startswith('intervention:'): try: back_id = int(current_back.split(':')[1]) # Construire l'URL avec l'historique parent back_url = reverse('mobile:intervention_detail_mobile', args=[back_id]) if parent_history: back_url += f'?back={parent_history}' back_label = _("Retour à l'intervention") back_icon = "bi-arrow-return-left" except (ValueError, IndexError): pass elif current_back.startswith('project:'): try: back_id = int(current_back.split(':')[1]) back_url = reverse('mobile:projects_detail', args=[back_id]) if parent_history: back_url += f'?back={parent_history}' back_label = _("Retour au projet") back_icon = "bi-folder" except (ValueError, IndexError): pass elif current_back.startswith('control:'): try: back_id = int(current_back.split(':')[1]) back_url = reverse('mobile:controls_detail', args=[back_id]) if parent_history: back_url += f'?back={parent_history}' back_label = _("Retour au contrôle") back_icon = "bi-clipboard-check" except (ValueError, IndexError): pass elif current_back.startswith('observation:'): try: back_id = int(current_back.split(':')[1]) back_url = reverse('mobile:observation_detail_mobile', args=[back_id]) if parent_history: back_url += f'?back={parent_history}' back_label = _("Retour à l'observation") back_icon = "bi-eye" except (ValueError, IndexError): pass # Historique à passer aux liens enfants (ajoute l'intervention actuelle) child_history = f'{back_history}>intervention:{intervention_id}' if back_history else f'intervention:{intervention_id}' # Limiter la profondeur de l'historique à 5 niveaux pour éviter des URLs trop longues child_history_parts = child_history.split('>') if len(child_history_parts) > 5: child_history = '>'.join(child_history_parts[-5:]) # Catégories pour la modale d'ajout d'équipement mobile mobile_asset_categories = [] if can_edit_mobile and intervention.thematic: mobile_asset_categories = list( AssetCategory.objects.filter(thematic=intervention.thematic) .order_by('name_fr' if get_language() != 'nl' else 'name_nl') ) # IDs des carrefours feux liés à l'intervention (pour la carte mobile) tl_intersection_ids = [] if intervention.thematic and intervention.thematic.code == 'trafficlights': tl_intersection_ct = ContentType.objects.get_for_model(TrafficLightIntersection) tl_intersection_ids = list( InterventionLocation.objects .filter(intervention=intervention, content_type=tl_intersection_ct) .values_list('object_id', flat=True) ) # 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)] # Discussion liée à cette intervention (mobile) discussion_messages_mobile = [] can_post_discussion_message_mobile = False try: from django.contrib.contenttypes.models import ContentType as _CT from notifications.models import Discussion as _Discussion from notifications.services import mark_discussion_seen as _mark_seen, can_post_intervention_message as _can_post _ct = _CT.objects.get_for_model(intervention) _discussion = _Discussion.objects.filter(content_type=_ct, object_id=intervention.pk).first() if _discussion: discussion_messages_mobile = list( _discussion.messages.filter(is_deleted=False) .select_related('author') .prefetch_related('attachments') .order_by('created_at') ) _mark_seen(_discussion, user=request.user) can_post_discussion_message_mobile = _can_post(request.user, intervention) except Exception: pass context = { 'intervention': intervention, 'assets': assets, 'notes': notes, 'can_edit_intervention': can_edit_mobile, 'allowed_update_fields': allowed_update_fields, 'deletable_document_ids': deletable_document_ids, 'editable_note_ids': editable_note_ids, 'add_note_form': InterventionNoteForm(), 'can_edit_notes': can_edit_notes, 'can_edit_operation_comments': 'operations' in allowed_update_fields, 'note_type_choices': add_note_type_choices, 'default_note_type': InterventionNote.NoteType.WORK_DONE, 'occupations': occupations, 'show_occupations': show_occupations, 'occupations_required': occupations_required, 'total_occupation_minutes': total_occupation_minutes, 'can_manage_occupations': can_manage_occupation_entries, 'occupation_members': occupation_members, 'occupation_form': occupation_form, 'hide_map_by_default': user_config.hide_intervention_map_by_default, 'pause_reason_choices': PAUSE_REASON_CHOICES, 'linked_groups': linked_groups, 'can_create_repair': can_create_repair, 'mobile_assets_enabled': can_edit_mobile, 'mobile_assets_config': mobile_assets_config, 'mobile_asset_location_labels': mobile_asset_location_labels, 'mobile_assets_max_results': MAX_MOBILE_ASSET_RESULTS, 'mobile_asset_categories': mobile_asset_categories, 'mobile_asset_replacement_data': mobile_asset_replacement_data, 'operations_general_group': general_operations_group, 'operations_asset_groups': asset_operations_groups, 'location_history_interventions': location_history_interventions, 'back_url': back_url, 'back_label': back_label, 'back_icon': back_icon, 'back_history': back_history, 'child_history': child_history, 'operation_status_sets': OperationStatusSet.objects.all(), 'measurement_definitions': MeasurementDefinition.objects.all(), # Droit de prise en charge : nécessite un contrat ET un accès au contrat 'can_take_over': can_take_over, 'is_restricted_team': is_restricted_team, # Contrôles visibles par l'utilisateur 'visible_controls': visible_controls, 'tl_intersection_ids': tl_intersection_ids, # --- Timeline mobile --- 'status_choices': [{"key": key, "label": label} for key, label in STATUS_CHOICES.items()], 'recommended_next_status': recommended_next_status, 'allowed_transitions': json.dumps(allowed_transitions), 'user_roles': user_roles, 'is_technician': is_technician, 'is_user_intern': user_config.is_intern if user_config else False, '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 '', # 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)), # Observations liées 'linked_observations': linked_observations, # Discussion 'discussion_messages': discussion_messages_mobile, 'can_post_discussion_message': can_post_discussion_message_mobile, } context['is_subscribed'] = InterventionSubscription.objects.filter(user=request.user, intervention=intervention).exists() return render(request, "interventions/intervention_detail_mobile.html", context) def mobile_upload_photo_api(request, intervention_id): """API d’upload de photo pour l’interface mobile (Canvas/getUserMedia). Attend un fichier 'image' dans request.FILES et renvoie les infos du document créé. """ if not request.user.is_authenticated: return JsonResponse({'success': False, 'error': _('Authentification requise.')}, status=401) 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 JsonResponse({'success': False, 'error': _('Vous n\'avez pas accès à cette intervention.')}, status=403) if not can_edit_intervention(request.user, intervention, user_config=user_config): return JsonResponse({'success': False, 'error': _('Vous n\'avez pas la permission de modifier cette intervention.')}, status=403) upload = request.FILES.get('image') or request.FILES.get('file') if not upload: return JsonResponse({'success': False, 'error': _('Aucun fichier reçu.')}, status=400) try: doc = InterventionDocument.objects.create( intervention=intervention, file=upload, uploaded_by=request.user, ) except Exception as e: logger.exception("Erreur lors de l\'upload de la photo mobile") return JsonResponse({'success': False, 'error': _('Erreur lors de l\'enregistrement de la photo.')}, status=500) file_url = build_private_url("interventions", "InterventionDocument", doc.id, "file") thumbnail_url = build_private_url("interventions", "InterventionDocument", doc.id, "thumbnail") if doc.thumbnail else file_url return JsonResponse({ 'success': True, 'document_id': doc.id, 'file_url': file_url, 'thumbnail_url': thumbnail_url, 'filename': getattr(doc, 'filename', None) or getattr(doc.file, 'name', ''), 'upload_date': doc.upload_date.isoformat() if hasattr(doc, 'upload_date') and doc.upload_date else None, }) def mobile_delete_photo_api(request, intervention_id, document_id): """API pour supprimer une photo uploadée via l’interface mobile (Canvas ou input). Utilise la même logique de droits que pour intervention_delete_document, mais renvoie une réponse JSON. """ if not request.user.is_authenticated: return JsonResponse({'success': False, 'error': _('Authentification requise.')}, status=401) intervention = get_object_or_404(Intervention, pk=intervention_id) doc = get_object_or_404(InterventionDocument, pk=document_id, intervention=intervention) user_config = (UserConfig.objects .select_related('user') .prefetch_related('roles') .filter(user=request.user).first()) if not user_config: return JsonResponse({'success': False, 'error': _('Vous n\'avez pas accès à cette intervention.')}, status=403) if not can_delete_document( user=request.user, user_config=user_config, document=doc, ): return JsonResponse( {'success': False, 'error': _('Vous n\'avez pas la permission de supprimer ce document.')}, status=403 ) try: doc.delete() except Exception: logger.exception("Erreur lors de la suppression du document mobile") return JsonResponse({'success': False, 'error': _('Erreur lors de la suppression du document.')}, status=500) return JsonResponse({'success': True}) def mobile_intervention_assets_filters(request, intervention_id): intervention = get_object_or_404(Intervention, pk=intervention_id) 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) ) categories_qs = categories_qs.order_by('name_fr') categories_payload = [ { "id": category.id, "code": category.code, "name": category.get_name(), } for category in categories_qs ] category_ids = [category['id'] for category in categories_payload] models_payload: Dict[str, List[Dict[str, str]]] = defaultdict(list) if category_ids: for Model in views._get_asset_model_classes(): model_label = Model._meta.model_name queryset = Model.objects.filter(category_id__in=category_ids).only( 'id', 'code', 'name_fr', 'name_nl', 'category_id' ) for asset_model in queryset.iterator(): label = asset_model.get_name() or '' if label: display = f"{label}" else: display = asset_model.code models_payload[str(asset_model.category_id)].append({ "id": asset_model.id, "code": asset_model.code, "name": label, "label": display, "model": model_label, }) for key, items in models_payload.items(): items.sort(key=lambda entry: entry['label'] or '') return JsonResponse({ 'success': True, 'categories': categories_payload, 'models': models_payload, 'max_results': MAX_MOBILE_ASSET_RESULTS, }) def mobile_intervention_assets_search(request, intervention_id): intervention = get_object_or_404(Intervention, pk=intervention_id) search_term = (request.GET.get('q') or '').strip().lower() category_ids = set(_parse_int_query_values(request, 'category', 'categories')) allowed_category_ids = _get_allowed_asset_category_ids(intervention) if allowed_category_ids: category_ids &= allowed_category_ids if not category_ids: category_filter = None else: category_filter = category_ids model_filters = _parse_model_filters(request) loc_mode = request.GET.get('loc_mode', '') if loc_mode == 'custom': # L'utilisateur a manuellement géré les localisations dans le modal loc_pairs = _parse_location_ct_obj_params(request, 'loc') locations = _resolve_location_pairs_to_links(loc_pairs) if loc_pairs else [] else: # Comportement par défaut : localisations liées à l'intervention location_ids = set(_parse_int_query_values(request, 'location', 'locations')) locations_qs = intervention.locations.select_related('content_type') if location_ids: locations_qs = locations_qs.filter(id__in=location_ids) locations = list(locations_qs) results = views._search_mobile_assets( intervention=intervention, locations=locations, category_ids=category_filter, model_filters=model_filters, search_term=search_term, ) return JsonResponse({ 'success': True, 'results': results, 'max_results': MAX_MOBILE_ASSET_RESULTS, }) def mobile_intervention_assets_locations(request, intervention_id): """Recherche de localisations pour la thématique de l'intervention. Permet à l'utilisateur d'ajouter des localisations supplémentaires dans le filtre du modal. """ intervention = get_object_or_404(Intervention, pk=intervention_id) q = (request.GET.get('q') or '').strip() thematic = intervention.thematic.code if intervention.thematic else None registry = registry_items_for_thematics([thematic] if thematic else None) lang = (get_language() or 'fr').split('-')[0] is_nl = (lang == 'nl') results = [] for _key, meta in registry: Model = meta['model'] ct = ContentType.objects.get_for_model(Model) if q: qn = strip_accents_lower(q) qs = base_search_qs(Model, qn, is_nl).order_by('_prio', 'code')[:SEARCH_LIMIT_PER_TYPE] else: qs = Model.objects.order_by('code')[:SEARCH_LIMIT_PER_TYPE] for loc in qs: label = _format_location_label(loc) if not label: continue results.append({ 'ct_id': ct.id, 'obj_id': loc.pk, 'label': label, 'type_label': str(meta.get('label', '')), }) return JsonResponse({'success': True, 'results': results[:SEARCH_LIMIT_TOTAL]}) def mobile_intervention_assets_sync(request, intervention_id): intervention = get_object_or_404(Intervention, pk=intervention_id) try: payload = json.loads(request.body or '{}') except json.JSONDecodeError: return JsonResponse({'success': False, 'error': _('Requête invalide.')}, status=400) assets_payload = payload.get('assets') if not isinstance(assets_payload, list): return JsonResponse({'success': False, 'error': _('Aucune sélection d\'équipements reçue.')}, status=400) # Extract additional parameters from payload delete_operations = payload.get('delete_operations', False) mark_as_removed = payload.get('mark_as_removed', False) removed_asset_key = payload.get('removed_asset_key', None) removed_asset_keys = payload.get('removed_asset_keys', None) if not isinstance(removed_asset_keys, list): removed_asset_keys = None try: stats = views._sync_intervention_assets_data( intervention, assets_payload, delete_operations=delete_operations, mark_as_removed=mark_as_removed, removed_asset_key=removed_asset_key, removed_asset_keys=removed_asset_keys, user=request.user, request=request ) except ValueError as exc: return JsonResponse({'success': False, 'error': str(exc)}, status=400) except RuntimeError as exc: logger.exception("Erreur lors de la synchronisation des assets mobiles", exc_info=exc) return JsonResponse({'success': False, 'error': _('Erreur lors de la synchronisation des équipements.')}, status=500) base_message = _( "Synchronisation terminée. Ajoutés: %(created)d • Supprimés: %(deleted)d • Total cibles: %(total)d" ) % { 'created': stats.get('created', 0), 'deleted': stats.get('deleted', 0), 'total': stats.get('target_total', 0), } if stats.get('operations_created'): base_message += _(" • Opérations ajoutées: %(ops)d") % {'ops': stats['operations_created']} if stats.get('operations_deleted'): base_message += _(" • Opérations supprimées: %(ops)d") % {'ops': stats['operations_deleted']} response_payload = { 'success': True, 'message': base_message, 'stats': stats, } if stats.get('assets_with_values'): response_payload['warning'] = ngettext( "Certaines opérations n'ont pas été supprimées car des valeurs ont été encodées pour %(count)d équipement.", "Certaines opérations n'ont pas été supprimées car des valeurs ont été encodées pour %(count)d équipements.", stats['assets_with_values'], ) % {'count': stats['assets_with_values']} return JsonResponse(response_payload) def add_repair_intervention_mobile(request): """ Vue mobile pour créer une nouvelle intervention de réparation (structures uniquement). Accessible uniquement aux techniciens de la thématique structures. """ # Vérifier les permissions try: user_config = UserConfig.objects.get(user=request.user) except UserConfig.DoesNotExist: return HttpResponseForbidden(_("Configuration utilisateur non trouvée.")) # 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 HttpResponseForbidden(_("Cette fonctionnalité est réservée à la thématique structures.")) # 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 HttpResponseForbidden(_("Cette fonctionnalité est réservée aux techniciens.")) has_edit_rights = UserThematics.objects.filter( user_config=user_config, thematic=default_thematic, can_edit_interventions=True ).exists() if not has_edit_rights: return HttpResponseForbidden(_("Vous n'avez pas les droits pour créer des interventions.")) # Récupérer les catégories de la thématique structures categories = AssetCategory.objects.filter(thematic=default_thematic).order_by('name_fr') # Récupérer le membre de la société company_member = CompanyMember.objects.filter(user=request.user).prefetch_related('teams__company').first() if request.method == 'POST': # 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 render(request, 'interventions/add/add_repair_intervention_mobile.html', { 'categories': categories, 'errors': errors, 'form_data': request.POST, }) # Récupérer la localisation try: location = StructureLocation.objects.select_related('structure').get(id=location_id) except StructureLocation.DoesNotExist: return render(request, 'interventions/add/add_repair_intervention_mobile.html', { 'categories': categories, 'errors': [_("Localisation non trouvée.")], 'form_data': request.POST, }) # Récupérer la catégorie try: category = AssetCategory.objects.get(id=category_id) except AssetCategory.DoesNotExist: return render(request, 'interventions/add/add_repair_intervention_mobile.html', { 'categories': categories, 'errors': [_("Catégorie non trouvée.")], 'form_data': request.POST, }) # 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 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, avec la même compagnie contract = None if structure: structure_contract = StructureContract.objects.filter( structure=structure, contract__is_active=True ).select_related('contract') if structure_contract.exists(): for sc in structure_contract: if assigned_provider and sc.contract.company == assigned_provider: contract = sc.contract break with transaction.atomic(): if not contract: from common.models import UserConfig user_config = UserConfig.objects.filter(user=request.user).first() if user_config and user_config.limit_interventions_to_contracts: default_contract = user_config.get_default_contract() if default_contract: contract = default_contract if not assigned_provider: assigned_provider = default_contract.company new_intervention = Intervention( title=title, description=_("Intervention de réparation créée depuis l'application mobile."), 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='in_preparation', status_order=STATUS_ORDERS['in_preparation'], 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, ) messages.success( request, _("L'intervention %(code)s a été créée avec succès.") % {'code': new_intervention.code} ) return redirect(reverse('mobile:intervention_detail_mobile', kwargs={'intervention_id': new_intervention.id})) # GET: afficher le formulaire return render(request, 'interventions/add/add_repair_intervention_mobile.html', { 'categories': categories, }) def choose_maintain_type_for_intervention_mobile(request): """ Étape 1 mobile : choix du type de maintenance (corrective / améliorative). Accessible aux utilisateurs internes admin/manager/controller. """ try: user_config = UserConfig.objects.get(user=request.user) except UserConfig.DoesNotExist: return HttpResponseForbidden(_("Configuration utilisateur non trouvée.")) if not user_config.is_intern or not user_config.roles.filter(name__in=['admin', 'manager', 'controller']).exists(): return HttpResponseForbidden(_("Accès réservé aux gestionnaires internes (admin, manager, controller).")) return render(request, 'interventions/add/choose_maintain_type_for_intervention_mobile.html') def choose_thematic_for_intervention_mobile(request): """ Étape 2 mobile : choix de la thématique avant de créer une intervention. Accessible aux utilisateurs internes admin/manager/controller. Si une seule thématique avec droits d'édition, redirige directement vers le formulaire desktop. """ try: user_config = UserConfig.objects.get(user=request.user) except UserConfig.DoesNotExist: return HttpResponseForbidden(_("Configuration utilisateur non trouvée.")) if not user_config.is_intern or not user_config.roles.filter(name__in=['admin', 'manager', 'controller']).exists(): return HttpResponseForbidden(_("Accès réservé aux gestionnaires internes (admin, manager, controller).")) maintain_type = request.GET.get('maintain_type', 'corrective') if maintain_type not in ('corrective', 'ameliorative'): maintain_type = 'corrective' lang = get_language() order_field = 'thematic__name_nl' if lang == 'nl' else 'thematic__name_fr' user_thematics = ( UserThematics.objects .filter(user_config=user_config, can_edit_interventions=True) .select_related('thematic') .order_by(order_field) ) thematics = [ut.thematic for ut in user_thematics if ut.thematic] if not thematics: return HttpResponseForbidden(_("Aucune thématique avec droits de création d'intervention trouvée.")) if len(thematics) == 1: url = reverse('mobile:intervention_add_mobile_form', kwargs={'thematic_code': thematics[0].code}) return redirect(f"{url}?maintain_type={maintain_type}") return render(request, 'interventions/add/choose_thematic_for_intervention_mobile.html', { 'thematics': thematics, 'maintain_type': maintain_type, }) def add_intervention_mobile(request, thematic_code): """ Vue mobile pour créer une nouvelle intervention (toutes thématiques). Accessible aux utilisateurs internes avec rôle admin, manager ou controller. Permet de créer des interventions correctives ou amélioratives. """ try: user_config = UserConfig.objects.get(user=request.user) except UserConfig.DoesNotExist: return HttpResponseForbidden(_("Configuration utilisateur non trouvée.")) # Réservé aux utilisateurs internes admin/manager/controller if not user_config.is_intern or not user_config.roles.filter(name__in=['admin', 'manager', 'controller']).exists(): return HttpResponseForbidden(_("Accès réservé aux gestionnaires internes (admin, manager, controller).")) thematic = get_object_or_404(Thematic, code=thematic_code) # Vérifier que l'utilisateur a les droits d'édition pour cette thématique has_edit_rights = UserThematics.objects.filter( user_config=user_config, thematic=thematic, can_edit_interventions=True, ).exists() if not has_edit_rights: return HttpResponseForbidden(_("Vous n'avez pas les droits pour créer des interventions pour cette thématique.")) categories = AssetCategory.objects.filter(thematic=thematic).order_by('name_fr') # URL de l'autocomplete des équipements selon la thématique _THEMATIC_ASSET_AUTOCOMPLETE = { 'structures': 'assets:structures_autocomplete_assets', 'trafficlights': 'assets:trafficlights_autocomplete_assets', 'publiclighting': 'assets:publiclighting_autocomplete_assets', 'artworks': 'assets:artworks_autocomplete_assets', 'nature': 'assets:nature_autocomplete_assets', 'controlcenters': 'assets:controlcenters_autocomplete_assets', 'sign': 'assets:sign_autocomplete_assets', 'roads': 'assets:roads_autocomplete_assets', 'its': 'assets:its_autocomplete_assets', 'water': 'assets:water_autocomplete_assets', } _asset_url_name = _THEMATIC_ASSET_AUTOCOMPLETE.get(thematic.code) try: asset_search_url = reverse(_asset_url_name) if _asset_url_name else None except NoReverseMatch: asset_search_url = None # Statuts disponibles à la création CREATION_STATUSES = [ ('in_preparation', STATUS_CHOICES['in_preparation']), ('to_be_approved', STATUS_CHOICES['to_be_approved']), ('to_be_planned', STATUS_CHOICES['to_be_planned']), ('to_be_processed', STATUS_CHOICES['to_be_processed']), ] if request.method == 'POST': location_ct_id = request.POST.get('location_ct') location_obj_id = request.POST.get('location_obj') category_id = request.POST.get('category_id') title = request.POST.get('title', '').strip() maintain_type = request.POST.get('maintain_type', 'corrective') if maintain_type not in ('corrective', 'ameliorative'): maintain_type = 'corrective' status = 'in_preparation' location_code = request.POST.get('location_code') or None asset_ct_id = request.POST.get('asset_ct') or None asset_obj_id = request.POST.get('asset_obj') or None try: lon = float(request.POST.get('location_lon')) if request.POST.get('location_lon') else None lat = float(request.POST.get('location_lat')) if request.POST.get('location_lat') else None except (ValueError, TypeError): lon, lat = None, None def _render_form(errors): return render(request, 'interventions/add/add_intervention_mobile.html', { 'categories': categories, 'creation_statuses': CREATION_STATUSES, 'errors': errors, 'form_data': request.POST, 'thematic': thematic, 'asset_search_url': asset_search_url, }) errors = [] if not location_ct_id or not location_obj_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 _render_form(errors) try: category = AssetCategory.objects.get(id=category_id) except AssetCategory.DoesNotExist: return _render_form([_("Catégorie non trouvée.")]) with transaction.atomic(): mobile_contract = None mobile_provider = None from common.models import UserConfig user_config = UserConfig.objects.filter(user=request.user).first() if user_config and user_config.limit_interventions_to_contracts: default_contract = user_config.get_default_contract() if default_contract: mobile_contract = default_contract mobile_provider = default_contract.company new_intervention = Intervention( title=title, description='', maintain_type=maintain_type, type='repair', priority='3', thematic=thematic, asset_category=category, contract=mobile_contract, assigned_provider=mobile_provider, lon=lon, lat=lat, location_code=location_code, status=status, status_order=STATUS_ORDERS[status], 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) # Créer le lien InterventionLocation via ContentType générique try: ct = ContentType.objects.get(id=int(location_ct_id)) InterventionLocation.objects.create( intervention=new_intervention, content_type=ct, object_id=int(location_obj_id), ) except (ContentType.DoesNotExist, ValueError, TypeError): pass # Ajouter l'équipement si spécifié (optionnel) if asset_ct_id and asset_obj_id: try: asset_ct = ContentType.objects.get(id=int(asset_ct_id)) InterventionAsset.objects.create( intervention=new_intervention, content_type=asset_ct, object_id=int(asset_obj_id), ) except (ContentType.DoesNotExist, ValueError, TypeError): pass 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, _("L'intervention %(code)s a été créée avec succès.") % {'code': new_intervention.code} ) return redirect(reverse('mobile:intervention_detail_mobile', kwargs={'intervention_id': new_intervention.id})) # GET return render(request, 'interventions/add/add_intervention_mobile.html', { 'categories': categories, 'creation_statuses': CREATION_STATUSES, 'thematic': thematic, 'asset_search_url': asset_search_url, }) def save_intervention_mobile_ajax(request, intervention_id): """ Save intervention data via AJAX without page reload. Handles: note, documents (photos), and operations. Accepts multipart/form-data for file uploads. """ intervention = get_object_or_404(Intervention, pk=intervention_id) result = { 'success': True, 'note_saved': False, 'documents_count': 0, 'operations_count': 0, 'errors': [] } # 1. Save note if provided note_content = request.POST.get("content", "").strip() note_type = request.POST.get("note_type", "") note_id = request.POST.get("note_id", "").strip() if note_content: try: note = None # Check if we're updating an existing note if note_id: try: note = InterventionNote.objects.get(pk=note_id, intervention=intervention) # Only allow updating if user is the author if note.note_author == request.user: note.content = note_content note.note_type = note_type or note.note_type note.updated_by = request.user note.save() result['note_saved'] = True result['note_updated'] = True else: result['errors'].append(_("Vous n'êtes pas autorisé à modifier cette note")) except InterventionNote.DoesNotExist: # Note doesn't exist, create a new one note = None # Create new note if no existing note was found/updated if not note: note = InterventionNote.objects.create( intervention=intervention, content=note_content, note_type=note_type or InterventionNote.NoteType.FIELD_NOTE, note_author=request.user, note_time=now() ) result['note_saved'] = True result['note_updated'] = False # Return note data for frontend if note: result['note'] = { 'id': note.id, 'note_id': note.id, 'content': note.content, 'note_type': note.note_type, 'note_type_display': note.get_note_type_display(), 'author_display': note.note_author.get_short_name() if hasattr(note.note_author, 'get_short_name') else str(note.note_author), 'note_time_display': note.note_time.strftime('%d-%m-%Y %H:%M') if note.note_time else '', 'badge_class': note.type_badge_class, 'icon_class': note.type_icon_class } except Exception as e: result['errors'].append(_("Erreur lors de l'enregistrement de la note: %(error)s") % {'error': str(e)}) # 2. Save documents (photos/files) for file in request.FILES.getlist("photos"): try: InterventionDocument.objects.create( intervention=intervention, file=file, uploaded_by=request.user ) result['documents_count'] += 1 except Exception as e: result['errors'].append(_("Erreur lors de l'upload du fichier %(name)s: %(error)s") % {'name': file.name, 'error': str(e)}) # 3. Save operations operations_json = request.POST.get("operations_json", "") if operations_json: try: operations_data = json.loads(operations_json) 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() result['operations_count'] += 1 continue else: operation.is_not_applicable = False if 'measured_value' in op_data: value = op_data['measured_value'] if value is not None and value != '': operation.measured_value = Decimal(str(value)) operation.measured_duration = None operation.measured_datetime = None operation.status = None operation.is_not_applicable = False else: operation.measured_value = None 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 except (ValueError, AttributeError): result['errors'].append(_("Valeur de durée invalide pour l'opération %(id)s") % {'id': op_id}) else: operation.measured_duration = None 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): result['errors'].append(_("Valeur de date invalide pour l'opération %(id)s") % {'id': op_id}) else: operation.measured_datetime = None if 'status_id' in op_data: status_id = op_data['status_id'] if status_id: status = OperationStatus.objects.get(pk=status_id) operation.status = status operation.measured_value = None operation.is_not_applicable = False else: operation.status = None operation.save() result['operations_count'] += 1 except Operation.DoesNotExist: result['errors'].append(_("Opération %(id)s introuvable") % {'id': op_id}) except OperationStatus.DoesNotExist: result['errors'].append(_("Statut invalide pour l'opération %(id)s") % {'id': op_id}) except (InvalidOperation, ValueError): result['errors'].append(_("Valeur de mesure invalide pour l'opération %(id)s") % {'id': op_id}) except json.JSONDecodeError: result['errors'].append(_("Format JSON invalide pour les opérations")) # Build message parts = [] if result['note_saved']: parts.append(_("Note enregistrée")) if result['documents_count'] > 0: parts.append(_("%(count)s fichier(s)") % {'count': result['documents_count']}) if result['operations_count'] > 0: parts.append(_("%(count)s opération(s)") % {'count': result['operations_count']}) if parts: result['message'] = ", ".join(parts) + " ✓" else: result['message'] = _("Aucune modification") result['success'] = len(result['errors']) == 0 return JsonResponse(result) def get_transferable_members(request, intervention_id): """ API endpoint to get members available for intervention transfer. Returns members from teams that share thematics with the current user's team, and belong to the same company. """ intervention = get_object_or_404(Intervention, pk=intervention_id) # Get current user's member and team current_member = CompanyMember.objects.prefetch_related('teams__company', 'teams__thematics').filter(user=request.user).first() if not current_member or not current_member.teams.exists(): return JsonResponse({ 'success': False, 'error': _("Vous n'êtes pas membre d'une équipe."), 'members': [] }) current_teams = current_member.teams.all() current_companies = list(set(t.company for t in current_teams if t.company)) # Get thematics of the current user's team current_team_thematic_ids = set() for t in current_teams: current_team_thematic_ids.update(t.thematics.values_list('id', flat=True)) if not current_team_thematic_ids: return JsonResponse({ 'success': False, 'error': _("Votre équipe n'a pas de thématiques assignées."), 'members': [] }) # Find teams in the same company that share at least one thematic teams_with_shared_thematics = ( CompanyTeam.objects .filter(company__in=current_companies) .filter(thematics__id__in=current_team_thematic_ids) .distinct() ) # Get all members from those teams, excluding the current user transferable_members = ( CompanyMember.objects .prefetch_related('teams__company') .filter(teams__in=teams_with_shared_thematics) .exclude(user=request.user) .distinct() .order_by('name') ) # Serialize members members_list = [] for member in transferable_members: member_teams = member.teams.all() shared_member_teams = [t for t in member_teams if t in teams_with_shared_thematics] first_shared_team = shared_member_teams[0] if shared_member_teams else (member_teams[0] if member_teams else None) members_list.append({ 'id': member.id, 'name': member.name, 'team_name': first_shared_team.name if first_shared_team else '', 'company_name': first_shared_team.company.name if first_shared_team and first_shared_team.company else '', }) return JsonResponse({ 'success': True, 'members': members_list, 'current_member_id': current_member.id, 'current_team_name': ", ".join(t.name for t in current_teams), }) def transfer_intervention(request, intervention_id): """ API endpoint to transfer an intervention to another member. The target member must be from a team with shared thematics and same company. """ intervention = get_object_or_404(Intervention, pk=intervention_id) # Get current user's member and team current_member = CompanyMember.objects.prefetch_related('teams__company', 'teams__thematics').filter(user=request.user).first() if not current_member or not current_member.teams.exists(): return JsonResponse({ 'success': False, 'error': _("Vous n'êtes pas membre d'une équipe.") }, status=403) current_teams = current_member.teams.all() current_companies = list(set(t.company for t in current_teams if t.company)) current_company_ids = [c.id for c in current_companies] # Get thematics of the current user's team current_team_thematic_ids = set() for t in current_teams: current_team_thematic_ids.update(t.thematics.values_list('id', flat=True)) # Find teams in the same company that share at least one thematic teams_with_shared_thematics = ( CompanyTeam.objects .filter(company__in=current_companies) .filter(thematics__id__in=current_team_thematic_ids) .distinct() ) # Get target member ID from request try: if request.content_type == 'application/json': payload = json.loads(request.body.decode('utf-8') or '{}') target_member_id = payload.get('member_id') else: target_member_id = request.POST.get('member_id') if not target_member_id: return JsonResponse({ 'success': False, 'error': _("Veuillez sélectionner un membre.") }, status=400) target_member_id = int(target_member_id) except (json.JSONDecodeError, ValueError, TypeError): return JsonResponse({ 'success': False, 'error': _("Paramètre invalide.") }, status=400) # Get target member target_member = CompanyMember.objects.prefetch_related('teams__company', 'teams__thematics').filter(id=target_member_id).first() if not target_member: return JsonResponse({ 'success': False, 'error': _("Membre introuvable.") }, status=404) # Verify target member is from the same company target_teams = target_member.teams.all() if not any(t.company_id in current_company_ids for t in target_teams if t.company_id): return JsonResponse({ 'success': False, 'error': _("Le membre cible doit être de la même société.") }, status=403) # Verify target team has shared thematics with current team target_team_thematic_ids = set() for t in target_teams: target_team_thematic_ids.update(t.thematics.values_list('id', flat=True)) if not current_team_thematic_ids.intersection(target_team_thematic_ids): return JsonResponse({ 'success': False, 'error': _("Le membre cible doit être dans une équipe avec des thématiques communes.") }, status=403) # Perform the transfer old_member = intervention.assigned_member old_status = intervention.status shared_teams = [t for t in target_teams if t in teams_with_shared_thematics] target_team = shared_teams[0] if shared_teams else (target_teams[0] if target_teams else None) intervention.assigned_member = target_member intervention.assigned_team = target_team # If intervention is in progress, pause it automatically status_changed = False if intervention.status == 'in_progress': intervention.status = 'on_pause' status_changed = True intervention.save(update_fields=['assigned_member', 'assigned_team', 'status']) else: intervention.save(update_fields=['assigned_member', 'assigned_team']) # Record the transfer in timeline event_time = now() # Always record the transfer event InterventionTimeLine.objects.create( intervention=intervention, event_user=request.user, event_time=event_time, event_type='transfer', from_status=old_status, to_status=old_status, # Status before any change ) # If status changed, also record a status change event if status_changed: InterventionTimeLine.objects.create( intervention=intervention, event_user=request.user, event_time=event_time, event_type='status', from_status=old_status, to_status=intervention.status, ) # Add a note about the transfer transfer_note = _("Intervention transférée de %(from_member)s (%(from_team)s) vers %(to_member)s (%(to_team)s)") % { 'from_member': old_member.name if old_member else _("non assigné"), 'from_team': old_team.name if old_team else _("aucune équipe"), 'to_member': target_member.name, 'to_team': target_team.name if target_team else _("aucune équipe"), } if status_changed: transfer_note += " - " + _("Statut changé automatiquement de 'Démarré' à 'En pause'.") InterventionNote.objects.create( intervention=intervention, content=transfer_note, note_type=InterventionNote.NoteType.COMMENT, note_author=request.user, note_time=now() ) return JsonResponse({ 'success': True, 'message': _("Intervention transférée avec succès vers %(member)s.") % {'member': target_member.name}, 'new_member': { 'id': target_member.id, 'name': target_member.name, 'team_name': target_team.name if target_team else _("aucune équipe"), } })