from django.http import HttpResponse, JsonResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseRedirect from django.shortcuts import render, get_object_or_404, redirect from django.template.loader import render_to_string from django.urls import reverse, NoReverseMatch from django.core.serializers import serialize from django.core.exceptions import FieldDoesNotExist from django.core.files.base import ContentFile from django.contrib.gis.db.models.functions import Transform, Centroid, AsGeoJSON, Distance from django.contrib.gis.geos import GEOSGeometry, Point, MultiPolygon from django.contrib.gis.measure import D from django.contrib.contenttypes.models import ContentType from django.contrib import messages from django.views.decorators.http import require_POST, require_GET, require_http_methods from django.contrib.auth.decorators import login_required from django.utils.timezone import now, localtime, make_aware, get_current_timezone, is_naive from django.utils import timezone as django_timezone from django.utils import timezone from django.utils.dateparse import parse_datetime from django.utils.dateformat import format as dj_format_date from django.utils.text import Truncator from django.utils.html import strip_tags from django.template.defaultfilters import linebreaksbr from django.utils.translation import get_language, gettext as _, gettext_lazy as _lazy, ngettext from django.apps import apps from django.conf import settings from django.core.mail import EmailMultiAlternatives from django.db.models import F, DateTimeField, Q, Case, When, IntegerField, CharField, Value, Count, Prefetch, Sum, Max, QuerySet, OuterRef, Subquery, Exists from django.db.models.functions import Coalesce, Lower, Cast from django.db.models.expressions import ExpressionWrapper from django.db.models import DurationField from django.db.models.fields.related import ForeignKey from django.db import transaction from collections import defaultdict, OrderedDict from decimal import Decimal, InvalidOperation import json import os import io import zipfile from fpdf import FPDF import pytz from urllib.parse import urlencode import re from html import unescape from typing import List, Iterable, Tuple, Dict, Optional, Set import mimetypes import csv from io import BytesIO from datetime import timedelta, datetime from dateutil.relativedelta import relativedelta from common.private_files.helpers import build_private_url from common.utils import get_short_name from interventions.models import (InterventionSubscription, Intervention, InterventionAsset, InterventionTimeLine, InterventionContractPost, InterventionDocument, InterventionNote, InterventionLocation, InterventionLink, ContractPauseReasonAssignment, Symptom, SourceCategory, InterventionPlanificationTimeLine, InterventionRealizationTimeLine, InterventionOccupation, Operation, OperationTemplate, OperationTemplateItem, OperationStatus, OperationStatusSet, MeasurementDefinition, GuidedOperationTemplate, GuidedOperationData, GuidedOperationMedia, InterventionPostPeriodQuantity, InterventionEmailLog, InterventionAssignment) from interventions.models import STATUS_CHOICES, STATUS_ORDERS, MAINTAIN_CHOICES, TYPE_CHOICES, ORIGIN_TYPE_CHOICES, PAUSE_REASON_CHOICES from interventions.views.forms import (InterventionForm, InterventionNoteForm, InterventionDocumentForm, InterventionOccupationForm, PreventiveInterventionForm, AssetsLinkForm, LinkExistingInterventionsForm) from interventions.permissions import (intervention_permission_required, intervention_add_permission_required, intervention_edit_permission_required, intervention_add_preventive_permission_required, get_allowed_statuses_for_user, get_allowed_update_fields, can_view_intervention, can_control_intervention, can_edit_intervention, can_view_intervention_summary, get_accessible_interventions_for_technician, filter_viewable_interventions_for_user, can_edit_note, can_delete_document, can_manage_occupations, can_add_intervention, can_add_repair_intervention, intervention_manage_occupations_required, get_contracts_for_intervention_creation, is_external_manager_with_contract_creation_rights, should_show_created_by_provider_badge, can_view_correction_messages) from interventions.permissions import ALLOWED_TRANSITIONS, get_allowed_transitions, user_allowed_transitions from interventions.views.drafts import InterventionDraftManager from interventions.utils import resolve_intervention_localisations from common.models import UserConfig, UserContractAccess, UserContractStatusPermission, Thematic, UserThematics from contracts.models import ContractOrder, ContractPost, Contract, Company, CompanyTeam, CompanyMember, UserCompanyTeamAccess, CompanyMemberHistory from contracts.permissions import get_allowed_contracts_for_user, user_has_access_to_contract_order from projects.models import Project, ProjectIntervention from assets.models import ( PublicLightingStreet, TrafficLightIntersection, TrafficLightContract, NatureLocation, StructureLocation, Structure, StructureContract, RoadStreet, SignStreet, AssetCategory, AbstractAsset, AbstractAssetModel, ITSGeoAsset, ITSGeoAssetContract, CleanLocation, CleanLocationContract, ) from assets.permissions import get_allowed_actions_for_asset from assets.views.locations import LOCATION_REGISTRY, registry_items_for_thematics, base_search_qs, SEARCH_LIMIT_PER_TYPE, SEARCH_LIMIT_TOTAL from common.utils import redirect_with_variant, measure_time, strip_accents_lower from observations.permissions import get_observation_access_context from observations.models import Observation as ObservationModel from controls.permissions import user_can_view_control from interventions.views.views_pdf_reporting import ( generate_intervention_pdf, preview_intervention_pdf, download_intervention_report_pdf, preview_intervention_report_pdf, bulk_execution_report_pdf, start_bulk_execution_report, poll_bulk_execution_report, download_bulk_execution_report, ) import logging import smtplib import socket import ssl logger = logging.getLogger(__name__) from interventions.views.helpers import * def interventions_calendar(request): lang = get_language() 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.")) selected_thematics = request.GET.getlist('thematic') selected_statuses = request.GET.getlist('status') selected_priorities = request.GET.getlist('priority') selected_categories = [int(v) for v in request.GET.getlist('asset_category') if v.isdigit()] selected_maintain_types = request.GET.getlist('maintain_type') selected_contracts = [int(v) for v in request.GET.getlist('contract') if v.isdigit()] selected_preparation_states = request.GET.getlist('preparation_state') selected_bbox = request.GET.get('bbox', '') selected_poly = request.GET.get('poly', '') selected_view_mode = request.GET.get('view_mode', 'timeline') selected_search = request.GET.get('q', '') role_names = _get_role_names(user_config) can_use_member_view = _can_use_member_view(role_names) if selected_view_mode in ('members', 'teams_daily') and not can_use_member_view: selected_view_mode = 'timeline' can_move_interventions = _can_move_interventions_in_calendar(role_names, selected_view_mode) is_manager = 'manager' in role_names and not bool(role_names & {'admin', 'controller', 'external_manager'}) 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 = [ 'in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed', 'assigned', 'in_progress', 'on_pause', 'finished', 'processed', 'to_be_corrected', 'corrected', 'validated', 'invoiced' ] if not request.GET: saved_preferences = user_config.calendar_preferences or {} saved_query_string = saved_preferences.get('query_string') if saved_query_string and saved_query_string.startswith('?'): return HttpResponseRedirect(f"{request.path}{saved_query_string}") query_params = request.GET.copy() if not selected_thematics and default_thematic: query_params.setlist('thematic', default_thematic) if not selected_statuses: query_params.setlist('status', default_statuses) return HttpResponseRedirect(f"{request.path}?{query_params.urlencode()}") else: current_query_string = f"?{request.GET.urlencode()}" prefs = user_config.calendar_preferences or {} if prefs.get('query_string') != current_query_string: prefs['query_string'] = current_query_string user_config.calendar_preferences = prefs user_config.save(update_fields=['calendar_preferences']) # Catégories : filtrées par les thématiques autorisées de l'utilisateur allowed_thematic_ids = [ tc.thematic_id for tc in thematic_choices_qs ] allowed_thematic_codes = set( tc.thematic.code for tc in thematic_choices_qs ) asset_category_qs = ( AssetCategory.objects .filter(thematic_id__in=allowed_thematic_ids) .select_related('thematic') .order_by(f'{"name_nl" if lang == "nl" else "name_fr"}') ) # Contrats : filtrés par les thématiques autorisées contracts_qs = ( Contract.objects .filter(thematics__id__in=allowed_thematic_ids) .prefetch_related('thematics') .distinct() .order_by('contract_number') ) # Choix enrichis avec les codes de thématique (pour filtrage dynamique JS) asset_category_choices = [ (cat.id, cat.name_nl if lang == 'nl' else cat.name_fr, cat.thematic.code) for cat in asset_category_qs ] contract_choices = [ (c.id, c.contract_number, [t.code for t in c.thematics.all() if t.code in allowed_thematic_codes]) for c in contracts_qs ] 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', } maintain_type_classes = { 'corrective': 'danger', 'preventive': 'warning', 'ameliorative': 'success', } status_choices_with_classes = [(key, value, status_classes.get(key, 'secondary')) for key, value in Intervention._meta.get_field('status').choices] maintain_type_choices_with_classes = [(key, value, maintain_type_classes.get(key, 'secondary')) for key, value in MAINTAIN_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, 'selected_thematics': selected_thematics, 'selected_statuses': selected_statuses, 'selected_priorities': selected_priorities, 'selected_view_mode': selected_view_mode, 'selected_search': selected_search, 'can_use_member_view': can_use_member_view, 'can_move_interventions': can_move_interventions, 'is_manager': is_manager, 'asset_category_choices': asset_category_choices, 'selected_categories': selected_categories, 'maintain_type_choices': maintain_type_choices_with_classes, 'selected_maintain_types': selected_maintain_types, 'contract_choices': contract_choices, 'selected_contracts': selected_contracts, 'selected_preparation_states': selected_preparation_states, 'selected_bbox': selected_bbox, 'selected_poly': selected_poly, 'calendar_preferences': user_config.calendar_preferences or {}, } return render(request, "interventions/interventions_calendar.html", context) def interventions_events(request): # permissions interventions = filter_viewable_interventions_for_user(request.user) selected_thematics = request.GET.getlist('thematic') selected_statuses = request.GET.getlist('status') selected_priorities = request.GET.getlist('priority') selected_categories = [int(v) for v in request.GET.getlist('asset_category') if v.isdigit()] selected_maintain_types = request.GET.getlist('maintain_type') selected_contracts = [int(v) for v in request.GET.getlist('contract') if v.isdigit()] selected_preparation_states = [v.strip() for v in request.GET.getlist('preparation_state') if v.strip()] selected_bbox = request.GET.get('bbox', '') selected_poly = request.GET.get('poly', '') selected_search = (request.GET.get('q') or '').strip() view_mode = request.GET.get('view_mode', 'timeline') # 'timeline', 'members', or 'teams_daily' if view_mode not in ('timeline', 'members', 'teams_daily'): view_mode = 'timeline' user_config = ( UserConfig.objects .select_related('user') .prefetch_related('roles') .filter(user=request.user) .first() ) role_names = _get_role_names(user_config) can_use_member_view = _can_use_member_view(role_names) if view_mode in ('members', 'teams_daily') and not can_use_member_view: view_mode = 'timeline' allowed_company_ids = None if view_mode in ('members', 'teams_daily'): allowed_company_ids = _member_view_allowed_company_ids(user_config, role_names) allowed_thematic_ids = _member_view_allowed_thematic_ids(user_config, role_names) allowed_contract_ids = _member_view_allowed_contract_ids(user_config, role_names) allowed_team_ids = _member_view_allowed_team_ids(user_config, role_names) # Priorité : date de réalisation > date de planification > date prévue > date de création effective_begin = Case( When(begin_time__isnull=False, then=F('begin_time')), When(planned_begin_time__isnull=False, then=F('planned_begin_time')), When(expected_begin_time__isnull=False, then=F('expected_begin_time')), default=F('creation_time'), output_field=DateTimeField(), ) minimum_duration = ExpressionWrapper(Value(timedelta(hours=2)), output_field=DurationField()) raw_effective_end = Case( When(end_time__isnull=False, then=F('end_time')), When(planned_end_time__isnull=False, then=F('planned_end_time')), When(expected_end_time__isnull=False, then=F('expected_end_time')), default=F('creation_time'), output_field=DateTimeField(), ) effective_end = Case( When( raw_effective_end__lt=F('effective_begin') + minimum_duration, then=F('effective_begin') + minimum_duration, ), default=raw_effective_end, output_field=DateTimeField(), ) # borne temporelle: 3 mois avant/après aujourd'hui (fallback ≈92j si relativedelta absent) now = timezone.now() if relativedelta: start = now - relativedelta(months=3) end = now + relativedelta(months=3) else: start = now - timedelta(days=92) end = now + timedelta(days=92) interventions = interventions.annotate( effective_begin=effective_begin, raw_effective_end=raw_effective_end, ).annotate(effective_end=effective_end) end_window_filter = Q(effective_end__gte=start) | Q(effective_end__isnull=True) if view_mode == 'members': interventions = ( interventions .filter(end_window_filter) .filter(Q(effective_begin__lte=end) | Q(effective_begin__isnull=True)) ) else: interventions = ( interventions .exclude(effective_begin__isnull=True) .filter(end_window_filter) .filter(effective_begin__lte=end) ) interventions = ( interventions .select_related('assigned_member') .prefetch_related('assigned_member__teams', 'assigned_member__teams__company') .order_by('effective_begin') # croissant: plus proche d'aujourd'hui au milieu/vers le bas ) if selected_thematics: interventions = interventions.filter(thematic__code__in=selected_thematics) if selected_statuses: interventions = interventions.filter(status__in=selected_statuses) if selected_priorities: interventions = interventions.filter(priority__in=selected_priorities) if selected_categories: interventions = interventions.filter(asset_category_id__in=selected_categories) if selected_maintain_types: interventions = interventions.filter(maintain_type__in=selected_maintain_types) if selected_contracts: interventions = interventions.filter(contract_id__in=selected_contracts) # --- Annotate and filter by preparation status interventions = annotate_preparation_status(interventions) if selected_preparation_states: interventions = interventions.filter(prep_state__in=selected_preparation_states) if selected_search: words = selected_search.split() from common.hooks import hooks interventions = hooks.apply_search_annotations(interventions) def _word_q(word): q_base = ( Q(title__icontains=word) | Q(code__icontains=word) | Q(location_code__icontains=word) | Q(contract__contract_number__icontains=word) ) q_base |= hooks.get_search_filters(word) return q_base combined = Q() for w in words: combined &= _word_q(w) interventions = interventions.filter(combined) # --- Filtres géographiques (bbox ou polygone) --- if selected_bbox: try: parts = [float(v) for v in selected_bbox.split(',')] if len(parts) == 4: minlng, minlat, maxlng, maxlat = parts interventions = interventions.filter( lon__isnull=False, lat__isnull=False, lon__gte=minlng, lon__lte=maxlng, lat__gte=minlat, lat__lte=maxlat, ) except (ValueError, TypeError): pass elif selected_poly: try: from django.contrib.gis.geos import GEOSGeometry, Point as GEOSPoint poly = GEOSGeometry(selected_poly, srid=4326) bbox = poly.extent # (xmin, ymin, xmax, ymax) candidates = list( interventions.filter( lon__isnull=False, lat__isnull=False, lon__gte=bbox[0], lon__lte=bbox[2], lat__gte=bbox[1], lat__lte=bbox[3], ).values_list('id', 'lon', 'lat') ) ids_in_poly = [ cid for cid, clon, clat in candidates if poly.contains(GEOSPoint(clon, clat, srid=4326)) ] interventions = interventions.filter(id__in=ids_in_poly) except Exception: pass # If member view mode is requested, return structured data if view_mode == 'members': return _interventions_events_by_members( interventions, allowed_company_ids, allowed_thematic_ids, allowed_contract_ids, allowed_team_ids, ) if view_mode == 'teams_daily': return _interventions_events_by_teams_daily( request, interventions, allowed_company_ids, allowed_thematic_ids, allowed_contract_ids, allowed_team_ids, ) # Default: timeline view events = [] for itv in interventions: events.append({ 'id': itv.id, 'title': itv.title, 'start': itv.effective_begin.isoformat() if itv.effective_begin else None, 'end': itv.effective_end.isoformat() if itv.effective_end else None, 'extendedProps': { 'id': itv.id, 'code': itv.code, 'status': itv.get_status_display(), 'status_code': itv.status, # utile pour la couleur dans le Gantt 'prep_state': itv.prep_state, 'priority': itv.get_priority_display(), 'type': itv.get_type_display(), 'planned_begin': itv.planned_begin_time.isoformat() if itv.planned_begin_time else None, 'planned_end': itv.planned_end_time.isoformat() if itv.planned_end_time else None, 'expected_begin': itv.expected_begin_time.isoformat() if itv.expected_begin_time else None, 'expected_end': itv.expected_end_time.isoformat() if itv.expected_end_time else None, 'actual_begin': itv.begin_time.isoformat() if itv.begin_time else None, 'actual_end': itv.end_time.isoformat() if itv.end_time else None, 'link': reverse('interventions:intervention_detail', args=[itv.id]), 'member_name': itv.assigned_member.name if itv.assigned_member else None, 'team_name': ", ".join(t.name for t in itv.assigned_member.teams.all()) if itv.assigned_member else None, } }) return JsonResponse(events, safe=False) def _interventions_events_by_members( interventions, allowed_company_ids=None, allowed_thematic_ids=None, allowed_contract_ids=None, allowed_team_ids=None, ): """ Group interventions by assigned members and teams. Returns a structure with: - teams: list of teams with their members and assigned interventions Each team also has a 'team_interventions' list for interventions assigned to the team but not to a specific member. - unassigned: list of interventions without assigned members (or assigned to filtered-out teams) - backlog_groups: non-assigned interventions grouped by maintain_type When ``allowed_company_ids`` is provided, only teams whose company belongs to this set are displayed. When ``allowed_thematic_ids`` is provided, only teams associated with at least one of these thematics are displayed. When ``allowed_contract_ids`` is provided, only interventions whose contract belongs to this set are shown in the backlog (right panel). This ensures the user only sees interventions they could assign for processing. When ``allowed_team_ids`` is provided, visible teams/members are restricted to this explicit set (UserCompanyTeamAccess.can_view). In that case, team filtering by company/thematic is not applied. """ from collections import defaultdict company_filter = None if allowed_company_ids is None else set(allowed_company_ids) thematic_filter = None if allowed_thematic_ids is None else set(allowed_thematic_ids) contract_filter = None if allowed_contract_ids is None else set(allowed_contract_ids) team_filter = None if allowed_team_ids is None else set(allowed_team_ids) def has_schedule(itv) -> bool: """Return True when intervention already started or has a future planned begin.""" if getattr(itv, 'begin_time', None): return True planned_begin = getattr(itv, 'planned_begin_time', None) return bool(planned_begin and planned_begin > timezone.now()) backlog_allowed_statuses = {'to_be_planned', 'to_be_processed'} backlog_allowed_types = {'corrective', 'preventive', 'ameliorative'} maintain_type_labels = dict(MAINTAIN_CHOICES) def compute_schedule_bounds(itv): default_duration = timedelta(hours=2) start = itv.effective_begin or itv.planned_begin_time or itv.expected_begin_time end = itv.effective_end or itv.planned_end_time or itv.expected_end_time if not start: start = timezone.now() if not end or end <= start: end = start + default_duration return start, end def serialize_intervention(itv): start, end = compute_schedule_bounds(itv) duration_ms = max(int((end - start).total_seconds() * 1000), 1) member = itv.assigned_member team = itv.assigned_team prep_state = getattr(itv, 'prep_state', None) if prep_state is None: prep_state = itv.get_preparation_orders_status() return { 'id': itv.id, 'code': itv.code, 'title': itv.title, 'start': start.isoformat(), 'end': end.isoformat(), 'planned_begin': itv.planned_begin_time.isoformat() if itv.planned_begin_time else None, 'planned_end': itv.planned_end_time.isoformat() if itv.planned_end_time else None, 'expected_begin': itv.expected_begin_time.isoformat() if itv.expected_begin_time else None, 'expected_end': itv.expected_end_time.isoformat() if itv.expected_end_time else None, 'actual_begin': itv.begin_time.isoformat() if itv.begin_time else None, 'actual_end': itv.end_time.isoformat() if itv.end_time else None, 'status': itv.get_status_display(), 'status_code': itv.status, 'prep_state': prep_state, 'priority': itv.get_priority_display(), 'type': itv.get_type_display(), 'maintain_type': itv.maintain_type, 'maintain_type_label': maintain_type_labels.get(itv.maintain_type, itv.maintain_type), 'member_id': member.id if member else None, 'member_name': member.name if member else None, 'team_id': team.id if team else (member.teams.all()[0].id if member and member.teams.all() else None), 'team_name': team.name if team else (member.teams.all()[0].name if member and member.teams.all() else None), 'assigned_to_team_only': bool(team and not member), 'duration_ms': duration_ms, 'link': reverse('interventions:intervention_detail', args=[itv.id]), } # Build the list of allowed teams (filtered by company and thematic) teams_qs = ( CompanyTeam.objects .select_related('company') .prefetch_related('thematics', 'members') .order_by('company__name', 'name') ) if team_filter is not None: teams_qs = teams_qs.filter(id__in=team_filter) else: if company_filter is not None: teams_qs = teams_qs.filter(company_id__in=company_filter) if thematic_filter is not None: teams_qs = teams_qs.filter(thematics__id__in=thematic_filter).distinct() allowed_teams = list(teams_qs) allowed_team_ids = {t.id for t in allowed_teams} # Build the list of allowed members first (filtered by company and thematic) members_qs = ( CompanyMember.objects .prefetch_related('teams__company', 'teams__thematics') .order_by('name') ) if team_filter is not None: members_qs = members_qs.filter(teams__id__in=team_filter) else: if company_filter is not None: members_qs = members_qs.filter(teams__company_id__in=company_filter) if thematic_filter is not None: members_qs = members_qs.filter(teams__thematics__id__in=thematic_filter) members = list(members_qs.distinct()) allowed_member_ids = {m.id for m in members} def member_team_allowed(member) -> bool: if not member or not member.teams.all(): return False # Check if member is in the pre-filtered allowed members list return member.id in allowed_member_ids def team_allowed(team) -> bool: if not team: return False return team.id in allowed_team_ids by_member = defaultdict(list) by_team = defaultdict(list) # Interventions assigned to team but not to a member unassigned = [] unscheduled_ids = set() for itv in interventions: scheduled = has_schedule(itv) member = itv.assigned_member team = itv.assigned_team # Interventions with status "to_be_planned" always go to the backlog (right column) # regardless of assignment or scheduling if itv.status == 'to_be_planned': unassigned.append(itv) if not scheduled: unscheduled_ids.add(itv.id) elif member and scheduled and member_team_allowed(member): # Intervention assigned to a specific member t_id = itv.assigned_team_id if t_id is None: m_teams = member.teams.all() t_id = m_teams[0].id if m_teams else None by_member[(member.id, t_id)].append(itv) elif team and not member and scheduled and team_allowed(team): # Intervention assigned to team but not to a specific member by_team[team.id].append(itv) else: unassigned.append(itv) if not scheduled: unscheduled_ids.add(itv.id) teams_dict = {} # First, initialize team entries from allowed teams for team in allowed_teams: team_id = team.id teams_dict[team_id] = { 'id': team_id, 'name': team.name, 'company': team.company.name if team.company else '', 'team_interventions': [serialize_intervention(itv) for itv in by_team.get(team_id, [])], 'members': [] } # Then add members to their respective teams for member in members: m_teams = member.teams.all() if not m_teams: team_id = None if team_id not in teams_dict: teams_dict[team_id] = { 'id': team_id, 'name': _('Sans équipe'), 'company': '', 'team_interventions': [], 'members': [] } serialized_interventions = [serialize_intervention(itv) for itv in by_member.get((member.id, None), [])] teams_dict[team_id]['members'].append({ 'id': member.id, 'name': member.name, 'interventions': serialized_interventions, }) else: for team in m_teams: team_id = team.id if team_id not in teams_dict: teams_dict[team_id] = { 'id': team_id, 'name': team.name, 'company': team.company.name if team.company else '', 'team_interventions': [], 'members': [] } serialized_interventions = [serialize_intervention(itv) for itv in by_member.get((member.id, team_id), [])] teams_dict[team_id]['members'].append({ 'id': member.id, 'name': member.name, 'interventions': serialized_interventions, }) teams = sorted(teams_dict.values(), key=lambda t: (t['company'] or '', t['name'] or '')) for team in teams: team['members'].sort(key=lambda m: m['name'] or '') # Backlog cutoff: today + 3 months backlog_cutoff_date = timezone.now() + timedelta(days=90) backlog_groups = {key: [] for key in backlog_allowed_types} unassigned_data = [serialize_intervention(itv) for itv in unassigned] FINISHED_STATUSES = {'finished', 'processed', 'corrected', 'validated', 'invoiced', 'closed', 'canceled'} for itv in interventions: expected_begin = itv.expected_begin_time if expected_begin and expected_begin > backlog_cutoff_date: continue is_finished = itv.status in FINISHED_STATUSES is_past_unfinished = False if not is_finished: begin = itv.effective_begin or itv.planned_begin_time or itv.expected_begin_time if begin and begin.date() < timezone.now().date(): is_past_unfinished = True scheduled = bool(itv.begin_time or itv.planned_begin_time) assigned = bool(itv.assigned_team or itv.assigned_member) include_in_backlog = ( (itv.maintain_type in backlog_allowed_types) and ( ( (itv.status in backlog_allowed_statuses) and (not assigned or not scheduled or itv.status == 'to_be_planned') ) or is_past_unfinished ) and (contract_filter is None or itv.contract_id in contract_filter) ) if include_in_backlog: backlog_groups[itv.maintain_type].append(serialize_intervention(itv)) return JsonResponse({ 'view_mode': 'members', 'teams': teams, 'unassigned': unassigned_data, 'backlog_groups': backlog_groups, }) def _interventions_events_by_teams_daily( request, interventions, allowed_company_ids=None, allowed_thematic_ids=None, allowed_contract_ids=None, allowed_team_ids=None, ): from django.utils.dateparse import parse_date from collections import defaultdict # 1. Determine anchor date (defaults to today) date_str = request.GET.get('date') anchor_date = parse_date(date_str) if date_str else timezone.now().date() # 2. Find Monday of that week monday = anchor_date - timedelta(days=anchor_date.weekday()) # 3. Generate the 7 days of the week days = [] for i in range(7): d = monday + timedelta(days=i) days.append({ 'date': d.isoformat(), 'is_today': d == timezone.now().date() }) start_date = monday end_date = monday + timedelta(days=6) # 4. Get allowed teams (similar to _interventions_events_by_members) company_filter = None if allowed_company_ids is None else set(allowed_company_ids) thematic_filter = None if allowed_thematic_ids is None else set(allowed_thematic_ids) team_filter = None if allowed_team_ids is None else set(allowed_team_ids) teams_qs = ( CompanyTeam.objects .select_related('company') .prefetch_related('thematics') .order_by('company__name', 'name') ) if team_filter is not None: teams_qs = teams_qs.filter(id__in=team_filter) else: if company_filter is not None: teams_qs = teams_qs.filter(company_id__in=company_filter) if thematic_filter is not None: teams_qs = teams_qs.filter(thematics__id__in=thematic_filter).distinct() allowed_teams = list(teams_qs) allowed_team_ids = {t.id for t in allowed_teams} # 5. Retrieve member history for daily composition # CompanyMemberHistory has join_date and leave_date history_qs = ( CompanyMemberHistory.objects .filter( team_id__in=allowed_team_ids, join_date__lte=end_date, ) .filter( Q(leave_date__isnull=True) | Q(leave_date__gte=start_date) ) .select_related('member') ) # Group history records by member ID member_histories = defaultdict(lambda: {'name': '', 'histories': []}) for hist in history_qs: member_histories[hist.member_id]['name'] = hist.member.name member_histories[hist.member_id]['histories'].append({ 'team_id': hist.team_id, 'join_date': hist.join_date, 'leave_date': hist.leave_date, }) # Map of (team_id, date) -> set of member names team_compositions_sets = defaultdict(set) # For each day of the week for i in range(7): d = monday + timedelta(days=i) for m_id, m_data in member_histories.items(): member_name = m_data['name'] # Find which histories are active on day d active = [] for h in m_data['histories']: if h['join_date'] <= d and (h['leave_date'] is None or h['leave_date'] >= d): active.append(h) if not active: continue if len(active) > 1: # Resolve conflicts: # Exclude those ending on day d if there is at least one that does not end on day d non_ending = [h for h in active if h['leave_date'] != d] if non_ending: active = non_ending # Add to team_compositions_sets for the remaining team(s) for h in active: team_compositions_sets[(h['team_id'], d)].add(member_name) # Convert sets to sorted lists for consistency and default to empty list on missing access team_compositions = defaultdict(list) for key, names_set in team_compositions_sets.items(): team_compositions[key] = sorted(list(names_set)) # 6. Retrieve assignments for the 7 days # Filter assignments by the interventions list (which already has user view permissions and search filters applied!) assignments = ( InterventionAssignment.objects .filter( intervention__in=interventions, date__range=(start_date, end_date) ) .select_related('intervention', 'member', 'team') ) # Map of (team_id, date) -> list of serialized interventions assigned_interventions = defaultdict(list) for assign in assignments: itv = assign.intervention # Verify team is allowed (or if team was filtered out, we show it as unassigned) t_id = assign.team_id if t_id not in allowed_team_ids: t_id = None assigned_interventions[(t_id, assign.date)].append({ 'id': itv.id, 'code': itv.code, 'title': itv.title, 'status': assign.status or itv.status, 'status_display': itv.get_status_display() if not assign.status else dict(STATUS_CHOICES).get(assign.status, assign.status), 'status_color': itv.get_status_color() if not assign.status else assign.status, 'link': reverse('interventions:intervention_detail', args=[itv.id]), 'member_name': assign.member.name if assign.member else None, }) # 7. Add scheduled interventions without a team in this period to "Sans équipe" row # Unassigned interventions are those scheduled in this period but having no assignment in InterventionAssignment # OR assigned to a team that is not allowed (t_id is None in our loop above) # Let's find interventions scheduled in this week that are in our viewable interventions list: scheduled_itvs = interventions.filter( Q(effective_begin__lte=end_date) & (Q(effective_end__gte=start_date) | Q(effective_end__isnull=True)) ) # Find which ones have assignments in this week assigned_itv_ids = set(assignments.values_list('intervention_id', flat=True)) unassigned_itvs = scheduled_itvs.exclude(id__in=assigned_itv_ids) for itv in unassigned_itvs: # Determine which days it spans within our week itv_start = (itv.effective_begin or timezone.now()).date() itv_end = (itv.effective_end or itv_start).date() # Intersect with [start_date, end_date] span_start = max(itv_start, start_date) span_end = min(itv_end, end_date) curr = span_start while curr <= span_end: assigned_interventions[(None, curr)].append({ 'id': itv.id, 'code': itv.code, 'title': itv.title, 'status': itv.status, 'status_display': itv.get_status_display(), 'status_color': itv.get_status_color(), 'link': reverse('interventions:intervention_detail', args=[itv.id]), 'member_name': None, }) curr += timedelta(days=1) # Sort interventions in each cell by code for key in assigned_interventions: assigned_interventions[key].sort(key=lambda x: x['code']) # 8. Build final teams list teams_data = [] for team in allowed_teams: team_days = {} for i in range(7): d = monday + timedelta(days=i) team_days[d.isoformat()] = { 'members': team_compositions[(team.id, d)], 'interventions': assigned_interventions[(team.id, d)] } teams_data.append({ 'id': team.id, 'name': team.name, 'company': team.company.name if team.company else '', 'days': team_days }) # Add "Sans équipe" row if there are any unassigned interventions has_unassigned = any(len(assigned_interventions[(None, monday + timedelta(days=i))]) > 0 for i in range(7)) if has_unassigned: unassigned_days = {} for i in range(7): d = monday + timedelta(days=i) unassigned_days[d.isoformat()] = { 'members': [], 'interventions': assigned_interventions[(None, d)] } teams_data.append({ 'id': None, 'name': _('Sans équipe'), 'company': '', 'days': unassigned_days }) # Compute backlog groups for the assignment sidebar backlog_allowed_statuses = {'to_be_planned', 'to_be_processed'} backlog_allowed_types = {'corrective', 'preventive', 'ameliorative'} maintain_type_labels = dict(MAINTAIN_CHOICES) contract_filter = None if allowed_contract_ids is None else set(allowed_contract_ids) def compute_schedule_bounds(itv): default_duration = timedelta(hours=2) start = itv.effective_begin or itv.planned_begin_time or itv.expected_begin_time if not start: start = timezone.now() end = itv.effective_end or itv.raw_effective_end or itv.planned_end_time or itv.expected_end_time if not end: end = start + default_duration return start, end def serialize_intervention(itv): start, end = compute_schedule_bounds(itv) duration_ms = max(int((end - start).total_seconds() * 1000), 1) member = itv.assigned_member team = itv.assigned_team prep_state = getattr(itv, 'prep_state', None) if prep_state is None: prep_state = itv.get_preparation_orders_status() return { 'id': itv.id, 'code': itv.code, 'title': itv.title, 'start': start.isoformat(), 'end': end.isoformat(), 'planned_begin': itv.planned_begin_time.isoformat() if itv.planned_begin_time else None, 'planned_end': itv.planned_end_time.isoformat() if itv.planned_end_time else None, 'expected_begin': itv.expected_begin_time.isoformat() if itv.expected_begin_time else None, 'expected_end': itv.expected_end_time.isoformat() if itv.expected_end_time else None, 'actual_begin': itv.begin_time.isoformat() if itv.begin_time else None, 'actual_end': itv.end_time.isoformat() if itv.end_time else None, 'status': itv.get_status_display(), 'status_code': itv.status, 'prep_state': prep_state, 'priority': itv.get_priority_display(), 'type': itv.get_type_display(), 'maintain_type': itv.maintain_type, 'maintain_type_label': maintain_type_labels.get(itv.maintain_type, itv.maintain_type), 'member_id': member.id if member else None, 'member_name': member.name if member else None, 'team_id': team.id if team else (member.teams.all()[0].id if member and member.teams.all() else None), 'team_name': team.name if team else (member.teams.all()[0].name if member and member.teams.all() else None), 'assigned_to_team_only': bool(team and not member), 'duration_ms': duration_ms, 'link': reverse('interventions:intervention_detail', args=[itv.id]), } backlog_cutoff_date = timezone.now() + timedelta(days=90) backlog_groups = {key: [] for key in backlog_allowed_types} FINISHED_STATUSES = {'finished', 'processed', 'corrected', 'validated', 'invoiced', 'closed', 'canceled'} for itv in interventions: expected_begin = itv.expected_begin_time if expected_begin and expected_begin > backlog_cutoff_date: continue is_finished = itv.status in FINISHED_STATUSES is_past_unfinished = False if not is_finished: begin = itv.effective_begin or itv.planned_begin_time or itv.expected_begin_time if begin and begin.date() < timezone.now().date(): is_past_unfinished = True scheduled = bool(itv.begin_time or itv.planned_begin_time) assigned = bool(itv.assigned_team or itv.assigned_member) include_in_backlog = ( (itv.maintain_type in backlog_allowed_types) and ( ( (itv.status in backlog_allowed_statuses) and (not assigned or not scheduled or itv.status == 'to_be_planned') ) or is_past_unfinished ) and (contract_filter is None or itv.contract_id in contract_filter) ) if include_in_backlog: backlog_groups[itv.maintain_type].append(serialize_intervention(itv)) return JsonResponse({ 'view_mode': 'teams_daily', 'start_date': start_date.isoformat(), 'end_date': end_date.isoformat(), 'days': days, 'teams': teams_data, 'backlog_groups': backlog_groups, }) def interventions_schedule_update(request): if not request.user.is_authenticated: return JsonResponse({'error': _('Authentification requise.')}, status=403) try: payload = json.loads(request.body or '{}') except json.JSONDecodeError: return JsonResponse({'error': _('Requête JSON invalide.')}, status=400) intervention_id = payload.get('intervention_id') if not intervention_id: return JsonResponse({'error': _('Identifiant intervention manquant.')}, status=400) qs = ( filter_viewable_interventions_for_user(request.user) .select_related('assigned_member', 'assigned_team') .prefetch_related('assigned_member__teams') ) try: intervention = qs.get(id=intervention_id) except Intervention.DoesNotExist: return JsonResponse({'error': _('Intervention introuvable.')}, status=404) if not can_edit_intervention(request.user, intervention): return JsonResponse({'error': _('Vous ne pouvez pas modifier cette intervention.')}, status=403) # Vérifier les restrictions pour les managers try: user_config = UserConfig.objects.get(user=request.user) user_roles = set(user_config.roles.values_list('name', flat=True)) is_manager_only = 'manager' in user_roles and not user_roles.intersection({'admin', 'controller', 'external_manager'}) # Les managers ne peuvent déplacer que les interventions dans certains statuts if is_manager_only: allowed_statuses_for_manager_move = {'in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed'} if intervention.status not in allowed_statuses_for_manager_move: return JsonResponse({'error': _("Vous ne pouvez pas déplacer cette intervention dans son statut actuel.")}, status=403) except UserConfig.DoesNotExist: pass member_key_present = 'assigned_member_id' in payload team_key_present = 'assigned_team_id' in payload member_id = payload.get('assigned_member_id') team_id = payload.get('assigned_team_id') # Convert to int to avoid type mismatch with database integer IDs in Python comparisons if member_id is not None: try: member_id = int(member_id) except (ValueError, TypeError): pass if team_id is not None: try: team_id = int(team_id) except (ValueError, TypeError): pass # Déterminer si l'assignation va réellement changer prev_member_id = intervention.assigned_member_id prev_team_id = intervention.assigned_team_id assignment_will_change = False if member_key_present: # Si la clé est présente, vérifier si la valeur change new_member_id = member_id if member_id else None if new_member_id != prev_member_id: assignment_will_change = True if team_key_present and not member_key_present: # Si seulement team_key est présente (pas member), vérifier si elle change new_team_id = team_id if team_id else None if new_team_id != prev_team_id: assignment_will_change = True # Vérifier la permission d'assignation seulement si l'assignation va réellement changer if assignment_will_change: allowed_update_fields = get_allowed_update_fields(request.user, intervention) if 'member_assignment' not in allowed_update_fields: return JsonResponse({'error': _("Vous n'avez pas la permission d'assigner cette intervention.")}, status=403) member = None team = None if member_id: member = CompanyMember.objects.prefetch_related('teams').filter(id=member_id).first() if not member: return JsonResponse({'error': _('Membre assigné invalide.')}, status=400) # When assigning to a member, also set the team. # If team_id is provided in the payload and is one of the member's teams, use it. # Otherwise, fall back to the first team. m_teams = member.teams.all() if team_id and any(t.id == team_id for t in m_teams): team = next(t for t in m_teams if t.id == team_id) else: team = m_teams[0] if m_teams else None elif team_key_present and team_id: # Assigning to team only (no member) team = CompanyTeam.objects.filter(id=team_id).first() if not team: return JsonResponse({'error': _('Équipe assignée invalide.')}, status=400) begin_str = payload.get('planned_begin_time') end_str = payload.get('planned_end_time') planned_begin = parse_datetime(begin_str) if begin_str else None planned_end = parse_datetime(end_str) if end_str else None tz = get_current_timezone() if planned_begin and is_naive(planned_begin): planned_begin = make_aware(planned_begin, tz) if planned_end and is_naive(planned_end): planned_end = make_aware(planned_end, tz) if not planned_begin: return JsonResponse({'error': _('La date de début planifiée est obligatoire.')}, status=400) if planned_begin.date() < timezone.now().date(): return JsonResponse({'error': _("Il est interdit de planifier ou d'assigner une intervention dans le passé.")}, status=400) if not planned_end: planned_end = planned_begin + timedelta(hours=2) if planned_end <= planned_begin: return JsonResponse({'error': _('La fin planifiée doit être postérieure au début planifié.')}, status=400) prev_begin = intervention.planned_begin_time prev_end = intervention.planned_end_time intervention.planned_begin_time = planned_begin intervention.planned_end_time = planned_end fields = ['planned_begin_time', 'planned_end_time'] # Handle member assignment if member_key_present: intervention.assigned_member = member intervention.assigned_team = team # team from member or None new_member_id = member.id if member else None new_team_id = team.id if team else None if new_member_id != prev_member_id: fields.append('assigned_member') if new_team_id != prev_team_id: fields.append('assigned_team') # Handle team-only assignment (when member is not specified but team is) elif team_key_present: intervention.assigned_member = None intervention.assigned_team = team new_team_id = team.id if team else None if prev_member_id is not None: fields.append('assigned_member') if new_team_id != prev_team_id: fields.append('assigned_team') # Auto-update status based on assignment prev_status = intervention.status new_status = prev_status # Check if intervention is being assigned (to member or team) is_now_assigned = intervention.assigned_member_id is not None or intervention.assigned_team_id is not None was_assigned = prev_member_id is not None or prev_team_id is not None # If intervention was "to_be_planned" and is now assigned -> change to "to_be_processed" if prev_status == 'to_be_planned' and is_now_assigned: new_status = 'to_be_processed' # If intervention is being unassigned (back to backlog) -> change to "to_be_planned" elif was_assigned and not is_now_assigned and prev_status == 'to_be_processed': new_status = 'to_be_planned' if new_status != prev_status: intervention.status = new_status fields.append('status') with transaction.atomic(): intervention.save(update_fields=fields) _log_planning_change(intervention, request.user, prev_begin, prev_end, prev_status if new_status != prev_status else None) return JsonResponse({ 'success': True, 'intervention': { 'id': intervention.id, 'planned_begin_time': intervention.planned_begin_time.isoformat() if intervention.planned_begin_time else None, 'planned_end_time': intervention.planned_end_time.isoformat() if intervention.planned_end_time else None, 'assigned_member_id': intervention.assigned_member_id, 'assigned_team_id': intervention.assigned_team_id, 'status': intervention.status, 'status_display': intervention.get_status_display(), } }) def intervention_planning_data_ajax(request, intervention_id): if not request.user.is_authenticated: return JsonResponse({'error': 'Unauthorized'}, status=401) intervention = get_object_or_404(filter_viewable_interventions_for_user(request.user), pk=intervention_id) return JsonResponse({ 'id': intervention.id, 'code': intervention.code, 'title': intervention.title, 'status': intervention.status, 'status_code': intervention.status, 'status_display': intervention.get_status_display(), 'prep_state': intervention.get_preparation_orders_status(), 'priority': intervention.get_priority_display(), 'type': intervention.get_type_display(), 'planned_begin': intervention.planned_begin_time.isoformat() if intervention.planned_begin_time else None, 'planned_end': intervention.planned_end_time.isoformat() if intervention.planned_end_time else None, 'expected_begin': intervention.expected_begin_time.isoformat() if intervention.expected_begin_time else None, 'expected_end': intervention.expected_end_time.isoformat() if intervention.expected_end_time else None, 'actual_begin': intervention.begin_time.isoformat() if intervention.begin_time else None, 'actual_end': intervention.end_time.isoformat() if intervention.end_time else None, 'link': reverse('interventions:intervention_detail', args=[intervention.id]), 'member_name': intervention.assigned_member.name if intervention.assigned_member else None, 'team_name': intervention.assigned_team.name if intervention.assigned_team else None, })