1820 lines
79 KiB
Python
1820 lines
79 KiB
Python
from django.http import HttpResponse, JsonResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseRedirect
|
|
from django.shortcuts import render, get_object_or_404, redirect
|
|
from django.template.loader import render_to_string
|
|
from django.urls import reverse, NoReverseMatch
|
|
from django.core.serializers import serialize
|
|
from django.core.exceptions import FieldDoesNotExist
|
|
from django.core.files.base import ContentFile
|
|
from django.contrib.gis.db.models.functions import Transform, Centroid, AsGeoJSON, Distance
|
|
from django.contrib.gis.geos import GEOSGeometry, Point, MultiPolygon
|
|
from django.contrib.gis.measure import D
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.contrib import messages
|
|
from django.views.decorators.http import require_POST, require_GET, require_http_methods
|
|
from django.views.decorators.cache import never_cache
|
|
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 interventions.services.document_sequencing import get_sequenced_intervention_documents
|
|
|
|
from common.models import UserConfig, UserContractAccess, UserContractStatusPermission, Thematic, UserThematics
|
|
from contracts.models import ContractOrder, ContractPost, Contract, Company, CompanyTeam, CompanyMember, UserCompanyTeamAccess, CompanyMemberHistory, CompanyMemberAbsence
|
|
from contracts.permissions import (get_allowed_contracts_for_user, user_has_access_to_contract_order,
|
|
user_can_edit_teams, user_can_edit_specific_team, get_viewable_teams_for_user)
|
|
from contracts.services_teams import get_teams_composition_for_period, get_member_absences_for_period
|
|
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 *
|
|
|
|
FINISHED_STATUSES = {'finished', 'processed', 'corrected', 'validated', 'invoiced', 'closed', 'canceled'}
|
|
|
|
|
|
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 et les droits de l'utilisateur
|
|
is_admin = bool(role_names & {'admin', 'top_manager'}) or request.user.is_superuser
|
|
contracts_qs = (
|
|
Contract.objects
|
|
.filter(thematics__id__in=allowed_thematic_ids)
|
|
.prefetch_related('thematics')
|
|
.distinct()
|
|
.order_by('contract_number')
|
|
)
|
|
if not is_admin:
|
|
if not user_config.is_intern or user_config.limit_interventions_to_contracts:
|
|
accessible_contract_ids = UserContractAccess.objects.filter(
|
|
user_config=user_config,
|
|
can_view_interventions=True,
|
|
).values_list('contract_id', flat=True)
|
|
contracts_qs = contracts_qs.filter(id__in=accessible_contract_ids)
|
|
|
|
# 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_view_member_history': bool(role_names & {'admin', 'manager', 'external_manager'}),
|
|
'can_edit_teams': user_can_edit_teams(request.user),
|
|
'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 in ('members', 'teams_daily'):
|
|
# Dans les vues par membres et par équipes journalières, le panneau latéral présente le backlog à assigner.
|
|
# Les interventions non terminées doivent rester visibles dans ce backlog même si leur date dépasse 3 mois.
|
|
backlog_window_filter = end_window_filter | ~Q(status__in=FINISHED_STATUSES)
|
|
interventions = (
|
|
interventions
|
|
.filter(backlog_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', 'assigned_team')
|
|
.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
|
|
interventions = interventions.prefetch_related(
|
|
Prefetch(
|
|
'events',
|
|
queryset=InterventionTimeLine.objects.filter(
|
|
Q(event_type__in=['status_change', 'status']) | Q(to_status__isnull=False)
|
|
).select_related('event_user').order_by('event_time'),
|
|
to_attr='status_events',
|
|
)
|
|
)
|
|
|
|
events = []
|
|
for itv in interventions:
|
|
status_events = getattr(itv, 'status_events', None)
|
|
status_history = []
|
|
if status_events:
|
|
first_evt = status_events[0]
|
|
if first_evt.from_status and itv.creation_time and itv.creation_time < first_evt.event_time:
|
|
status_history.append({
|
|
'status_code': first_evt.from_status,
|
|
'status_label': str(STATUS_CHOICES.get(first_evt.from_status, first_evt.from_status)),
|
|
'event_time': itv.creation_time.isoformat(),
|
|
'user_name': None,
|
|
})
|
|
for evt in status_events:
|
|
code = evt.to_status or itv.status
|
|
user_name = get_short_name(evt.event_user) if evt.event_user else None
|
|
event_iso = evt.event_time.isoformat() if evt.event_time else None
|
|
if status_history and status_history[-1]['status_code'] == code and status_history[-1]['event_time'] == event_iso:
|
|
continue
|
|
status_history.append({
|
|
'status_code': code,
|
|
'status_label': str(STATUS_CHOICES.get(code, code)),
|
|
'event_time': event_iso,
|
|
'user_name': user_name,
|
|
})
|
|
if status_history and status_history[-1]['status_code'] != itv.status:
|
|
ref_time = itv.effective_begin or itv.begin_time or itv.creation_time or timezone.now()
|
|
status_history.append({
|
|
'status_code': itv.status,
|
|
'status_label': itv.get_status_display(),
|
|
'event_time': ref_time.isoformat() if ref_time else None,
|
|
'user_name': None,
|
|
})
|
|
else:
|
|
ref_time = itv.begin_time or itv.creation_time or itv.effective_begin
|
|
status_history.append({
|
|
'status_code': itv.status,
|
|
'status_label': itv.get_status_display(),
|
|
'event_time': ref_time.isoformat() if ref_time else None,
|
|
'user_name': None,
|
|
})
|
|
|
|
milestones = []
|
|
if itv.expected_begin_time:
|
|
milestones.append({
|
|
'type': 'expected_begin',
|
|
'label': str(_('Début prévu')),
|
|
'time': itv.expected_begin_time.isoformat(),
|
|
'shape': 'square',
|
|
'dashed': True,
|
|
})
|
|
if itv.expected_end_time:
|
|
milestones.append({
|
|
'type': 'expected_end',
|
|
'label': str(_('Fin prévue')),
|
|
'time': itv.expected_end_time.isoformat(),
|
|
'shape': 'square',
|
|
'dashed': True,
|
|
})
|
|
if itv.planned_begin_time:
|
|
milestones.append({
|
|
'type': 'planned_begin',
|
|
'label': str(_('Début planifié')),
|
|
'time': itv.planned_begin_time.isoformat(),
|
|
'shape': 'square',
|
|
'dashed': True,
|
|
})
|
|
if itv.planned_end_time:
|
|
milestones.append({
|
|
'type': 'planned_end',
|
|
'label': str(_('Fin planifiée')),
|
|
'time': itv.planned_end_time.isoformat(),
|
|
'shape': 'square',
|
|
'dashed': True,
|
|
})
|
|
if itv.end_time:
|
|
milestones.append({
|
|
'type': 'actual_end',
|
|
'label': str(_('Fin réelle')),
|
|
'time': itv.end_time.isoformat(),
|
|
'shape': 'circle',
|
|
'dashed': False,
|
|
})
|
|
|
|
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
|
|
'status_history': status_history,
|
|
'milestones': milestones,
|
|
'prep_state': itv.prep_state,
|
|
'priority': itv.get_priority_display(),
|
|
'priority_code': itv.priority,
|
|
'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 (itv.assigned_team.name if itv.assigned_team else None),
|
|
'is_assigned': bool(itv.assigned_team_id or itv.assigned_member_id),
|
|
}
|
|
})
|
|
|
|
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 has a scheduled or actual start time."""
|
|
return bool(getattr(itv, 'begin_time', None) or getattr(itv, 'planned_begin_time', None))
|
|
|
|
|
|
backlog_allowed_statuses = {'in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed', 'assigned'}
|
|
PRE_PROCESSING_STATUSES = {'in_preparation', 'to_be_approved', 'to_be_planned'}
|
|
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(),
|
|
'priority_code': itv.priority or '3',
|
|
'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),
|
|
'is_assigned': bool(team or 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 lower than "to_be_processed" always go to the backlog (right column)
|
|
# regardless of assignment or scheduling
|
|
if itv.status in PRE_PROCESSING_STATUSES:
|
|
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 in PRE_PROCESSING_STATUSES)
|
|
)
|
|
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))
|
|
|
|
for group_key in backlog_groups:
|
|
backlog_groups[group_key].sort(key=lambda x: (x.get('priority_code') or '9', -x.get('id', 0)))
|
|
|
|
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)
|
|
member_teams_by_day = defaultdict(lambda: defaultdict(set))
|
|
|
|
# For each day of the week
|
|
today = timezone.now().date()
|
|
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 d <= today:
|
|
if h['join_date'] <= d and (h['leave_date'] is None or h['leave_date'] >= d):
|
|
active.append(h)
|
|
else:
|
|
# Pour les jours futurs : affiché uniquement si explicitement planifié pour ce jour (join_date == d)
|
|
if h['join_date'] == d:
|
|
active.append(h)
|
|
|
|
if not active:
|
|
continue
|
|
|
|
# Grouper par équipe pour éliminer les doublons au sein d'une même équipe
|
|
active_by_team = defaultdict(list)
|
|
for h in active:
|
|
active_by_team[h['team_id']].append(h)
|
|
|
|
for t_id in active_by_team.keys():
|
|
team_compositions_sets[(t_id, d)].add((m_id, member_name))
|
|
member_teams_by_day[d][m_id].add(t_id)
|
|
|
|
# 5.b Récupérer les absences pour cette semaine
|
|
absences_map = {
|
|
(a.member_id, a.date): a.get_reason_display()
|
|
for a in CompanyMemberAbsence.objects.filter(date__range=(start_date, end_date))
|
|
}
|
|
|
|
teams_name_map = {t.id: t.name for t in allowed_teams if t.id is not None}
|
|
|
|
# Convert sets to sorted lists of dicts for consistency and default to empty list on missing access
|
|
team_compositions = defaultdict(list)
|
|
for (t_id, d_val), members_set in team_compositions_sets.items():
|
|
team_compositions[(t_id, d_val)] = [
|
|
{
|
|
'id': mid,
|
|
'name': mname,
|
|
'is_absent': (mid, d_val) in absences_map,
|
|
'absence_reason': absences_map.get((mid, d_val)),
|
|
'is_multi_team': len(member_teams_by_day[d_val][mid]) > 1,
|
|
'other_teams': [
|
|
{'id': ot_id, 'name': teams_name_map.get(ot_id, '')}
|
|
for ot_id in member_teams_by_day[d_val][mid]
|
|
if ot_id != t_id
|
|
],
|
|
}
|
|
for mid, mname in sorted(list(members_set), key=lambda x: x[1])
|
|
]
|
|
|
|
# 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,
|
|
'is_assigned': True,
|
|
'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:
|
|
PRE_PROCESSING_STATUSES = {'in_preparation', 'to_be_approved', 'to_be_planned'}
|
|
scheduled_itvs = interventions.exclude(status__in=PRE_PROCESSING_STATUSES).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(),
|
|
'is_assigned': bool(itv.assigned_team_id or itv.assigned_member_id),
|
|
'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 '',
|
|
'can_edit': user_can_edit_specific_team(request.user, team.id),
|
|
'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 = {'in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed', 'assigned'}
|
|
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(),
|
|
'priority_code': itv.priority or '3',
|
|
'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),
|
|
'is_assigned': bool(team or 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 in PRE_PROCESSING_STATUSES)
|
|
)
|
|
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))
|
|
|
|
for group_key in backlog_groups:
|
|
backlog_groups[group_key].sort(key=lambda x: (x.get('priority_code') or '9', -x.get('id', 0)))
|
|
|
|
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)
|
|
|
|
user_config = getattr(request.user, 'config', None)
|
|
if not user_config:
|
|
try:
|
|
user_config = UserConfig.objects.get(user=request.user)
|
|
except UserConfig.DoesNotExist:
|
|
return JsonResponse({'error': _("Configuration utilisateur introuvable.")}, status=403)
|
|
|
|
user_roles = set(user_config.roles.values_list('name', flat=True))
|
|
is_admin = bool(user_roles & {'admin', 'top_manager'}) or request.user.is_superuser
|
|
is_external_manager = 'external_manager' in user_roles
|
|
is_controller = 'controller' in user_roles
|
|
is_manager_only = 'manager' in user_roles and not bool(user_roles & {'admin', 'top_manager', 'controller', 'external_manager'})
|
|
|
|
# 1. Vérifier si l'utilisateur a un rôle autorisé à planifier/déplacer dans le calendrier
|
|
can_schedule = is_admin or is_external_manager or is_controller or is_manager_only or (bool(user_roles & {'technician', 'inspector'}) and can_edit_intervention(request.user, intervention, user_config=user_config))
|
|
if not can_schedule:
|
|
return JsonResponse({'error': _("Vous n'avez pas la permission de planifier cette intervention.")}, status=403)
|
|
|
|
# 2. Vérifier si le statut actuel permet le déplacement/replanification dans le calendrier
|
|
allowed_statuses_for_move = {'in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed', 'assigned'}
|
|
if intervention.status not in allowed_statuses_for_move:
|
|
return JsonResponse({'error': _("Vous ne pouvez pas déplacer cette intervention dans son statut actuel.")}, status=403)
|
|
|
|
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
|
|
prev_member = intervention.assigned_member
|
|
prev_team = intervention.assigned_team
|
|
|
|
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
|
|
|
|
# 3. Vérifier la permission d'assignation seulement si l'assignation va réellement changer
|
|
if assignment_will_change:
|
|
can_assign = is_admin or is_external_manager or is_controller or (bool(user_roles & {'technician', 'inspector'}) and can_edit_intervention(request.user, intervention, user_config=user_config))
|
|
if not can_assign:
|
|
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)
|
|
m_teams = member.teams.all()
|
|
# Vérifier que le membre appartient à une entreprise autorisée pour un external_manager
|
|
if is_external_manager and not is_admin:
|
|
allowed_company_ids = _member_view_allowed_company_ids(user_config, user_roles)
|
|
if allowed_company_ids is not None:
|
|
member_company_ids = set(m_teams.values_list('company_id', flat=True))
|
|
if not member_company_ids.intersection(allowed_company_ids):
|
|
return JsonResponse({'error': _("Vous ne pouvez assigner qu'aux membres de votre entreprise.")}, status=403)
|
|
# 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.
|
|
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)
|
|
# Vérifier que l'équipe appartient à une entreprise autorisée pour un external_manager
|
|
if is_external_manager and not is_admin:
|
|
allowed_company_ids = _member_view_allowed_company_ids(user_config, user_roles)
|
|
if allowed_company_ids is not None and team.company_id not in allowed_company_ids:
|
|
return JsonResponse({'error': _("Vous ne pouvez assigner qu'aux équipes de votre entreprise.")}, status=403)
|
|
|
|
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)
|
|
|
|
unassign = payload.get('unassign', False) or payload.get('to_backlog', False) or (member_key_present and team_key_present and not member_id and not team_id)
|
|
|
|
if not unassign:
|
|
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)
|
|
else:
|
|
planned_begin = None
|
|
planned_end = None
|
|
|
|
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
|
|
is_unassigning = unassign or (not is_now_assigned)
|
|
|
|
# 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" only if user has transition rights
|
|
elif is_unassigning and prev_status in ('to_be_processed', 'assigned'):
|
|
allowed_transitions = user_allowed_transitions(request.user, prev_status, intervention=intervention)
|
|
if 'to_be_planned' in allowed_transitions or is_admin:
|
|
new_status = 'to_be_planned'
|
|
else:
|
|
new_status = prev_status
|
|
|
|
if new_status != prev_status:
|
|
intervention.status = new_status
|
|
fields.append('status')
|
|
|
|
with transaction.atomic():
|
|
if getattr(request.user, 'is_authenticated', False):
|
|
intervention.last_modified_by = request.user
|
|
if 'last_modified_by' not in fields:
|
|
fields.append('last_modified_by')
|
|
intervention.save(update_fields=fields)
|
|
_log_planning_change(intervention, request.user, prev_begin, prev_end, prev_status if new_status != prev_status else None)
|
|
if 'assigned_team' in fields or 'assigned_member' in fields:
|
|
from interventions.services.timeline_service import record_assignment_event
|
|
record_assignment_event(
|
|
intervention=intervention,
|
|
user=request.user,
|
|
old_team=prev_team,
|
|
new_team=intervention.assigned_team,
|
|
old_member=prev_member,
|
|
new_member=intervention.assigned_member,
|
|
)
|
|
if new_status != prev_status:
|
|
from interventions.services.timeline_service import record_timeline_event
|
|
record_timeline_event(
|
|
intervention=intervention,
|
|
user=request.user,
|
|
event_type='status_change',
|
|
from_status=prev_status,
|
|
to_status=new_status,
|
|
)
|
|
|
|
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.get_status_display(),
|
|
'status_code': intervention.status,
|
|
'status_display': intervention.get_status_display(),
|
|
'is_assigned': intervention.is_assigned,
|
|
'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,
|
|
})
|
|
|
|
|
|
def member_history_ajax(request, member_id):
|
|
if not request.user.is_authenticated:
|
|
return JsonResponse({'error': _("Authentification requise.")}, status=403)
|
|
|
|
user_config = (
|
|
UserConfig.objects
|
|
.select_related('user')
|
|
.prefetch_related('roles')
|
|
.filter(user=request.user)
|
|
.first()
|
|
)
|
|
if not user_config:
|
|
return JsonResponse({'error': _("Accès non autorisé.")}, status=403)
|
|
|
|
role_names = _get_role_names(user_config)
|
|
can_view = bool(role_names & {'admin', 'manager', 'external_manager'})
|
|
if not can_view:
|
|
return JsonResponse({'error': _("Permission refusée.")}, status=403)
|
|
|
|
member = get_object_or_404(CompanyMember.objects.prefetch_related('teams__company'), id=member_id)
|
|
|
|
team_history_qs = (
|
|
CompanyMemberHistory.objects
|
|
.filter(member=member)
|
|
.select_related('team__company')
|
|
.order_by('-join_date')
|
|
)
|
|
team_history = [
|
|
{
|
|
'team_name': h.team.name if h.team else '—',
|
|
'join_date': h.join_date.strftime('%d/%m/%Y') if h.join_date else None,
|
|
'leave_date': h.leave_date.strftime('%d/%m/%Y') if h.leave_date else None,
|
|
'is_active': h.leave_date is None,
|
|
}
|
|
for h in team_history_qs
|
|
]
|
|
|
|
company_names = set(t.company.name for t in member.teams.all() if getattr(t, 'company', None))
|
|
if not company_names:
|
|
company_names = set(h.team.company.name for h in team_history_qs if h.team and getattr(h.team, 'company', None))
|
|
if not company_names and member.user_id:
|
|
company_names = set(
|
|
uca.contract.company.name for uca in UserContractAccess.objects.filter(user_config__user=member.user)
|
|
if uca.contract and getattr(uca.contract, 'company', None)
|
|
)
|
|
company_str = ", ".join(sorted(company_names)) if company_names else '—'
|
|
|
|
# 0. Récupérer les dates d'absences du membre
|
|
absent_dates = set(
|
|
CompanyMemberAbsence.objects.filter(member=member).values_list('date', flat=True)
|
|
)
|
|
|
|
# 1. Équipes et périodes valides du membre selon CompanyMemberHistory
|
|
team_periods_q = Q()
|
|
for h in team_history_qs:
|
|
if h.team_id and h.join_date:
|
|
tq = Q(team_id=h.team_id, date__gte=h.join_date)
|
|
if h.leave_date:
|
|
tq &= Q(date__lte=h.leave_date)
|
|
team_periods_q |= tq
|
|
|
|
# 2. Récupérer les affectations d'interventions liées au membre
|
|
assignment_q = Q(member=member)
|
|
if team_periods_q:
|
|
assignment_q |= team_periods_q
|
|
|
|
assignments_qs = (
|
|
InterventionAssignment.objects
|
|
.filter(assignment_q)
|
|
.select_related('intervention', 'team')
|
|
.order_by('-date', '-intervention__id')
|
|
)
|
|
|
|
# 3. Dédoublonnage strict par ID d'intervention et exclusion des jours d'absence
|
|
seen_itv_ids = set()
|
|
interventions_dict = {}
|
|
|
|
for a in assignments_qs:
|
|
itv = a.intervention
|
|
if not itv or itv.id in seen_itv_ids:
|
|
continue
|
|
# Si le membre était absent ce jour-là et n'était pas nommément assigné
|
|
if a.date and a.date in absent_dates and a.member_id != member.id:
|
|
continue
|
|
seen_itv_ids.add(itv.id)
|
|
interventions_dict[itv.id] = {
|
|
'id': itv.id,
|
|
'code': itv.code,
|
|
'title': itv.title,
|
|
'date': a.date.strftime('%d/%m/%Y') if a.date else '',
|
|
'raw_date': a.date.isoformat() if a.date else '',
|
|
'team_name': a.team.name if a.team else (itv.assigned_team.name if itv.assigned_team else '—'),
|
|
'status_code': itv.status,
|
|
'status_display': itv.get_status_display(),
|
|
'detail_url': reverse('interventions:intervention_detail', args=[itv.id]),
|
|
}
|
|
|
|
# 4. Interventions directement assignées ou avec pointage/occupation du membre
|
|
direct_itvs_q = Q(assigned_member=member)
|
|
if member.user_id:
|
|
direct_itvs_q |= Q(occupations__member=member)
|
|
|
|
extra_itvs = (
|
|
Intervention.objects
|
|
.filter(direct_itvs_q)
|
|
.exclude(id__in=seen_itv_ids)
|
|
.select_related('assigned_team')
|
|
.distinct()[:50]
|
|
)
|
|
|
|
for itv in extra_itvs:
|
|
if itv.id in seen_itv_ids:
|
|
continue
|
|
|
|
itv_date = (itv.end_time or itv.begin_time or itv.planned_begin_time or itv.creation_time)
|
|
if itv_date:
|
|
itv_date_local = timezone.localtime(itv_date) if timezone.is_aware(itv_date) else itv_date
|
|
itv_date_only = itv_date_local.date()
|
|
if itv_date_only in absent_dates and itv.assigned_member_id != member.id and not itv.occupations.filter(member=member).exists():
|
|
continue
|
|
date_str = itv_date_local.strftime('%d/%m/%Y')
|
|
raw_date_str = itv_date_local.date().isoformat()
|
|
else:
|
|
date_str = ''
|
|
raw_date_str = ''
|
|
|
|
seen_itv_ids.add(itv.id)
|
|
interventions_dict[itv.id] = {
|
|
'id': itv.id,
|
|
'code': itv.code,
|
|
'title': itv.title,
|
|
'date': date_str,
|
|
'raw_date': raw_date_str,
|
|
'team_name': itv.assigned_team.name if itv.assigned_team else '—',
|
|
'status_code': itv.status,
|
|
'status_display': itv.get_status_display(),
|
|
'detail_url': reverse('interventions:intervention_detail', args=[itv.id]),
|
|
}
|
|
|
|
interventions_list = list(interventions_dict.values())
|
|
interventions_list.sort(key=lambda x: (x['raw_date'], x['code'] or '', x['id']), reverse=True)
|
|
interventions_list = interventions_list[:50]
|
|
|
|
return JsonResponse({
|
|
'member': {
|
|
'id': member.id,
|
|
'name': member.name,
|
|
'company': company_str,
|
|
},
|
|
'team_history': team_history,
|
|
'interventions': interventions_list,
|
|
})
|
|
|
|
|
|
@never_cache
|
|
def team_daily_members_ajax(request, team_id):
|
|
"""
|
|
Renvoie la composition d'une équipe à une date donnée, ainsi que les
|
|
membres éligibles disponibles pour un éventuel ajout.
|
|
"""
|
|
if not request.user.is_authenticated:
|
|
return JsonResponse({'error': _("Authentification requise.")}, status=403)
|
|
|
|
date_str = request.GET.get('date')
|
|
if not date_str:
|
|
return JsonResponse({'error': _("Paramètre date requis.")}, status=400)
|
|
|
|
from django.utils.dateparse import parse_date
|
|
target_date = parse_date(date_str)
|
|
if not target_date:
|
|
return JsonResponse({'error': _("Date invalide.")}, status=400)
|
|
|
|
viewable_teams = get_viewable_teams_for_user(request.user)
|
|
team = get_object_or_404(CompanyTeam.objects.select_related('company'), id=team_id)
|
|
if not viewable_teams.filter(id=team.id).exists():
|
|
return JsonResponse({'error': _("Accès non autorisé à cette équipe.")}, status=403)
|
|
|
|
can_edit = user_can_edit_specific_team(request.user, team.id)
|
|
today = timezone.now().date()
|
|
is_past = target_date < today
|
|
|
|
# Composition de l'équipe à cette date
|
|
compositions = get_teams_composition_for_period([team.id], target_date, target_date)
|
|
raw_members = compositions.get((team.id, target_date), [])
|
|
member_ids = {m['id'] for m in raw_members}
|
|
|
|
absences = get_member_absences_for_period(member_ids, target_date, target_date)
|
|
|
|
current_members = []
|
|
for m in raw_members:
|
|
mid = m['id']
|
|
abs_info = absences.get((mid, target_date))
|
|
current_members.append({
|
|
'id': mid,
|
|
'name': m['name'],
|
|
'is_absent': abs_info is not None,
|
|
'absence_reason': abs_info.get('reason_display') if abs_info else '',
|
|
'is_multi_team': m.get('is_multi_team', False),
|
|
'other_teams': m.get('other_teams', []),
|
|
})
|
|
|
|
# Membres candidats disponibles pour l'ajout (excluant ceux déjà dans l'équipe ce jour-là)
|
|
available_members = []
|
|
if can_edit and not is_past:
|
|
user_config = getattr(request.user, 'config', None)
|
|
user_roles = set(user_config.roles.values_list('name', flat=True)) if user_config else set()
|
|
is_admin_user = bool(user_roles & {'admin', 'top_manager'}) or request.user.is_superuser
|
|
user_company = getattr(user_config, 'company', None) if user_config else None
|
|
|
|
candidates_qs = CompanyMember.objects.select_related('user', 'user__config').order_by('name')
|
|
|
|
if team.company_id:
|
|
candidates_qs = candidates_qs.filter(
|
|
Q(user__config__company_id=team.company_id) | Q(teams__company_id=team.company_id)
|
|
).distinct()
|
|
elif not is_admin_user and user_company is not None:
|
|
candidates_qs = candidates_qs.filter(user__config__company=user_company)
|
|
|
|
candidates_qs = candidates_qs.exclude(id__in=member_ids)
|
|
|
|
for cand in candidates_qs:
|
|
available_members.append({
|
|
'id': cand.id,
|
|
'name': cand.name,
|
|
'user_display': cand.user.get_full_name() or cand.user.username if cand.user else '',
|
|
})
|
|
|
|
from django.utils.dateformat import format as dj_format_date
|
|
date_display = dj_format_date(target_date, 'l d F Y')
|
|
|
|
return JsonResponse({
|
|
'team': {
|
|
'id': team.id,
|
|
'name': team.name,
|
|
'company': team.company.name if team.company else '',
|
|
},
|
|
'date': target_date.isoformat(),
|
|
'date_display': date_display,
|
|
'is_past': is_past,
|
|
'can_edit': can_edit and not is_past,
|
|
'current_members': current_members,
|
|
'available_members': available_members,
|
|
})
|
|
|
|
|
|
@login_required
|
|
@require_GET
|
|
def intervention_calendar_details_ajax(request, intervention_id):
|
|
"""
|
|
Retourne les notes et documents d'une intervention pour la modale du calendrier,
|
|
organisés sous forme chronologique (phases: avant réalisation, exécution, après réalisation).
|
|
"""
|
|
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
|
|
|
if not (can_view_intervention(request.user, intervention) or can_view_intervention_summary(request.user, intervention)):
|
|
return HttpResponseForbidden(_("Vous n'avez pas la permission de voir cette intervention."))
|
|
|
|
note_priority_case = Case(
|
|
When(note_type=InterventionNote.NoteType.EQUIPMENT, then=Value(0)),
|
|
When(note_type=InterventionNote.NoteType.IMPORTANT_INFO, then=Value(1)),
|
|
When(note_type=InterventionNote.NoteType.ORIGIN, then=Value(2)),
|
|
default=Value(3),
|
|
output_field=IntegerField(),
|
|
)
|
|
|
|
notes = (
|
|
InterventionNote.objects
|
|
.filter(intervention=intervention)
|
|
.select_related('note_author', 'updated_by')
|
|
.annotate(type_priority_order=note_priority_case)
|
|
.order_by('type_priority_order', 'note_time', 'id')
|
|
)
|
|
|
|
sequenced_data = get_sequenced_intervention_documents(intervention, notes_qs=notes)
|
|
|
|
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg'}
|
|
for block in sequenced_data['blocks']:
|
|
for doc in block['documents']:
|
|
fn = getattr(doc, 'filename', '') or ''
|
|
ext = os.path.splitext(fn)[1].lower() if fn else ''
|
|
doc.ext = ext
|
|
doc.is_image = bool(getattr(doc, 'thumbnail', None) or ext in IMAGE_EXTENSIONS)
|
|
doc.is_pdf = ext == '.pdf'
|
|
|
|
context = {
|
|
'intervention': intervention,
|
|
'sequenced_blocks': sequenced_data['blocks'],
|
|
'notes_count': sequenced_data['total_notes_count'],
|
|
'documents_count': sequenced_data['total_documents_count'],
|
|
'total_count': sequenced_data['total_items_count'],
|
|
'has_items': sequenced_data['has_items'],
|
|
}
|
|
|
|
html = render_to_string(
|
|
'interventions/partials/calendar_intervention_notes_docs.html',
|
|
context,
|
|
request=request,
|
|
)
|
|
|
|
return JsonResponse({
|
|
'success': True,
|
|
'notes_count': sequenced_data['total_notes_count'],
|
|
'documents_count': sequenced_data['total_documents_count'],
|
|
'total_count': sequenced_data['total_items_count'],
|
|
'html': html,
|
|
})
|
|
|