loko/streetup/interventions/views/general.py

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