1254 lines
54 KiB
Python
1254 lines
54 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'))
|
|
orders = _expand_csv_params(request.GET.getlist('order'))
|
|
codes = _expand_csv_params(request.GET.getlist('code') + request.GET.getlist('codes'))
|
|
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:
|
|
thematic_list = _expand_csv_params(thematics)
|
|
if thematic_list:
|
|
t_ids = [t for t in thematic_list if t.isdigit()]
|
|
t_codes = [t for t in thematic_list if not t.isdigit()]
|
|
thematic_q = Q()
|
|
if t_ids:
|
|
thematic_q |= Q(thematic_id__in=t_ids)
|
|
if t_codes:
|
|
thematic_q |= Q(thematic__code__in=t_codes)
|
|
qs = qs.filter(thematic_q)
|
|
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:
|
|
c_ids = [c for c in contracts if c.isdigit()]
|
|
c_codes = [c for c in contracts if not c.isdigit()]
|
|
contract_q = Q()
|
|
if c_ids:
|
|
contract_q |= Q(contract_id__in=c_ids)
|
|
if c_codes:
|
|
contract_q |= Q(contract__contract_number__in=c_codes)
|
|
qs = qs.filter(contract_q)
|
|
if orders:
|
|
order_q = Q()
|
|
has_null = False
|
|
has_not_null = False
|
|
o_ids = []
|
|
o_codes = []
|
|
for o in orders:
|
|
o_lower = o.lower()
|
|
if o_lower in ('null', 'none', '0', 'false', 'without', 'no_order', 'without_order'):
|
|
has_null = True
|
|
elif o_lower in ('with', 'has_order', 'not_null', 'with_order', 'true'):
|
|
has_not_null = True
|
|
elif o.isdigit():
|
|
o_ids.append(int(o))
|
|
else:
|
|
o_codes.append(o)
|
|
if has_null:
|
|
order_q |= Q(order__isnull=True)
|
|
if has_not_null:
|
|
order_q |= Q(order__isnull=False)
|
|
if o_ids:
|
|
order_q |= Q(order_id__in=o_ids)
|
|
if o_codes:
|
|
order_q |= Q(order__order_code__in=o_codes)
|
|
qs = qs.filter(order_q)
|
|
if codes:
|
|
qs = qs.filter(code__in=codes)
|
|
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)
|
|
|
|
# --- 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]')
|
|
|
|
from django.utils.functional import Promise
|
|
def _sanitize_xlsx(value):
|
|
if isinstance(value, Promise):
|
|
value = str(value)
|
|
if isinstance(value, str):
|
|
return _illegal_chars_re.sub('', value)
|
|
return value
|
|
|
|
wb = Workbook()
|
|
ws = wb.active
|
|
ws.title = 'Interventions'
|
|
|
|
# headers
|
|
ws.append([str(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)
|
|
|