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 * # permission is defined in the function interventions_geojson def index(request): lang = get_language() user_config = UserConfig.objects.filter(user=request.user).first() if not user_config: return HttpResponseForbidden(_("You do not have access to this page.")) # Récupération brute des filtres depuis l'URL thematics = request.GET.getlist('thematic') locations = request.GET.get('locations') statuses = request.GET.getlist('status') priorities = request.GET.getlist('priority') maintain_types = request.GET.getlist('maintain_type') categories = request.GET.getlist('category') types = request.GET.getlist('type') preparation_states = request.GET.getlist('preparation_state') begin_date_min = request.GET.get('begin_date_min') begin_date_max = request.GET.get('begin_date_max') end_date_min = request.GET.get('end_date_min') end_date_max = request.GET.get('end_date_max') pause_reasons = request.GET.getlist('pause_reason') thematic_choices_qs = ( UserThematics.objects .filter(user_config=user_config, can_view_interventions=True) .select_related('thematic') .order_by(f'thematic__{"name_nl" if lang == "nl" else "name_fr"}') ) default_thematic = [user_config.default_thematic.code] if user_config.default_thematic else [] # Default statuses: tous sauf finalisé (closed) et annulé (canceled) default_statuses = [ 'in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed', 'assigned', 'in_progress', 'on_pause', 'finished', 'processed', 'to_be_corrected', 'corrected', 'validated', 'invoiced' ] preselected_locations = _resolve_locations_payload(request.GET.get("locations")) # Load saved filters and sort preferences if no URL params saved_filters = user_config.intervention_list_filters or {} saved_sort = user_config.intervention_list_sort or {} # Redirection si aucun paramètre de filtre n'est fourni if not request.GET: query_params = request.GET.copy() # Apply saved filters if available if saved_filters: # Restore 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: # Par défaut, appliquer les thématiques autorisées en édition et les statuts par défaut. # Si aucune thématique autorisée, utiliser la thématique par défaut de l'utilisateur si elle existe. edit_thematic_choices_qs = ( UserThematics.objects .filter(user_config=user_config, can_edit_interventions=True) .select_related('thematic') ) if edit_thematic_choices_qs.exists(): authorized_thematic_codes = list(edit_thematic_choices_qs.values_list('thematic__code', flat=True)) query_params.setlist('thematic', authorized_thematic_codes) elif default_thematic: query_params.setlist('thematic', default_thematic) query_params.setlist('status', default_statuses) return HttpResponseRedirect(f"{request.path}?{query_params.urlencode()}") pause_reason_filter_enabled = len(statuses) == 1 and statuses[0] == 'on_pause' if not pause_reason_filter_enabled: pause_reasons = [] # Récupération du filtre overdue overdue = request.GET.get('overdue') followed_only = request.GET.get('followed_only') # Récupération du filtre to_be_planned_soon to_be_planned_soon = request.GET.get('to_be_planned_soon') # Déterminer les types d'interventions que l'utilisateur peut créer # - Corrective : tout le monde # - Préventive : admin, manager, controller (pas external_manager, pas operator) # - Améliorative : admin, manager, controller, external_manager, operator user_roles = set(user_config.roles.values_list('name', flat=True)) # Préventive : uniquement admin, manager, controller can_create_preventive = bool(user_roles & {'admin', 'manager', 'controller'}) # Améliorative : admin, manager, controller, external_manager, operator can_create_ameliorative = bool(user_roles & {'admin', 'manager', 'controller', 'external_manager', 'operator'}) # Get authorized thematic codes for this user authorized_thematic_codes = set(tc.thematic.code for tc in thematic_choices_qs) # Get categories with their thematic IDs for dynamic filtering # Only include categories whose thematic is authorized for the user (or categories without thematic) categories_with_thematics = sorted( [(cat.id, cat.get_name(), cat.thematic.code if cat.thematic else None, cat.parent_id) for cat in AssetCategory.objects.select_related('thematic').all().order_by('name_fr') if cat.thematic is None or cat.thematic.code in authorized_thematic_codes], key=lambda t: (t[3] if t[3] is not None else t[0], 1 if t[3] is not None else 0, t[1]) ) # Providers and contracts (intern users only) provider_choices = [] contract_choices = [] if user_config.is_intern: is_admin = user_config.roles.filter(name='admin').exists() or request.user.is_superuser contracts_qs = Contract.objects.select_related('company').prefetch_related('thematics') if not is_admin: accessible_contract_ids = UserContractAccess.objects.filter( user_config=user_config, can_view_interventions=True, ).values_list('contract_id', flat=True) if authorized_thematic_codes: contracts_qs = contracts_qs.filter(thematics__code__in=authorized_thematic_codes) contracts_qs = contracts_qs.filter(id__in=accessible_contract_ids).distinct() # Build contract choices with thematic codes from django.contrib.auth import get_user_model User = get_user_model() for contract in contracts_qs: thematic_codes = list(contract.thematics.values_list('code', flat=True)) managers_qs = User.objects.filter( Q(config__usercontractaccess__contract=contract, config__usercontractaccess__can_approve=True) | Q(managed_interventions__contract=contract) ).distinct().order_by('username') managers_data = [] for mgr in managers_qs: managers_data.append({ 'id': mgr.id, 'name': _get_user_short_name(mgr) }) managers_json = json.dumps(managers_data) contract_choices.append(( contract.id, contract.contract_number, contract.company_id, thematic_codes, contract.uses_intervention_manager, managers_json )) # Providers from these contracts, with aggregated thematic codes provider_map = {} for contract in contracts_qs: thematic_codes = set(contract.thematics.values_list('code', flat=True)) if contract.company_id not in provider_map: provider_map[contract.company_id] = { 'name': contract.company.name, 'thematics': set() } provider_map[contract.company_id]['thematics'].update(thematic_codes) provider_choices = [ (pid, pdata['name'], list(pdata['thematics'])) for pid, pdata in provider_map.items() ] # Assigned teams and members (respect permissions) role_names = _get_role_names(user_config) allowed_company_ids = _member_view_allowed_company_ids(user_config, role_names) allowed_thematic_ids = _member_view_allowed_thematic_ids(user_config, role_names) if allowed_thematic_ids is None: allowed_thematic_ids = set(tc.thematic.id for tc in thematic_choices_qs) teams_qs = ( CompanyTeam.objects .select_related('company') .prefetch_related('thematics') .order_by('company__name', 'name') ) if allowed_company_ids is not None: teams_qs = teams_qs.filter(company_id__in=allowed_company_ids) if allowed_thematic_ids: teams_qs = teams_qs.filter(thematics__id__in=allowed_thematic_ids).distinct() team_choices = [ ( team.id, f"{team.name}", team.company_id, list(team.thematics.values_list('code', flat=True)), ) for team in teams_qs ] team_ids = [t[0] for t in team_choices] members_qs = ( CompanyMember.objects .prefetch_related('teams__company', 'teams__thematics') .order_by('name') ) if allowed_company_ids is not None: members_qs = members_qs.filter(teams__company_id__in=allowed_company_ids) if allowed_thematic_ids: members_qs = members_qs.filter(teams__thematics__id__in=allowed_thematic_ids) if team_ids: members_qs = members_qs.filter(teams__id__in=team_ids) members_qs = members_qs.distinct() member_choices = [] for member in members_qs: m_teams = member.teams.all() m_team_ids_str = ",".join(str(t.id) for t in m_teams) m_companies_str = ",".join(str(t.company_id) for t in m_teams if t.company_id) m_thematics = set() for t in m_teams: m_thematics.update(t.thematics.values_list('code', flat=True)) member_choices.append(( member.id, f"{member.name}", m_team_ids_str, m_companies_str, list(m_thematics), )) # Status classes (Bootstrap contextual classes) status_classes = { 'in_preparation': 'secondary', 'to_be_approved': 'warning', 'to_be_planned': 'warning', 'to_be_processed': 'danger', 'assigned': 'warning', 'in_progress': 'primary', 'on_pause': 'warning', 'finished': 'info', 'processed': 'info', 'to_be_corrected': 'danger', 'corrected': 'info', 'validated': 'success', 'invoiced': 'dark', 'closed': 'dark', 'canceled': 'secondary', } # Type de maintenance classes (Bootstrap) maintain_type_classes = { 'corrective': 'danger', 'preventive': 'warning', 'ameliorative': 'success', } # Format status_choices with classes status_choices_with_classes = [(key, value, status_classes.get(key, 'secondary')) for key, value in Intervention._meta.get_field('status').choices] # Format maintain_type_choices with classes maintain_type_choices_with_classes = [(key, value, maintain_type_classes.get(key, 'secondary')) for key, value in Intervention._meta.get_field('maintain_type').choices] context = { 'thematic_choices': [(tc.thematic.code, tc.thematic.name, tc.thematic.icon) for tc in thematic_choices_qs], 'status_choices': status_choices_with_classes, 'priority_choices': Intervention._meta.get_field('priority').choices, 'maintain_type_choices': maintain_type_choices_with_classes, 'type_choices': Intervention._meta.get_field('type').choices, 'category_choices': categories_with_thematics, 'provider_choices': provider_choices, 'contract_choices': contract_choices, 'team_choices': team_choices, 'member_choices': member_choices, 'show_provider_filters': user_config.is_intern, 'pause_reason_choices': PAUSE_REASON_CHOICES, 'selected_thematics': thematics, 'selected_locations': locations, 'selected_statuses': statuses, 'selected_priorities': priorities, 'selected_maintain_types': maintain_types, 'selected_categories': categories, 'selected_types': types, 'selected_providers': request.GET.getlist('provider'), 'selected_contracts': request.GET.getlist('contract'), 'selected_teams': request.GET.getlist('assigned_team'), 'selected_members': request.GET.getlist('assigned_member'), 'selected_pause_reasons': pause_reasons, 'selected_preparation_states': preparation_states, 'begin_date_min': begin_date_min or '', 'begin_date_max': begin_date_max or '', 'end_date_min': end_date_min or '', 'end_date_max': end_date_max or '', 'pause_reason_filter_enabled': pause_reason_filter_enabled, "preselected_locations": json.dumps(preselected_locations, ensure_ascii=False), 'selected_interventions_ids': json.dumps([]), 'overdue': overdue, 'followed_only': followed_only, 'to_be_planned_soon': to_be_planned_soon, 'can_create_preventive': can_create_preventive, 'can_create_ameliorative': can_create_ameliorative, 'intervention_list_layout': user_config.intervention_list_layout, 'saved_sort': json.dumps(saved_sort, ensure_ascii=False), 'saved_columns': json.dumps(user_config.intervention_list_columns, ensure_ascii=False), 'is_admin': user_config.roles.filter(name='admin').exists(), } return render(request, "interventions/interventions_index.html", context) # @measure_time def interventions_geojson(request): qs = filter_viewable_interventions_for_user(request.user) # Helper function to expand comma-separated values in query params def _expand_csv_params(param_list): """Expands comma-separated values in a list of parameters.""" if not param_list: return None result = [] for item in param_list: result.extend([v.strip() for v in item.split(',') if v.strip()]) return result if result else None # --- filters from query params thematics = request.GET.getlist('thematic') or None locations = request.GET.get('locations') or None statuses = _expand_csv_params(request.GET.getlist('status')) priorities = _expand_csv_params(request.GET.getlist('priority')) maintain_types = _expand_csv_params(request.GET.getlist('maintain_type')) categories = _expand_csv_params(request.GET.getlist('category')) types = _expand_csv_params(request.GET.getlist('type')) providers = _expand_csv_params(request.GET.getlist('provider')) contracts = _expand_csv_params(request.GET.getlist('contract')) assigned_teams = _expand_csv_params(request.GET.getlist('assigned_team')) assigned_members = _expand_csv_params(request.GET.getlist('assigned_member')) pause_reasons = _expand_csv_params(request.GET.getlist('pause_reason')) intervention_managers = _expand_csv_params(request.GET.getlist('intervention_manager')) preparation_states = _expand_csv_params(request.GET.getlist('preparation_state')) begin_date_min = request.GET.get('begin_date_min') or None begin_date_max = request.GET.get('begin_date_max') or None end_date_min = request.GET.get('end_date_min') or None end_date_max = request.GET.get('end_date_max') or None date_type = request.GET.get('date_type', 'all') followed_only = request.GET.get('followed_only') search_q = (request.GET.get('q') or '').strip() if thematics: qs = qs.filter(thematic__code__in=thematics) if locations: selected_loc = _parse_selected_locations(request) allowed_ct_ids = _allowed_ct_ids_from_thematics(thematics) if thematics else None qs = apply_location_filter(qs, selected_loc, allowed_ct_ids=allowed_ct_ids) if statuses: qs = qs.filter(status__in=statuses) if pause_reasons and statuses and set(statuses) == {'on_pause'}: qs = qs.filter(pause_reason__in=pause_reasons) if priorities: qs = qs.filter(priority__in=priorities) if maintain_types: qs = qs.filter(maintain_type__in=maintain_types) if categories: qs = qs.filter(asset_category_id__in=categories) if types: qs = qs.filter(type__in=types) if providers: qs = qs.filter(assigned_provider_id__in=providers) if contracts: qs = qs.filter(contract_id__in=contracts) if assigned_members: qs = qs.filter(assigned_member_id__in=assigned_members) if assigned_teams: qs = qs.filter(Q(assigned_team_id__in=assigned_teams) | Q(assigned_member__teams__in=assigned_teams)).distinct() if intervention_managers: qs = qs.filter(intervention_manager_id__in=intervention_managers) if followed_only == '1' and request.user.is_authenticated: qs = qs.filter(subscriptions__user=request.user) # --- annotate preparation status qs = annotate_preparation_status(qs) if preparation_states: qs = qs.filter(prep_state__in=preparation_states) if search_q: words = search_q.split() qs = qs.annotate( _prego_str=Cast('origin_prego_nbr', output_field=CharField()), _fms_str=Cast('origin_fms_nbr', output_field=CharField()), ) def _word_q(word): return ( Q(title__icontains=word) | Q(code__icontains=word) | Q(location_code__icontains=word) | Q(contract__contract_number__icontains=word) | Q(_prego_str__icontains=word) | Q(_fms_str__icontains=word) ) combined = Q() for w in words: combined &= _word_q(w) qs = qs.filter(combined) # --- annotate once (DB does geometry + dates) if date_type == 'planned': effective_begin = Coalesce('begin_time', 'planned_begin_time', output_field=DateTimeField()) effective_end = Coalesce('end_time', 'planned_end_time', output_field=DateTimeField()) else: effective_begin = Coalesce('begin_time', 'planned_begin_time', 'expected_begin_time', output_field=DateTimeField()) effective_end = Coalesce('end_time', 'planned_end_time', 'expected_end_time', output_field=DateTimeField()) qs = qs.annotate( geom_geojson=AsGeoJSON(Transform(Centroid('geom'), 4326)), effective_begin=effective_begin, effective_end=effective_end, # 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(), # ), origin_type_label=Case( When(origin_type='damage', then=Value('Avarie')), When(origin_type='defect', then=Value('Défaut/Panne')), When(origin_type='demand', then=Value('Demande')), default=Value(''), output_field=CharField(), ) ) # --- date filters (convert date strings to aware datetimes) if begin_date_min: # Convert YYYY-MM-DD string to aware datetime at start of day begin_datetime = datetime.strptime(begin_date_min, '%Y-%m-%d') begin_datetime = make_aware(begin_datetime, get_current_timezone()) qs = qs.filter(effective_begin__gte=begin_datetime) if begin_date_max: # Convert YYYY-MM-DD string to aware datetime at end of day end_of_day = datetime.strptime(begin_date_max, '%Y-%m-%d') end_of_day = end_of_day.replace(hour=23, minute=59, second=59, microsecond=999999) end_of_day = make_aware(end_of_day, get_current_timezone()) qs = qs.filter(effective_begin__lte=end_of_day) if end_date_min: # Convert YYYY-MM-DD string to aware datetime at start of day end_datetime = datetime.strptime(end_date_min, '%Y-%m-%d') end_datetime = make_aware(end_datetime, get_current_timezone()) qs = qs.filter(effective_end__gte=end_datetime) if end_date_max: # Convert YYYY-MM-DD string to aware datetime at end of day end_of_day = datetime.strptime(end_date_max, '%Y-%m-%d') end_of_day = end_of_day.replace(hour=23, minute=59, second=59, microsecond=999999) end_of_day = make_aware(end_of_day, get_current_timezone()) qs = qs.filter(effective_end__lte=end_of_day) # --- overdue filter (critère élargi) : # planifiées en retard + démarrées >7j + assignées >7j (en pause exclu) overdue = request.GET.get('overdue') if overdue == '1': _now_ov = django_timezone.now() _1_day_ago_ov = _now_ov - timedelta(days=1) _7d_ov = _now_ov - timedelta(days=7) _latest_in_progress_sq = InterventionTimeLine.objects.filter( intervention=OuterRef('pk'), to_status='in_progress', ).order_by('-event_time').values('event_time')[:1] _assigned_sq = InterventionTimeLine.objects.filter( intervention=OuterRef('pk'), to_status='assigned', ).order_by('-event_time').values('event_time')[:1] _eff_begin_nt = Coalesce('planned_begin_time', 'expected_begin_time', output_field=DateTimeField()) qs = qs.annotate( _nt_eff_begin=_eff_begin_nt, _nt_last_in_progress=Coalesce(Subquery(_latest_in_progress_sq), 'begin_time', output_field=DateTimeField()), _nt_last_assigned=Coalesce(Subquery(_assigned_sq), 'transmission_time', 'creation_time', output_field=DateTimeField()), ).filter( Q(status__in=['to_be_processed', 'assigned'], _nt_eff_begin__isnull=False, _nt_eff_begin__lt=_1_day_ago_ov) | Q(status='in_progress', _nt_last_in_progress__isnull=False, _nt_last_in_progress__lt=_7d_ov) | Q(status='assigned', _nt_last_assigned__isnull=False, _nt_last_assigned__lt=_7d_ov) ) # --- to_be_planned_soon filter: interventions with expected_begin_time null or < today + 3 months to_be_planned_soon = request.GET.get('to_be_planned_soon') if to_be_planned_soon == '1': three_months_from_now = timezone.now() + timedelta(days=90) qs = qs.filter( Q(expected_begin_time__isnull=True) | Q(expected_begin_time__lt=three_months_from_now) ) # --- is_checked filter: filter by verification status is_checked_filter = request.GET.get('is_checked') if is_checked_filter == 'true': qs = qs.filter(is_checked=True) elif is_checked_filter == 'false': qs = qs.filter(is_checked=False) # Quand is_checked est présent, restreindre aux contrats sur lesquels # l'utilisateur a explicitement le droit de vérifier les interventions. # Cela exclut les interventions sans contrat et celles des contrats non autorisés. if is_checked_filter is not None: _uconfig = UserConfig.objects.filter(user=request.user).first() if _uconfig: _check_contract_ids = UserContractAccess.objects.filter( user_config=_uconfig, can_check_interventions=True, ).values_list('contract_id', flat=True) qs = qs.filter(contract_id__in=_check_contract_ids) else: qs = qs.none() # --- full-text search search_q = (request.GET.get('q') or '').strip() search_mode = (request.GET.get('search_mode') or 'and').lower() if search_mode not in ('and', 'or'): search_mode = 'and' if search_q: words = search_q.split() qs = qs.annotate( _prego_str=Cast('origin_prego_nbr', output_field=CharField()), _fms_str=Cast('origin_fms_nbr', output_field=CharField()), ) def _word_q(word): return ( Q(title__icontains=word) | Q(code__icontains=word) | Q(location_code__icontains=word) | Q(contract__contract_number__icontains=word) | Q(_prego_str__icontains=word) | Q(_fms_str__icontains=word) ) combined = Q() if search_mode == 'and': for _w in words: combined &= _word_q(_w) else: for _w in words: combined |= _word_q(_w) qs = qs.filter(combined) # --- ordering + limit in SQL # Get the current language for localized field selection lang = get_language() source_category_field = f'source_category__name_{"nl" if lang == "nl" else "fr"}' # qs = qs.order_by('custom_priority', F('effective_begin').desc(nulls_last=True), '-id')\ qs = qs.order_by('-code')\ .select_related('assigned_provider', 'contract', 'contract__company', 'assigned_team')\ .values( 'id', 'code', 'title', 'location_code', 'status', 'maintain_type', 'origin_type_label', 'prep_state', 'effective_begin', 'geom_geojson', 'assigned_provider__name', 'contract__contract_number', 'contract__company__name', 'assigned_team__name', source_category_field, )[:MAX_FEATURES] # --- build features from dict rows; only json.loads on one string field features = [] for r in qs: geometry = json.loads(r['geom_geojson']) if r['geom_geojson'] else None features.append({ "type": "Feature", "id": r['id'], "geometry": geometry, "properties": { "id": r['id'], "code": r['code'], "title": r['title'], "location_code": r['location_code'], "begin_time": r['effective_begin'], "status": r['status'], # If you need the display label, either map it here or annotate a Case() for it # "status_display": r['status'], "maintain_type": r['maintain_type'], "prep_state": r['prep_state'], "assigned_provider": r['assigned_provider__name'], "contract_number": r['contract__contract_number'], "contract_provider": r['contract__company__name'], "origin_type": r['origin_type_label'], "source_category": r[source_category_field] if r[source_category_field] else '', "team": r['assigned_team__name'] or '', }, }) return JsonResponse({"type": "FeatureCollection", "features": features}, safe=False, json_dumps_params={'separators': (',', ':')}) def available_orders(request): """Retourne la liste des commandes (ContractOrder) accessibles à l'utilisateur pour association de masse: statut 'pending' ou 'sent'. Format JSON: [{id, code, label, status, contract_id, contract_number}] """ if not request.user.is_authenticated: return JsonResponse({'orders': []}) allowed_contracts = get_allowed_contracts_for_user(request.user) if not allowed_contracts.exists(): return JsonResponse({'orders': []}) # Limiter aux statuts souhaités qs = (ContractOrder.objects .select_related('contract') .filter(contract__in=allowed_contracts, order_status__in=['pending', 'sent'])) term = request.GET.get('q') if term: qs = qs.filter(Q(order_code__icontains=term) | Q(description__icontains=term)) data = [] for o in qs[:200]: # sécurité: limite raisonnable data.append({ 'id': o.id, 'order_code': o.order_code, 'label': f"{o.order_code} ({o.contract.contract_number})", 'status': o.order_status, 'status_display': o.get_order_status_display(), 'contract_id': o.contract_id, 'contract_number': o.contract.contract_number, }) return JsonResponse({'orders': data}) def interventions_search(request): """API de recherche full-text sur les interventions, indépendant du tableau. Paramètres GET: q : mots-clés séparés par espaces mode : 'and' (défaut) ou 'or' Retourne jusqu'à 50 résultats : id, code, title, location_code, status, contract_number, origin_prego_nbr, origin_fms_nbr. """ if not request.user.is_authenticated: return JsonResponse({'results': []}, status=403) q = (request.GET.get('q') or '').strip() mode = request.GET.get('mode', 'and').lower() if mode not in ('and', 'or'): mode = 'and' if not q: return JsonResponse({'results': []}) words = q.split() base_qs = ( filter_viewable_interventions_for_user(request.user) .select_related('contract', 'contract__company') .annotate( prego_str=Cast('origin_prego_nbr', output_field=CharField()), fms_str=Cast('origin_fms_nbr', output_field=CharField()), ) ) def word_filter(word): return ( Q(title__icontains=word) | Q(code__icontains=word) | Q(location_code__icontains=word) | Q(contract__contract_number__icontains=word) | Q(prego_str__icontains=word) | Q(fms_str__icontains=word) ) combined = Q() if mode == 'and': for word in words: combined &= word_filter(word) else: for word in words: combined |= word_filter(word) qs = base_qs.filter(combined).order_by('-id')[:50] results = [] for itv in qs: results.append({ 'id': itv.id, 'code': itv.code or '', 'title': itv.title or '', 'location_code': itv.location_code or '', 'status': itv.status or '', 'contract_number': itv.contract.contract_number if itv.contract else '', 'origin_prego_nbr': itv.origin_prego_nbr, 'origin_fms_nbr': itv.origin_fms_nbr, }) return JsonResponse({'results': results}) def bulk_associate_order(request): """Associe un ensemble d'interventions (ids JSON) à une commande donnée. Body JSON: { order_id: , ids: [...] } Règles: - L'utilisateur doit avoir accès à la commande. - La commande doit être au statut 'pending' ou 'sent'. - Met à jour intervention.order et intervention.contract si nécessaire. Retour: {updated: n, invalid_ids: [...], message: str} """ if not request.user.is_authenticated: return JsonResponse({'message': 'Authentification requise.'}, status=403) try: payload = json.loads(request.body or '{}') except json.JSONDecodeError: return JsonResponse({'message': 'Payload JSON invalide.'}, status=400) order_id = payload.get('order_id') ids = payload.get('ids') or [] if not order_id or not isinstance(ids, list) or not ids: return JsonResponse({'message': 'Paramètres manquants.'}, status=400) # Vérifie l'accès à la commande if not user_has_access_to_contract_order(request.user, order_id): return JsonResponse({'message': "Accès à la commande refusé."}, status=403) try: order = ContractOrder.objects.select_related('contract').get(pk=order_id) except ContractOrder.DoesNotExist: return JsonResponse({'message': 'Commande inexistante.'}, status=404) if order.order_status not in ['pending', 'sent']: return JsonResponse({'message': "La commande n'est pas dans un statut autorisant l'association."}, status=400) # Récupère interventions visibles par l'utilisateur (pour éviter modifications non autorisées) allowed_interventions = filter_viewable_interventions_for_user(request.user) interventions_qs = Intervention.objects.filter(id__in=ids) updated = 0 invalid_ids = [] with transaction.atomic(): for itv in interventions_qs.select_for_update(): if not allowed_interventions.filter(pk=itv.pk).exists(): invalid_ids.append(itv.id) continue # Vérifie permission d'édition pour la commande if not 'order' in get_allowed_update_fields(request.user, itv): invalid_ids.append(itv.id) continue # Applique la commande itv.order = order # Si pas de contrat ou différent → aligne sur celui de la commande if itv.contract_id != order.contract_id: itv.contract_id = order.contract_id itv.save(update_fields=['order', 'contract']) updated += 1 msg = ngettext( "%(count)s intervention associée à la commande.", "%(count)s interventions associées à la commande.", updated ) % {'count': updated} if invalid_ids: msg += f" {len(invalid_ids)} intervention(s) ignorée(s) (droits ou accès)." return JsonResponse({ 'updated': updated, 'invalid_ids': invalid_ids, 'order_id': order.id, 'contract_id': order.contract_id, 'message': msg, }) def export_interventions(request): """Export selected interventions as CSV or XLSX. POST params: - ids: JSON string of intervention IDs (e.g., "[1,2,3]") - format: 'csv' (default) or 'xlsx' Only interventions viewable by the user are exported. """ ids_param = request.POST.get('ids', '[]') fmt = (request.POST.get('format') or 'csv').lower() try: ids = json.loads(ids_param) if not isinstance(ids, list): raise ValueError ids = [int(x) for x in ids] except (ValueError, TypeError, json.JSONDecodeError): return HttpResponseBadRequest(_("Paramètre 'ids' invalide.")) if not ids: return HttpResponseBadRequest(_("Aucune intervention sélectionnée.")) qs = (filter_viewable_interventions_for_user(request.user) .filter(id__in=ids) .select_related('assigned_provider', 'contract', 'order', 'thematic')) # Define columns to export: (header, value_getter) columns: list[tuple[str, callable]] = [ (_("ID"), lambda i: i.id), (_("Code"), lambda i: i.code or ''), (_("Titre"), lambda i: i.title or ''), (_("Statut"), lambda i: getattr(i, 'get_status_display', lambda: i.status)() or i.status or ''), (_("Thématique"), lambda i: (i.thematic.name if i.thematic else '')), (_("Catégorie d'actif"), lambda i: (i.asset_category.get_name() if i.asset_category else '')), (_("Symptôme"), lambda i: (i.symptom.get_name() if i.symptom else '')), (_("Priorité"), lambda i: getattr(i, 'get_priority_display', lambda: i.priority)() or i.priority or ''), (_("Type de maintenance"), lambda i: getattr(i, 'get_maintain_type_display', lambda: i.maintain_type)() or i.maintain_type or ''), (_("Type d'intervention"), lambda i: getattr(i, 'get_type_display', lambda: i.type)() or (i.type or '')), (_("Type d'origine"), lambda i: getattr(i, 'get_origin_type_display', lambda: i.origin_type)() or i.origin_type or ''), (_("Catégorie source"), lambda i: (i.source_category.getName() if i.source_category else '')), (_("Détail source"), lambda i: i.source_detail or ''), (_("Référence source"), lambda i: i.source_ref or ''), (_("Numéro Prego"), lambda i: i.origin_prego_nbr or ''), (_("Numéro FMS"), lambda i: i.origin_fms_nbr or ''), (_("Fournisseur assigné"), lambda i: (i.assigned_provider.name if i.assigned_provider else '')), (_("Equipe assignée"), lambda i: (i.assigned_team.name if i.assigned_team else '')), (_("Membre assigné"), lambda i: (i.assigned_member.name if i.assigned_member else '')), (_("Contrat"), lambda i: (i.contract.contract_number if i.contract else '')), (_("Commande"), lambda i: (i.order.order_code if i.order else '')), (_("Début prévu"), lambda i: dj_format_date(i.expected_begin_time, 'Y-m-d H:i') if i.expected_begin_time else ''), (_("Fin prévue"), lambda i: dj_format_date(i.expected_end_time, 'Y-m-d H:i') if i.expected_end_time else ''), (_("Début planifié"), lambda i: dj_format_date(i.planned_begin_time, 'Y-m-d H:i') if i.planned_begin_time else ''), (_("Fin planifiée"), lambda i: dj_format_date(i.planned_end_time, 'Y-m-d H:i') if i.planned_end_time else ''), (_("Début"), lambda i: dj_format_date(i.begin_time, 'Y-m-d H:i') if i.begin_time else ''), (_("Fin"), lambda i: dj_format_date(i.end_time, 'Y-m-d H:i') if i.end_time else ''), (_("Création"), lambda i: dj_format_date(i.creation_time, 'Y-m-d H:i') if i.creation_time else ''), (_("Créé par"), lambda i: (i.created_by.get_full_name() if i.created_by else '')), (_("Localisation"), lambda i: i.location_code or ''), (_("Adresse"), lambda i: i.address or ''), (_("Longitude"), lambda i: i.lon if i.lon is not None else ''), (_("Latitude"), lambda i: i.lat if i.lat is not None else ''), ] user_config = getattr(request.user, 'config', None) # If user has role 'operator', include name of files attached to intervention by the creator of the intervention if user_config and user_config.roles.filter(name='operator').exists(): def get_attached_files_names(intervention): files_qs = InterventionDocument.objects.filter(intervention=intervention, uploaded_by=intervention.created_by) return ', '.join(os.path.basename(f.file.name) for f in files_qs) columns.append((_('Fichiers attachés (par créateur)'), get_attached_files_names)) # Add notes column in simplified format def get_notes_formatted(intervention): import re import html notes = intervention.notes.select_related('note_author').order_by('note_time') notes_list = [] for note in notes: # Unescape HTML entities and remove HTML tags from content unescaped_content = html.unescape(note.content) clean_content = re.sub(r'<[^>]+>', '', unescaped_content).strip() # Format: {type_display | author | date | content} note_str = "{{{type_display} | {author} | {date} | {content}}}".format( type_display=note.get_note_type_display(), author=get_short_name(note.note_author) if note.note_author else '', date=dj_format_date(note.note_time, 'Y-m-d H:i') if note.note_time else '', content=clean_content, ) notes_list.append(note_str) return ', '.join(notes_list) columns.append((_('Notes'), get_notes_formatted)) items = list(qs) timestamp = localtime(now()).strftime('%Y%m%d_%H%M%S') if fmt == 'xlsx': try: from openpyxl import Workbook except Exception: return HttpResponseBadRequest(_("Export XLSX indisponible (dépendance manquante).")) _illegal_chars_re = re.compile(r'[\x00-\x08\x0b\x0c\x0e-\x1f]') def _sanitize_xlsx(value): if isinstance(value, str): return _illegal_chars_re.sub('', value) return value wb = Workbook() ws = wb.active ws.title = 'Interventions' # headers ws.append([h for h, _ in columns]) # rows for i in items: ws.append([_sanitize_xlsx(getter(i)) for _, getter in columns]) output = BytesIO() wb.save(output) output.seek(0) filename = f"interventions_{timestamp}.xlsx" resp = HttpResponse(output.read(), content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') resp['Content-Disposition'] = f'attachment; filename="{filename}"' return resp # default CSV filename = f"interventions_{timestamp}.csv" resp = HttpResponse(content_type='text/csv; charset=utf-8') resp['Content-Disposition'] = f'attachment; filename="{filename}"' # Use semicolon separator for better Excel support in EU locales writer = csv.writer(resp, delimiter=';') writer.writerow([h for h, _ in columns]) for i in items: row = [getter(i) for _, getter in columns] writer.writerow(row) return resp def export_intervention_sheets_zip(request): """Exporte les fiches d'intervention (PDF) des interventions sélectionnées dans un fichier ZIP. Réservé aux administrateurs. POST params: - ids: JSON string de la liste des IDs d'intervention (ex: "[1,2,3]") """ user_config = UserConfig.objects.filter(user=request.user).first() if not user_config or not user_config.roles.filter(name='admin').exists(): return HttpResponseForbidden(_("Cette action est réservée aux administrateurs.")) ids_param = request.POST.get('ids', '[]') try: ids = json.loads(ids_param) if not isinstance(ids, list): raise ValueError ids = [int(x) for x in ids] except (ValueError, TypeError, json.JSONDecodeError): return HttpResponseBadRequest(_("Paramètre 'ids' invalide.")) if not ids: return HttpResponseBadRequest(_("Aucune intervention sélectionnée.")) interventions = ( filter_viewable_interventions_for_user(request.user) .filter(id__in=ids) .select_related('thematic') ) zip_buffer = io.BytesIO() with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf: for intervention in interventions: pdf_buffer = io.BytesIO() try: generate_intervention_pdf(intervention, pdf_buffer) except Exception: logger.exception("Erreur génération PDF pour intervention %s", intervention.id) continue pdf_buffer.seek(0) filename = f"fiche_I{intervention.code or intervention.id}.pdf" zf.writestr(filename, pdf_buffer.read()) zip_buffer.seek(0) timestamp = localtime(now()).strftime('%Y%m%d_%H%M%S') zip_filename = f"fiches_interventions_{timestamp}.zip" response = HttpResponse(zip_buffer.read(), content_type='application/zip') response['Content-Disposition'] = f'attachment; filename="{zip_filename}"' return response def ajax_get_categories_by_thematic(request): """Get categories filtered by thematic for OperationTemplate admin.""" thematic_id = request.GET.get('thematic_id') if not thematic_id: return JsonResponse({'categories': []}) try: from assets.models import AssetCategory categories = AssetCategory.objects.filter( thematic_id=thematic_id ).order_by('name_fr') categories_data = [ { 'id': cat.id, 'name': cat.get_name(), 'parent_id': cat.parent_id, } for cat in categories ] return JsonResponse({'categories': categories_data}) except Exception as e: return JsonResponse({'error': str(e)}, status=400) def ajax_get_subcategories(request): """AJAX endpoint to get subcategories (children) of a given category. Used by intervention add/edit forms to populate the sub-category select. """ category_id = request.GET.get('category_id') if not category_id: return JsonResponse({'subcategories': []}) try: category = AssetCategory.objects.get(id=category_id) lang = get_language() order_field = 'name_nl' if lang == 'nl' else 'name_fr' children = category.get_children().order_by(order_field) subcategory_list = [ { 'id': child.id, 'name': child.get_name(), 'code': child.code, } for child in children ] return JsonResponse({'subcategories': subcategory_list}) except AssetCategory.DoesNotExist: return JsonResponse({'subcategories': []}) def ajax_get_asset_models(request): """Get asset models filtered by thematic and/or categories for OperationTemplate admin.""" from django.contrib.contenttypes.models import ContentType from django.apps import apps from assets.models.core import AbstractAssetModel thematic_id = request.GET.get('thematic_id') category_ids_str = request.GET.get('category_ids', '') category_ids = [int(c) for c in category_ids_str.split(',') if c.strip().isdigit()] if not thematic_id: return JsonResponse({'models': []}) try: models_data = [] for model in apps.get_models(): if model._meta.abstract: continue try: if issubclass(model, AbstractAssetModel) and model != AbstractAssetModel: ct = ContentType.objects.get_for_model(model) # Filter instances by categories first (most specific), then by thematic if category_ids: instances = model.objects.filter( category_id__in=category_ids ).order_by('code') else: instances = model.objects.filter( category__thematic_id=thematic_id ).order_by('code') for instance in instances: # Value format: "content_type_id:object_id" value = f"{ct.id}:{instance.pk}" # Display with category info category_name = instance.category.get_name() if instance.category else '' display = f"{instance} ({category_name})" if category_name else str(instance) models_data.append({ 'value': value, 'display': display }) except (TypeError, Exception): continue return JsonResponse({'models': models_data}) except Exception as e: return JsonResponse({'error': str(e)}, status=400) def ajax_get_replacement_models_by_category(request): """Get asset models for replacement filtered by category for mobile interface.""" from django.contrib.contenttypes.models import ContentType asset_model = request.GET.get('asset_model') asset_id = request.GET.get('asset_id') category_id = request.GET.get('category_id') if not asset_model or not asset_id: return JsonResponse({'error': 'Missing asset_model or asset_id'}, status=400) try: # Get the asset ContentType ct = ContentType.objects.get(model=asset_model) model_class = ct.model_class() # Get the asset instance asset_obj = model_class.objects.get(pk=asset_id) # Build model options filtered by category category_id_int = int(category_id) if category_id and category_id != '' else None models = _build_replacement_model_options(asset_obj, category_id_int) return JsonResponse({'models': models}) except ContentType.DoesNotExist: return JsonResponse({'error': 'Asset type not found'}, status=404) except model_class.DoesNotExist: return JsonResponse({'error': 'Asset not found'}, status=404) except Exception as e: return JsonResponse({'error': str(e)}, status=400)