loko/streetup/interventions/views/calendar.py
2026-07-22 14:48:40 +02:00

1306 lines
56 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.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()
interventions = interventions.annotate(
_prego_str=Cast('origin_prego_nbr', output_field=CharField()),
_fms_str=Cast('origin_fms_nbr', output_field=CharField()),
)
def _word_q(word):
return (
Q(title__icontains=word)
| Q(code__icontains=word)
| Q(location_code__icontains=word)
| Q(contract__contract_number__icontains=word)
| Q(_prego_str__icontains=word)
| Q(_fms_str__icontains=word)
)
combined = Q()
for w in words:
combined &= _word_q(w)
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,
})