3895 lines
167 KiB
Python
3895 lines
167 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, get_type_choices
|
||
from interventions.services.document_sequencing import get_sequenced_intervention_documents
|
||
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, can_duplicate_intervention,
|
||
intervention_manage_occupations_required,
|
||
get_contracts_for_intervention_creation, is_external_manager_with_contract_creation_rights,
|
||
should_show_created_by_provider_badge, can_view_correction_messages)
|
||
from interventions.permissions import ALLOWED_TRANSITIONS, get_allowed_transitions, user_allowed_transitions
|
||
from interventions.views.drafts import InterventionDraftManager
|
||
from interventions.utils import resolve_intervention_localisations
|
||
|
||
from common.models import UserConfig, UserContractAccess, UserContractStatusPermission, Thematic, UserThematics
|
||
from contracts.models import ContractOrder, ContractPost, Contract, Company, CompanyTeam, CompanyMember, UserCompanyTeamAccess, CompanyMemberHistory
|
||
from contracts.permissions import get_allowed_contracts_for_user, user_has_access_to_contract_order
|
||
from projects.models import Project, ProjectIntervention
|
||
from assets.models import (
|
||
PublicLightingStreet,
|
||
TrafficLightIntersection, TrafficLightContract,
|
||
NatureLocation,
|
||
StructureLocation, Structure, StructureContract,
|
||
RoadStreet,
|
||
SignStreet,
|
||
AssetCategory,
|
||
AbstractAsset,
|
||
AbstractAssetModel,
|
||
ITSGeoAsset, ITSGeoAssetContract,
|
||
CleanLocation, CleanLocationContract,
|
||
)
|
||
from assets.permissions import get_allowed_actions_for_asset
|
||
from assets.views.locations import LOCATION_REGISTRY, registry_items_for_thematics, base_search_qs, SEARCH_LIMIT_PER_TYPE, SEARCH_LIMIT_TOTAL
|
||
|
||
from common.utils import redirect_with_variant, measure_time, strip_accents_lower
|
||
from observations.permissions import get_observation_access_context
|
||
from observations.models import Observation as ObservationModel
|
||
|
||
from controls.permissions import user_can_view_control
|
||
|
||
from interventions.views.views_pdf_reporting import (
|
||
generate_intervention_pdf,
|
||
preview_intervention_pdf,
|
||
download_intervention_report_pdf,
|
||
preview_intervention_report_pdf,
|
||
bulk_execution_report_pdf,
|
||
start_bulk_execution_report,
|
||
poll_bulk_execution_report,
|
||
download_bulk_execution_report,
|
||
)
|
||
|
||
import logging
|
||
import smtplib
|
||
import socket
|
||
import ssl
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
from interventions.views.helpers import *
|
||
|
||
def intervention_detail(request, intervention_id):
|
||
# Redirection mobile si User-Agent correspond et pas de force_desktop
|
||
user_agent = request.META.get('HTTP_USER_AGENT', '').lower()
|
||
# Mots-clés courants pour mobiles et tablettes
|
||
mobile_patterns = [r'android', r'iphone', r'ipad', r'ipod', r'mobile', r'blackberry', r'opera mini', r'iemobile']
|
||
|
||
if not request.GET.get('force_desktop') and any(re.search(pattern, user_agent) for pattern in mobile_patterns):
|
||
return redirect('interventions:intervention_detail_mobile', intervention_id=intervention_id)
|
||
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
intervention.prep_state = intervention.get_preparation_orders_status()
|
||
# user_config = get_object_or_404(UserConfig, user=request.user)
|
||
user_config = (UserConfig.objects
|
||
.select_related('user')
|
||
.prefetch_related('roles',
|
||
Prefetch('userthematics',
|
||
queryset=UserThematics.objects.select_related('thematic')))
|
||
.filter(user=request.user).first())
|
||
if not user_config:
|
||
return HttpResponseForbidden(_("You do not have access to this page."))
|
||
|
||
lang = get_language()
|
||
|
||
contract = intervention.contract or (intervention.order.contract if intervention.order else None)
|
||
thematic = intervention.thematic
|
||
|
||
allowed_update_fields = get_allowed_update_fields(request.user, intervention)
|
||
can_edit_notes = 'notes' in allowed_update_fields
|
||
allowed_transitions = get_allowed_transitions(request, intervention)
|
||
|
||
# Posts of the contract
|
||
contract_posts = None
|
||
total_general = None
|
||
total_validated = None
|
||
can_view_posts = False
|
||
orders = None
|
||
quote_remaining_available = False
|
||
user_contract_access = None
|
||
can_check_intervention = False
|
||
if contract:
|
||
# Get the user's access to the contract
|
||
user_contract_access = UserContractAccess.objects.filter(user_config=user_config, contract=contract).first()
|
||
|
||
# Droit de vérification : contrat avec needs_checking=True ET utilisateur avec can_check_interventions
|
||
if user_contract_access and getattr(contract, 'needs_checking', False) and user_contract_access.can_check_interventions:
|
||
can_check_intervention = True
|
||
|
||
# Filter the positions associated with this intervention
|
||
if user_contract_access and user_contract_access.can_view_posts:
|
||
can_view_posts = True
|
||
contract_posts = list(
|
||
InterventionContractPost.objects.filter(intervention=intervention)
|
||
.select_related('contract_post')
|
||
)
|
||
|
||
quote_remaining_available = False
|
||
if intervention.order:
|
||
quote = getattr(intervention.order, 'quote', None)
|
||
if quote:
|
||
quote_remaining_available = True
|
||
remaining_items = quote.get_remaining_items(
|
||
exclude_intervention_id=intervention.id,
|
||
include_zero=True,
|
||
include_current_intervention_periods=intervention.id,
|
||
)
|
||
remaining_map = {
|
||
item['contract_post'].id: {
|
||
'remaining_quantity': item['remaining_quantity'],
|
||
'planned_quantity': item.get('planned_quantity', 0)
|
||
}
|
||
for item in remaining_items
|
||
}
|
||
for post in contract_posts:
|
||
quote_data = remaining_map.get(post.contract_post_id, {})
|
||
post.quote_remaining_quantity = quote_data.get('remaining_quantity')
|
||
post.quote_planned_quantity = quote_data.get('planned_quantity')
|
||
else:
|
||
for post in contract_posts:
|
||
post.quote_remaining_quantity = None
|
||
post.quote_planned_quantity = None
|
||
else:
|
||
for post in contract_posts:
|
||
post.quote_remaining_quantity = None
|
||
post.quote_planned_quantity = None
|
||
|
||
# Calculating the grand total
|
||
total_general = sum(item.total_price for item in contract_posts)
|
||
total_validated = sum(item.total_price for item in contract_posts if item.control_status == 'validated')
|
||
|
||
# Vérifier si au moins un poste est multipliable par coefficient
|
||
has_coefficient_posts = any(
|
||
item.contract_post.is_coefficient_multipliable
|
||
for item in contract_posts
|
||
)
|
||
else:
|
||
has_coefficient_posts = False
|
||
|
||
orders_qs = ContractOrder.objects.filter(contract=contract)
|
||
if intervention.order_id:
|
||
orders = orders_qs.filter(
|
||
Q(id=intervention.order_id) | ~Q(order_status__in=['cancelled', 'paid'])
|
||
).distinct().select_related('contract').order_by('order_code')
|
||
else:
|
||
orders = orders_qs.exclude(order_status__in=['cancelled', 'paid']).select_related('contract').order_by('order_code')
|
||
elif intervention.order_id:
|
||
has_coefficient_posts = False
|
||
orders = ContractOrder.objects.filter(id=intervention.order_id).select_related('contract')
|
||
else:
|
||
has_coefficient_posts = False
|
||
orders = ContractOrder.objects.none()
|
||
|
||
contract_id = contract.id if contract else None
|
||
|
||
# Limiter les providers et contracts selon les permissions de l'utilisateur
|
||
if user_config.is_intern and not user_config.limit_interventions_to_contracts:
|
||
# Utilisateurs internes non limités aux contrats : accès à tous les prestataires et contrats
|
||
providers = Company.objects.all()
|
||
if intervention.thematic:
|
||
providers = providers.filter(contracts__thematics=intervention.thematic).distinct()
|
||
|
||
contracts_qs = Contract.objects.filter(company=intervention.assigned_provider)
|
||
# Filtrer par thématique si l'intervention en a une
|
||
if intervention.thematic:
|
||
contracts_qs = contracts_qs.filter(thematics=intervention.thematic)
|
||
contracts = contracts_qs.distinct()
|
||
else:
|
||
# Utilisateurs limités aux contrats (externes ou avec limit_interventions_to_contracts=True)
|
||
accessible_contracts_ids = UserContractAccess.objects.filter(
|
||
user_config=user_config,
|
||
can_view_interventions=True
|
||
).values_list('contract_id', flat=True)
|
||
|
||
accessible_contracts_qs = Contract.objects.filter(id__in=accessible_contracts_ids)
|
||
if intervention.thematic:
|
||
accessible_contracts_qs = accessible_contracts_qs.filter(thematics=intervention.thematic)
|
||
accessible_contracts_qs = accessible_contracts_qs.distinct()
|
||
|
||
# Prestataires limités à ceux des contrats accessibles
|
||
providers = Company.objects.filter(
|
||
contracts__id__in=accessible_contracts_qs.values_list('id', flat=True)
|
||
).distinct()
|
||
|
||
# Contrats limités à ceux accessibles ET associés au prestataire actuel
|
||
if intervention.assigned_provider:
|
||
contracts_qs = accessible_contracts_qs.filter(company=intervention.assigned_provider)
|
||
else:
|
||
contracts_qs = accessible_contracts_qs
|
||
|
||
contracts = contracts_qs.distinct()
|
||
|
||
projects = Project.objects.filter(thematics=intervention.thematic) if intervention.thematic else None
|
||
project_interv = ProjectIntervention.objects.filter(intervention=intervention).first()
|
||
project = project_interv.project if project_interv else None
|
||
|
||
# Assets liés via la table générique
|
||
linked_assets = InterventionAsset.objects.filter(intervention=intervention)
|
||
linked_assets = sorted(
|
||
linked_assets,
|
||
key=lambda ia: (ia.asset.get_name() if ia.asset and hasattr(ia.asset, 'get_name') else ia.asset.code if ia.asset else '').lower()
|
||
)
|
||
|
||
linked_groups = _build_link_groups(intervention)
|
||
|
||
operations = list(
|
||
intervention.operations.all()
|
||
.select_related('status', 'measurement_definition', 'asset_content_type')
|
||
.order_by('asset_content_type_id', 'asset_object_id', 'template_group', 'order')
|
||
)
|
||
operations_grouped = _group_operations_by_asset_and_template(operations, linked_assets)
|
||
general_operations_group = next((group for group in operations_grouped if not group["has_asset"]), None)
|
||
asset_operations_groups = [group for group in operations_grouped if group["has_asset"]]
|
||
# Trier les groupes d'opérations équipement par le nom de l'équipement
|
||
asset_operations_groups.sort(
|
||
key=lambda g: (g["asset"].get_name() if g["asset"] and hasattr(g["asset"], 'get_name') else g["asset"].code if g["asset"] else '').lower()
|
||
)
|
||
|
||
|
||
# Notes
|
||
current_time = now()
|
||
|
||
note_priority_case = Case(
|
||
When(note_type=InterventionNote.NoteType.EQUIPMENT, then=Value(0)),
|
||
When(note_type=InterventionNote.NoteType.IMPORTANT_INFO, then=Value(1)),
|
||
When(note_type=InterventionNote.NoteType.ORIGIN, then=Value(2)),
|
||
default=Value(3),
|
||
output_field=IntegerField(),
|
||
)
|
||
|
||
notes = (
|
||
InterventionNote.objects
|
||
.select_related('note_author', 'intervention', 'intervention__thematic')
|
||
.filter(intervention=intervention)
|
||
.annotate(type_priority_order=note_priority_case)
|
||
.order_by('type_priority_order', 'note_time')
|
||
)
|
||
|
||
editable_note_ids = {n.id for n in notes if can_edit_note(request.user, user_config, n)}
|
||
|
||
deletable_document_ids = {d.id for d in intervention.documents.all() if can_delete_document(request.user, user_config, d)}
|
||
|
||
occupations = (InterventionOccupation.objects
|
||
.select_related('member__user', 'created_by', 'updated_by')
|
||
.prefetch_related('member__teams__company')
|
||
.filter(intervention=intervention)
|
||
.order_by('created_at'))
|
||
total_occupation_minutes = intervention.get_total_occupation_minutes()
|
||
can_manage_occupation_entries = can_manage_occupations(request.user, intervention, user_config=user_config)
|
||
show_occupations = getattr(intervention.contract, 'uses_occupations', False)
|
||
occupations_required = (
|
||
getattr(intervention.contract, 'occupations_required', False)
|
||
and intervention.maintain_type == 'corrective'
|
||
)
|
||
occupation_members_qs = (_get_available_occupation_members(request.user, intervention)
|
||
if can_manage_occupation_entries else CompanyMember.objects.none())
|
||
occupation_members = list(occupation_members_qs)
|
||
occupation_form = (InterventionOccupationForm(members_queryset=occupation_members_qs, user=request.user)
|
||
if can_manage_occupation_entries else None)
|
||
|
||
can_control = can_control_intervention(request.user, intervention)
|
||
|
||
categories_qs = AssetCategory.objects.all()
|
||
allowed_category_ids = _get_allowed_asset_category_ids(intervention)
|
||
if allowed_category_ids:
|
||
categories_qs = categories_qs.filter(id__in=allowed_category_ids)
|
||
elif intervention.thematic_id:
|
||
categories_qs = categories_qs.filter(
|
||
Q(thematic=intervention.thematic) | Q(thematic__isnull=True)
|
||
)
|
||
order_by_field = 'name_nl' if lang == 'nl' else 'name_fr'
|
||
categories_qs = categories_qs.order_by(order_by_field)
|
||
|
||
is_draft = intervention.status in ['in_preparation', 'to_be_approved']
|
||
|
||
recommended_next_status = None
|
||
if is_draft and intervention.maintain_type == 'preventive':
|
||
recommended_next_status = 'to_be_planned'
|
||
elif is_draft and intervention.maintain_type == 'corrective':
|
||
recommended_next_status = 'to_be_processed'
|
||
if intervention.symptom:
|
||
recommended_next_status = intervention.symptom.recommended_next_status or recommended_next_status
|
||
elif intervention.status == 'to_be_planned':
|
||
recommended_next_status = 'to_be_processed'
|
||
|
||
is_user_intern = user_config.is_intern
|
||
|
||
geom_geojson = None
|
||
if intervention.geom:
|
||
geom_4326 = intervention.geom.transform(4326, clone=True)
|
||
|
||
if not geom_4326.empty:
|
||
point_4326 = geom_4326.centroid
|
||
intervention.lon = point_4326.x
|
||
intervention.lat = point_4326.y
|
||
else:
|
||
intervention.lon = None
|
||
intervention.lat = None
|
||
|
||
geom_geojson = {
|
||
"type": "FeatureCollection",
|
||
"features": [
|
||
{
|
||
"type": "Feature",
|
||
"geometry": json.loads(geom_4326.geojson),
|
||
"properties": {
|
||
"code": intervention.code,
|
||
"title": intervention.title
|
||
}
|
||
}
|
||
]
|
||
}
|
||
|
||
can_create_repair = (
|
||
intervention.maintain_type != 'corrective' and
|
||
can_add_repair_intervention(request.user, intervention)
|
||
)
|
||
|
||
can_manage_links = can_edit_intervention(request.user, intervention, user_config=user_config)
|
||
can_send_reminder = bool(user_config and user_config.is_intern and can_manage_links and intervention.status in ['to_be_processed',])
|
||
|
||
# Historique des interventions liées aux mêmes localisations (3 derniers mois)
|
||
locations_qs = list(
|
||
InterventionLocation.objects
|
||
.filter(intervention=intervention)
|
||
.select_related('content_type')
|
||
)
|
||
location_history_interventions = []
|
||
if locations_qs:
|
||
three_months_ago = now() - timedelta(days=90)
|
||
location_pairs = [
|
||
(loc.content_type_id, loc.object_id) for loc in locations_qs
|
||
]
|
||
if location_pairs:
|
||
# Trouver les interventions liées aux mêmes localisations
|
||
related_intervention_ids = (
|
||
InterventionLocation.objects
|
||
.filter(
|
||
content_type_id__in=[p[0] for p in location_pairs],
|
||
object_id__in=[p[1] for p in location_pairs],
|
||
)
|
||
.exclude(intervention_id=intervention.id)
|
||
.values_list('intervention_id', flat=True)
|
||
.distinct()
|
||
)
|
||
# Récupérer les interventions des 3 derniers mois
|
||
_latest_finished_sq = InterventionTimeLine.objects.filter(
|
||
intervention=OuterRef('pk'),
|
||
to_status='finished',
|
||
).order_by('-event_time').values('event_time')[:1]
|
||
related_interventions = (
|
||
Intervention.objects
|
||
.annotate(
|
||
effective_end=Coalesce(
|
||
Subquery(_latest_finished_sq),
|
||
'end_time',
|
||
'planned_end_time',
|
||
'expected_end_time',
|
||
'creation_time',
|
||
output_field=DateTimeField(),
|
||
),
|
||
)
|
||
.filter(
|
||
id__in=related_intervention_ids,
|
||
effective_end__gte=three_months_ago,
|
||
)
|
||
.exclude(status='canceled')
|
||
.select_related('thematic', 'contract', 'assigned_provider')
|
||
.order_by('-effective_end')[:20]
|
||
)
|
||
# Vérifier les permissions pour chaque intervention
|
||
for related_intv in related_interventions:
|
||
can_view = can_view_intervention(request.user, related_intv)
|
||
can_view_summary = can_view_intervention_summary(request.user, related_intv)
|
||
location_history_interventions.append({
|
||
'intervention': related_intv,
|
||
'can_view': can_view,
|
||
'can_view_summary': can_view_summary,
|
||
})
|
||
|
||
# Gestion des droits pour la validation par périodes
|
||
can_manage_period_validation = False
|
||
if user_config and user_config.is_intern and user_config.roles.filter(name__in=['admin', 'manager', 'controller']).exists():
|
||
can_manage_period_validation = True
|
||
|
||
future_planned_events = list(intervention.planned_events.all())
|
||
future_realized_events = list(intervention.realized_events.all())
|
||
|
||
# Filtrer les équipes du prestataire assigné par thématique
|
||
available_teams = []
|
||
if intervention.assigned_provider:
|
||
if intervention.thematic:
|
||
# Filtrer les équipes associées à la thématique de l'intervention
|
||
available_teams = list(
|
||
intervention.assigned_provider.teams
|
||
.filter(thematics=intervention.thematic)
|
||
.prefetch_related('members')
|
||
)
|
||
else:
|
||
# Si pas de thématique, afficher toutes les équipes du prestataire
|
||
available_teams = list(
|
||
intervention.assigned_provider.teams
|
||
.all()
|
||
.prefetch_related('members')
|
||
)
|
||
|
||
# Choix de type de note pour l'ajout (sans "origine" qui est réservé à la création)
|
||
add_note_type_choices = [
|
||
(value, label) for value, label in InterventionNote.NoteType.choices
|
||
if value != InterventionNote.NoteType.ORIGIN
|
||
]
|
||
|
||
# Filtrer les contrôles visibles par l'utilisateur
|
||
visible_controls = [
|
||
control for control in intervention.related_controls.all()
|
||
if user_can_view_control(request.user, control)
|
||
]
|
||
|
||
# Bons de préparation liés à l'intervention:
|
||
# - admin
|
||
# - ou droit d'édition sur l'intervention + droit de gérer les bons de préparation sur au moins un entrepôt
|
||
can_create_preparation_order = bool(
|
||
user_config and (
|
||
user_config.has_role('admin')
|
||
or (can_manage_links and user_config.can_manage_preparation_orders_any())
|
||
)
|
||
)
|
||
preparation_orders = (
|
||
intervention.preparation_orders.all()
|
||
.prefetch_related('preparationorderitem_set__product', 'other_items')
|
||
.order_by('-date')
|
||
if hasattr(intervention, 'preparation_orders') else []
|
||
)
|
||
material_stats = intervention.get_material_stats()
|
||
|
||
# Config desktop pour le modal de liaison d'assets (même mécanique que mobile)
|
||
desktop_assets_config = None
|
||
desktop_assets_location_labels: List[str] = []
|
||
if can_manage_links:
|
||
location_payload_desktop = []
|
||
for loc_link in locations_qs:
|
||
label = _format_location_label(getattr(loc_link, 'location', None))
|
||
if not label:
|
||
continue
|
||
location_payload_desktop.append({
|
||
"id": loc_link.id,
|
||
"label": label,
|
||
"content_type_id": loc_link.content_type_id,
|
||
"object_id": loc_link.object_id,
|
||
})
|
||
desktop_assets_location_labels = [entry['label'] for entry in location_payload_desktop]
|
||
|
||
existing_pairs_desktop = []
|
||
for ia in linked_assets:
|
||
if not ia.content_type_id or not ia.object_id:
|
||
continue
|
||
asset_obj = getattr(ia, 'asset', None)
|
||
display_label = str(asset_obj) if asset_obj else ''
|
||
existing_pairs_desktop.append({
|
||
"content_type_id": ia.content_type_id,
|
||
"object_id": ia.object_id,
|
||
"model": ia.content_type.model if ia.content_type else '',
|
||
"display_label": display_label,
|
||
"code": getattr(asset_obj, 'code', None),
|
||
})
|
||
|
||
desktop_assets_config = {
|
||
"interventionId": intervention.id,
|
||
"searchUrl": reverse("interventions:mobile_intervention_assets_search", args=[intervention.id]),
|
||
"filtersUrl": reverse("interventions:mobile_intervention_assets_filters", args=[intervention.id]),
|
||
"syncUrl": reverse("interventions:mobile_intervention_assets_sync", args=[intervention.id]),
|
||
"locationsUrl": reverse("interventions:mobile_intervention_assets_locations", args=[intervention.id]),
|
||
"maxResults": MAX_MOBILE_ASSET_RESULTS,
|
||
"existingPairs": existing_pairs_desktop,
|
||
"locations": location_payload_desktop,
|
||
"hasLocations": bool(location_payload_desktop),
|
||
}
|
||
|
||
# Observations liées à l'intervention
|
||
linked_observations = []
|
||
obs_access_context = get_observation_access_context(request.user, user_config=user_config)
|
||
if obs_access_context:
|
||
all_observations = intervention.get_associated_observations().select_related('thematic', 'category')
|
||
linked_observations = [obs for obs in all_observations if obs_access_context.allows(obs)]
|
||
|
||
# Observation source (si la source est une observation)
|
||
source_observation = None
|
||
if intervention.source_category and intervention.source_ref:
|
||
sc_names = [getattr(intervention.source_category, a, '') or '' for a in ('name_fr', 'name_nl')]
|
||
if any(n.strip().lower() == 'observation' for n in sc_names):
|
||
try:
|
||
obs = ObservationModel.objects.get(pk=int(intervention.source_ref))
|
||
if obs_access_context and obs_access_context.allows(obs):
|
||
source_observation = obs
|
||
except (ObservationModel.DoesNotExist, ValueError, TypeError):
|
||
pass
|
||
|
||
# Discussion liée à cette intervention
|
||
discussion = None
|
||
discussion_messages = []
|
||
can_post_discussion_message = False
|
||
try:
|
||
from django.contrib.contenttypes.models import ContentType
|
||
from notifications.models import Discussion
|
||
from notifications.services import mark_discussion_seen, can_post_intervention_message
|
||
ct = ContentType.objects.get_for_model(intervention)
|
||
discussion = Discussion.objects.filter(
|
||
content_type=ct, object_id=intervention.pk
|
||
).prefetch_related('messages__attachments', 'participants').first()
|
||
if discussion:
|
||
discussion_messages = list(
|
||
discussion.messages.filter(is_deleted=False)
|
||
.prefetch_related('attachments')
|
||
.order_by('created_at')
|
||
)
|
||
mark_discussion_seen(discussion, user=request.user)
|
||
can_post_discussion_message = can_post_intervention_message(request.user, intervention)
|
||
except Exception:
|
||
pass
|
||
|
||
# Localisations & copy targets for copy-document modal
|
||
localisations = resolve_intervention_localisations(intervention)
|
||
_copy_targets = []
|
||
for _loc in localisations:
|
||
_copy_targets.append({
|
||
"type": "location",
|
||
"ct_id": _loc["ct_id"],
|
||
"obj_id": _loc["obj_id"],
|
||
"label": _loc["name"],
|
||
})
|
||
for _ia in linked_assets:
|
||
if _ia.asset:
|
||
_label = _ia.asset.code
|
||
_name = _ia.asset.get_name() if hasattr(_ia.asset, 'get_name') else None
|
||
if _name:
|
||
_label = f"{_ia.asset.code} — {_name}"
|
||
_copy_targets.append({
|
||
"type": "asset",
|
||
"ct_id": _ia.content_type_id,
|
||
"obj_id": _ia.object_id,
|
||
"label": _label,
|
||
})
|
||
import json as _json_mod
|
||
copy_targets_json = _json_mod.dumps(_copy_targets)
|
||
|
||
sequenced_data = get_sequenced_intervention_documents(intervention, notes_qs=notes)
|
||
|
||
context = {
|
||
"intervention": intervention,
|
||
"status_choices" : [{"key": key, "label": label} for key, label in STATUS_CHOICES.items()],
|
||
"recommended_next_status": recommended_next_status,
|
||
"notes": notes,
|
||
"editable_note_ids": editable_note_ids,
|
||
"contract_posts": contract_posts,
|
||
"total_general": total_general,
|
||
"total_validated": total_validated,
|
||
'can_view_prices': user_contract_access.can_view_prices if user_contract_access else False,
|
||
'geom_geojson': geom_geojson,
|
||
'allowed_transitions': json.dumps(allowed_transitions),
|
||
'add_note_form': InterventionNoteForm(),
|
||
'note_type_choices': InterventionNote.NoteType.choices,
|
||
'add_note_type_choices': add_note_type_choices,
|
||
'default_note_type': InterventionNote.NoteType.COMMENT,
|
||
'add_document_form': InterventionDocumentForm(),
|
||
'deletable_document_ids': deletable_document_ids,
|
||
'linked_assets': linked_assets,
|
||
'operations_grouped': operations_grouped,
|
||
'operations_general_group': general_operations_group,
|
||
'operations_asset_groups': asset_operations_groups,
|
||
'orders': orders,
|
||
'contract_id': contract_id,
|
||
'contract_posts_count': contract.posts.count() if contract else 0,
|
||
'can_control': can_control,
|
||
'is_draft': is_draft,
|
||
'providers': providers,
|
||
'contracts': contracts,
|
||
'maintain_choices': MAINTAIN_CHOICES,
|
||
'type_choices': get_type_choices(intervention.thematic),
|
||
'origin_type_choices': ORIGIN_TYPE_CHOICES,
|
||
'priority_choices': Intervention._meta.get_field('priority').choices,
|
||
'symptoms': Symptom.objects.filter(is_active=True, thematic=intervention.thematic).order_by('name_fr'),
|
||
'categories': categories_qs,
|
||
'source_categories': SourceCategory.objects.filter(is_active=True).order_by('name_fr'),
|
||
'occupations': occupations,
|
||
'show_occupations': show_occupations,
|
||
'occupations_required': occupations_required,
|
||
# Avertissement si aucune prestation au passage à 'Traité'
|
||
'contract_warn_no_prestation_on_processed': bool(contract and getattr(contract, 'warn_if_no_prestation_on_processed', False)),
|
||
'has_prestations_encoded': intervention.interv_contract_posts.exists(),
|
||
'total_occupation_minutes': total_occupation_minutes,
|
||
'can_manage_occupations': can_manage_occupation_entries,
|
||
'occupation_members': occupation_members,
|
||
'occupation_form': occupation_form,
|
||
'projects': projects,
|
||
'project': project,
|
||
'is_user_intern': is_user_intern,
|
||
'allowed_update_fields': allowed_update_fields,
|
||
'can_edit_notes': can_edit_notes,
|
||
'can_manage_posts': 'posts' in allowed_update_fields,
|
||
'can_view_posts': can_view_posts,
|
||
'can_manage_posts': 'posts' in allowed_update_fields,
|
||
'can_edit_operation_comments': 'operations' in allowed_update_fields,
|
||
'quote_remaining_available': quote_remaining_available,
|
||
'has_coefficient_posts': has_coefficient_posts,
|
||
"localisations": localisations,
|
||
"copy_targets_json": copy_targets_json,
|
||
'hide_map_by_default': user_config.hide_intervention_map_by_default,
|
||
'pause_reason_choices': PAUSE_REASON_CHOICES,
|
||
'linked_groups': linked_groups,
|
||
'can_manage_links': can_manage_links,
|
||
'link_existing_form': LinkExistingInterventionsForm(),
|
||
'can_create_repair': can_create_repair,
|
||
'can_duplicate_intervention': can_duplicate_intervention(request.user, intervention),
|
||
'can_send_reminder': can_send_reminder,
|
||
# Historique des interventions aux mêmes localisations
|
||
'location_history_interventions': location_history_interventions,
|
||
# Period validation
|
||
'can_manage_period_validation': can_manage_period_validation,
|
||
'period_validation_enabled': getattr(intervention, 'period_validation_enabled', False),
|
||
'period_number': getattr(intervention, 'period_number', 1),
|
||
'period_is_final': getattr(intervention, 'period_is_final', False),
|
||
'has_period_quantities': intervention.period_quantities.exists(),
|
||
'future_planned_events': future_planned_events,
|
||
'future_realized_events': future_realized_events,
|
||
# Historique des envois d'emails (ordre chronologique)
|
||
'email_logs': intervention.email_logs.order_by('sent_at'),
|
||
# Operation editing data (same as mobile)
|
||
'operation_status_sets': OperationStatusSet.objects.all(),
|
||
'measurement_definitions': MeasurementDefinition.objects.all(),
|
||
# Badge "créé par le prestataire"
|
||
'show_created_by_provider_badge': should_show_created_by_provider_badge(request.user, intervention),
|
||
# Rôles de l'utilisateur pour filtrer les boutons de transition (desktop)
|
||
'user_roles': list(user_config.roles.values_list('name', flat=True)) if user_config else [],
|
||
# Droit d'édition sur l'intervention (pour prise en charge par admin/manager/controller)
|
||
'can_edit_intervention': can_manage_links,
|
||
# Droit de prise en charge : nécessite un contrat ET un accès au contrat
|
||
'can_take_over': bool(contract and user_contract_access),
|
||
# Contrôles visibles par l'utilisateur
|
||
'visible_controls': visible_controls,
|
||
# Équipes filtrées par thématique
|
||
'available_teams': available_teams,
|
||
# Permission de voir les messages de correction
|
||
'can_view_correction_messages': can_view_correction_messages(request.user),
|
||
# Bons de préparation
|
||
'can_create_preparation_order': can_create_preparation_order,
|
||
'preparation_orders': preparation_orders,
|
||
'material_stats': material_stats,
|
||
# Config modal liaison assets (desktop)
|
||
'desktop_assets_enabled': can_manage_links,
|
||
'desktop_assets_config': desktop_assets_config,
|
||
'desktop_assets_location_labels': desktop_assets_location_labels,
|
||
'desktop_assets_max_results': MAX_MOBILE_ASSET_RESULTS,
|
||
# Droit de vérification des interventions (can_check_interventions + needs_checking)
|
||
'can_check_intervention': can_check_intervention,
|
||
# Photos obligatoires pour terminer l'intervention (techniciens uniquement)
|
||
'photos_required': _is_photo_required_for_user(request.user, intervention, user_config=user_config),
|
||
'photos_required_since': (lambda s: s.isoformat() if s else '')(
|
||
_get_photos_required_since(intervention)
|
||
) if _is_photo_required_for_user(request.user, intervention, user_config=user_config) else '',
|
||
# Observations liées
|
||
'linked_observations': linked_observations,
|
||
# Observation source
|
||
'source_observation': source_observation,
|
||
# Discussion
|
||
'discussion': discussion,
|
||
'discussion_messages': discussion_messages,
|
||
'can_post_discussion_message': can_post_discussion_message,
|
||
# Sequenced notes & documents
|
||
'sequenced_blocks': sequenced_data['blocks'],
|
||
'document_blocks': sequenced_data['blocks'],
|
||
'all_sequenced_documents': sequenced_data['all_documents'],
|
||
'total_documents_count': sequenced_data['total_documents_count'],
|
||
'total_notes_count': sequenced_data['total_notes_count'],
|
||
'total_items_count': sequenced_data['total_items_count'],
|
||
'can_delete_documents': 'documents' in allowed_update_fields,
|
||
'can_copy_documents': 'documents' in allowed_update_fields and copy_targets_json != '[]',
|
||
# Guided operation template
|
||
'guided_template': intervention.guided_operation_template,
|
||
'guided_operation_data': getattr(intervention, 'guided_operation_data', None),
|
||
'guided_data_json': getattr(getattr(intervention, 'guided_operation_data', None), 'data', None) or {},
|
||
'save_guided_url': reverse('interventions:save_guided_operation_ajax', kwargs={'intervention_id': intervention_id}),
|
||
'upload_guided_media_url': reverse('interventions:upload_guided_operation_media', kwargs={'intervention_id': intervention_id}),
|
||
'guided_media_json': _build_guided_media_json(getattr(intervention, 'guided_operation_data', None)),
|
||
}
|
||
from interventions.models import InterventionSubscription
|
||
context['is_subscribed'] = InterventionSubscription.objects.filter(user=request.user, intervention=intervention).exists()
|
||
|
||
return render(request, "interventions/intervention_detail.html", context)
|
||
|
||
|
||
def link_existing_interventions(request, intervention_id):
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
|
||
user_config = (UserConfig.objects
|
||
.select_related('user')
|
||
.prefetch_related('roles')
|
||
.filter(user=request.user)
|
||
.first())
|
||
if not user_config:
|
||
return HttpResponseForbidden(_("Vous n'avez pas la permission de modifier cette intervention."))
|
||
|
||
if not can_edit_intervention(request.user, intervention, user_config=user_config):
|
||
return HttpResponseForbidden(_("Vous n'avez pas la permission de modifier cette intervention."))
|
||
|
||
form = LinkExistingInterventionsForm(request.POST)
|
||
if not form.is_valid():
|
||
for error in form.errors.get('codes', []):
|
||
messages.error(request, error)
|
||
return redirect('interventions:intervention_detail', intervention_id=intervention.id)
|
||
|
||
codes = form.cleaned_data['codes']
|
||
found_map = {
|
||
obj.code.upper(): obj
|
||
for obj in Intervention.objects.filter(code__in=codes)
|
||
}
|
||
|
||
created_codes = []
|
||
already_codes = []
|
||
missing_codes = []
|
||
restricted_codes = []
|
||
self_codes = []
|
||
|
||
for code in codes:
|
||
if code == intervention.code:
|
||
self_codes.append(code)
|
||
continue
|
||
|
||
target = found_map.get(code)
|
||
if not target:
|
||
missing_codes.append(code)
|
||
continue
|
||
|
||
if not can_view_intervention(request.user, target):
|
||
restricted_codes.append(code)
|
||
continue
|
||
|
||
link, created = InterventionLink.link(intervention, target, InterventionLink.LinkType.RELATED)
|
||
if created:
|
||
created_codes.append(target.code)
|
||
else:
|
||
already_codes.append(target.code)
|
||
|
||
if created_codes:
|
||
messages.success(
|
||
request,
|
||
ngettext(
|
||
"L'intervention %(codes)s a été liée à %(origin)s.",
|
||
"Les interventions %(codes)s ont été liées à %(origin)s.",
|
||
len(created_codes)
|
||
) % {
|
||
'codes': ", ".join(created_codes),
|
||
'origin': intervention.code,
|
||
}
|
||
)
|
||
|
||
if already_codes:
|
||
messages.info(
|
||
request,
|
||
ngettext(
|
||
"L'intervention %(codes)s était déjà liée à %(origin)s.",
|
||
"Les interventions %(codes)s étaient déjà liées à %(origin)s.",
|
||
len(already_codes)
|
||
) % {
|
||
'codes': ", ".join(already_codes),
|
||
'origin': intervention.code,
|
||
}
|
||
)
|
||
|
||
if restricted_codes:
|
||
messages.warning(
|
||
request,
|
||
ngettext(
|
||
"Vous n'avez pas accès à l'intervention %(codes)s.",
|
||
"Vous n'avez pas accès aux interventions %(codes)s.",
|
||
len(restricted_codes)
|
||
) % {
|
||
'codes': ", ".join(restricted_codes),
|
||
}
|
||
)
|
||
|
||
if missing_codes:
|
||
messages.error(
|
||
request,
|
||
ngettext(
|
||
"Aucune intervention trouvée pour le code %(codes)s.",
|
||
"Aucune intervention trouvée pour les codes %(codes)s.",
|
||
len(missing_codes)
|
||
) % {
|
||
'codes': ", ".join(missing_codes),
|
||
}
|
||
)
|
||
|
||
if self_codes:
|
||
messages.warning(
|
||
request,
|
||
ngettext(
|
||
"Le code %(codes)s correspond à l'intervention actuelle et a été ignoré.",
|
||
"Les codes %(codes)s correspondent à l'intervention actuelle et ont été ignorés.",
|
||
len(self_codes)
|
||
) % {
|
||
'codes': ", ".join(self_codes),
|
||
}
|
||
)
|
||
|
||
return redirect('interventions:intervention_detail', intervention_id=intervention.id)
|
||
|
||
|
||
def create_repair_intervention(request, intervention_id):
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
|
||
if not can_add_repair_intervention(request.user, intervention):
|
||
return HttpResponseForbidden(_("Vous n'avez pas la permission de créer une intervention de réparation pour cette thématique."))
|
||
|
||
variant = request.POST.get('variant')
|
||
if "/mobile/" in request.path:
|
||
variant = 'mobile'
|
||
|
||
|
||
assigned_member = CompanyMember.objects.filter(user=request.user).prefetch_related('teams__company').first()
|
||
if assigned_member and assigned_member.teams.all():
|
||
assigned_team = assigned_member.teams.all()[0]
|
||
assigned_provider = assigned_team.company
|
||
else:
|
||
assigned_member = intervention.assigned_member
|
||
assigned_team = intervention.assigned_team
|
||
assigned_provider = intervention.assigned_provider
|
||
|
||
repair_type = 'repair_post_preventive'
|
||
|
||
with transaction.atomic():
|
||
new_intervention = Intervention(
|
||
title=f"REPAIR ({intervention.title})",
|
||
description=f"{_('Réparation à effectuer')} ({_('origine')}: {intervention.code})",
|
||
maintain_type='corrective',
|
||
type=repair_type,
|
||
origin_type=intervention.origin_type,
|
||
priority=intervention.priority,
|
||
thematic=intervention.thematic,
|
||
asset_category=intervention.asset_category,
|
||
symptom=intervention.symptom,
|
||
contract=intervention.contract,
|
||
order=None,
|
||
assigned_provider=assigned_provider,
|
||
assigned_team=assigned_team,
|
||
assigned_member=assigned_member,
|
||
lon=intervention.lon,
|
||
lat=intervention.lat,
|
||
address=intervention.address,
|
||
location_code=intervention.location_code,
|
||
geom=intervention.geom,
|
||
source_category=None,
|
||
source_detail=intervention.code,
|
||
source_ref=intervention.code,
|
||
init_description=f"{_('Réparation à effectuer')} ({_('origine')}: {intervention.code})",
|
||
status='to_be_processed',
|
||
status_order=STATUS_ORDERS['to_be_processed'],
|
||
created_by=request.user,
|
||
)
|
||
new_intervention.save()
|
||
# Auto-vérification si l'utilisateur a le droit can_check_interventions
|
||
auto_check_if_eligible(request.user, new_intervention)
|
||
|
||
for location in intervention.locations.all():
|
||
InterventionLocation.objects.get_or_create(
|
||
intervention=new_intervention,
|
||
content_type=location.content_type,
|
||
object_id=location.object_id,
|
||
)
|
||
|
||
# Copier uniquement les équipements sélectionnés (ou tous si aucun filtre fourni)
|
||
if request.POST.get('assets_filter_enabled'):
|
||
selected_asset_ids = [int(v) for v in request.POST.getlist('selected_assets') if v.isdigit()]
|
||
assets_to_copy = intervention.interventionasset_set.filter(pk__in=selected_asset_ids)
|
||
else:
|
||
assets_to_copy = intervention.interventionasset_set.all()
|
||
for asset in assets_to_copy:
|
||
InterventionAsset.objects.get_or_create(
|
||
intervention=new_intervention,
|
||
content_type=asset.content_type,
|
||
object_id=asset.object_id,
|
||
)
|
||
|
||
for project_link in ProjectIntervention.objects.filter(intervention=intervention):
|
||
ProjectIntervention.objects.create(
|
||
intervention=new_intervention,
|
||
project=project_link.project,
|
||
phase=project_link.phase,
|
||
row_number=project_link.row_number,
|
||
)
|
||
|
||
InterventionLink.link(intervention, new_intervention, InterventionLink.LinkType.REPAIR)
|
||
|
||
InterventionTimeLine.objects.create(
|
||
intervention=new_intervention,
|
||
event_user=request.user,
|
||
event_time=now(),
|
||
event_type='creation',
|
||
from_status=None,
|
||
to_status=new_intervention.status,
|
||
)
|
||
|
||
messages.success(
|
||
request,
|
||
_("Une intervention de réparation %(code)s a été créée à partir de %(origin)s.") % {
|
||
'code': new_intervention.code,
|
||
'origin': intervention.code,
|
||
}
|
||
)
|
||
|
||
if variant == 'mobile':
|
||
return redirect('mobile:intervention_detail_mobile', intervention_id=new_intervention.id)
|
||
|
||
return redirect('interventions:intervention_detail', intervention_id=intervention.id)
|
||
|
||
|
||
def duplicate_intervention(request, intervention_id):
|
||
"""
|
||
Duplique une intervention existante:
|
||
- Recopie la plupart des éléments (titre, description, localisation, équipement, thématique, etc.).
|
||
- Recopie les opérations (statut réinitialisé).
|
||
- Recopie les notes de type 'origine' (note_type='origin').
|
||
- Recopie les documents.
|
||
- Ne recopie pas : prestataire, contrat, équipe/membre assigné, dates.
|
||
- Statut par défaut : in_preparation (En préparation).
|
||
- Relie automatiquement la nouvelle intervention à la précédente (InterventionLink).
|
||
"""
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
|
||
if not can_duplicate_intervention(request.user, intervention):
|
||
return HttpResponseForbidden(_("Vous n'avez pas la permission de dupliquer cette intervention."))
|
||
|
||
variant = request.POST.get('variant')
|
||
if "/mobile/" in request.path:
|
||
variant = 'mobile'
|
||
|
||
with transaction.atomic():
|
||
new_intervention = Intervention(
|
||
title=intervention.title,
|
||
description=intervention.description,
|
||
init_description=intervention.init_description,
|
||
maintain_type=intervention.maintain_type,
|
||
type=intervention.type,
|
||
origin_type=intervention.origin_type,
|
||
priority=intervention.priority,
|
||
thematic=intervention.thematic,
|
||
asset_category=intervention.asset_category,
|
||
symptom=intervention.symptom,
|
||
source_category=intervention.source_category,
|
||
source_detail=intervention.source_detail,
|
||
source_ref=intervention.source_ref,
|
||
pmo_number=intervention.pmo_number,
|
||
osiris=intervention.osiris,
|
||
lon=intervention.lon,
|
||
lat=intervention.lat,
|
||
address=intervention.address,
|
||
location_code=intervention.location_code,
|
||
geom=intervention.geom,
|
||
guided_operation_template=intervention.guided_operation_template,
|
||
status='in_preparation',
|
||
status_order=STATUS_ORDERS.get('in_preparation', 1),
|
||
created_by=request.user,
|
||
last_modified_by=request.user,
|
||
contract=None,
|
||
order=None,
|
||
intervention_manager=None,
|
||
assigned_provider=None,
|
||
assigned_team=None,
|
||
assigned_member=None,
|
||
begin_time=None,
|
||
end_time=None,
|
||
planned_begin_time=None,
|
||
planned_end_time=None,
|
||
expected_begin_time=None,
|
||
expected_end_time=None,
|
||
transmission_time=None,
|
||
init_call_confirmed_time=None,
|
||
init_call_received=False,
|
||
pause_reason=None,
|
||
pause_reason_other=None,
|
||
cancellation_type=None,
|
||
period_validation_enabled=False,
|
||
period_number=1,
|
||
period_is_final=False,
|
||
processed_via_other_intervention=False,
|
||
reason_for_correction=None,
|
||
correction_details=None,
|
||
follow_up_performed=None,
|
||
is_checked=False,
|
||
)
|
||
new_intervention.save()
|
||
auto_check_if_eligible(request.user, new_intervention)
|
||
|
||
# Copier les templates d'opérations équipements (M2M)
|
||
templates = list(intervention.equipment_operation_templates.all())
|
||
if templates:
|
||
new_intervention.equipment_operation_templates.set(templates)
|
||
|
||
# Copier les localisations liées
|
||
for location in intervention.locations.all():
|
||
InterventionLocation.objects.get_or_create(
|
||
intervention=new_intervention,
|
||
content_type=location.content_type,
|
||
object_id=location.object_id,
|
||
)
|
||
|
||
# Copier les équipements liés
|
||
for asset in intervention.interventionasset_set.all():
|
||
InterventionAsset.objects.get_or_create(
|
||
intervention=new_intervention,
|
||
content_type=asset.content_type,
|
||
object_id=asset.object_id,
|
||
)
|
||
|
||
# Copier les liaisons aux projets
|
||
for project_link in ProjectIntervention.objects.filter(intervention=intervention):
|
||
ProjectIntervention.objects.create(
|
||
intervention=new_intervention,
|
||
project=project_link.project,
|
||
phase=project_link.phase,
|
||
row_number=project_link.row_number,
|
||
)
|
||
|
||
# Copier les opérations (avec statut réinitialisé)
|
||
for op in intervention.operations.all():
|
||
Operation.objects.create(
|
||
intervention=new_intervention,
|
||
name_fr=op.name_fr,
|
||
name_nl=op.name_nl,
|
||
comment='',
|
||
status_set=op.status_set,
|
||
status=None,
|
||
measurement_definition=op.measurement_definition,
|
||
measured_value=None,
|
||
measured_duration=None,
|
||
measured_datetime=None,
|
||
is_not_applicable=False,
|
||
order=op.order,
|
||
template_group=op.template_group,
|
||
asset_content_type=op.asset_content_type,
|
||
asset_object_id=op.asset_object_id,
|
||
is_custom=op.is_custom,
|
||
is_generic=op.is_generic,
|
||
requires_photo=op.requires_photo,
|
||
)
|
||
|
||
# Copier uniquement les notes de type 'origine'
|
||
origin_notes = intervention.notes.filter(note_type=InterventionNote.NoteType.ORIGIN)
|
||
for note in origin_notes:
|
||
InterventionNote.objects.create(
|
||
intervention=new_intervention,
|
||
content=note.content,
|
||
note_time=note.note_time,
|
||
note_type=note.note_type,
|
||
note_author=note.note_author or request.user,
|
||
)
|
||
|
||
# Copier les documents
|
||
for doc in intervention.documents.all():
|
||
if doc.file:
|
||
try:
|
||
doc.file.open('rb')
|
||
InterventionDocument.objects.create(
|
||
intervention=new_intervention,
|
||
file=ContentFile(doc.file.read(), name=os.path.basename(doc.file.name)),
|
||
uploaded_by=doc.uploaded_by or request.user,
|
||
description=doc.description,
|
||
)
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
try:
|
||
doc.file.close()
|
||
except Exception:
|
||
pass
|
||
|
||
# Relier automatiquement à l'intervention précédente
|
||
InterventionLink.link(intervention, new_intervention, InterventionLink.LinkType.RELATED)
|
||
|
||
# Timeline de création
|
||
InterventionTimeLine.objects.create(
|
||
intervention=new_intervention,
|
||
event_user=request.user,
|
||
event_time=now(),
|
||
event_type='creation',
|
||
from_status=None,
|
||
to_status=new_intervention.status,
|
||
)
|
||
|
||
messages.success(
|
||
request,
|
||
_("L'intervention %(code)s a été dupliquée avec succès à partir de %(origin)s.") % {
|
||
'code': new_intervention.code,
|
||
'origin': intervention.code,
|
||
}
|
||
)
|
||
|
||
if variant == 'mobile':
|
||
return redirect('mobile:intervention_detail_mobile', intervention_id=new_intervention.id)
|
||
|
||
return redirect('interventions:intervention_detail', intervention_id=new_intervention.id)
|
||
|
||
|
||
|
||
def add_repair_intervention_modal(request):
|
||
"""
|
||
Vue AJAX pour créer une intervention de réparation via modale desktop.
|
||
Retourne du JSON pour permettre la réponse AJAX.
|
||
"""
|
||
from django.http import JsonResponse
|
||
from django.views.decorators.http import require_http_methods
|
||
|
||
# Vérifier les permissions
|
||
try:
|
||
user_config = UserConfig.objects.get(user=request.user)
|
||
except UserConfig.DoesNotExist:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Configuration utilisateur non trouvée.")
|
||
}, status=403)
|
||
|
||
# Vérifier que l'utilisateur a la thématique structures par défaut
|
||
default_thematic = user_config.default_thematic
|
||
if not default_thematic or default_thematic.code != 'structures':
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Cette fonctionnalité est réservée à la thématique structures.")
|
||
}, status=403)
|
||
|
||
# Vérifier que l'utilisateur est technicien avec droits d'édition
|
||
is_technician = user_config.roles.filter(name='technician').exists()
|
||
if not is_technician:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Cette fonctionnalité est réservée aux techniciens.")
|
||
}, status=403)
|
||
|
||
has_edit_rights = UserThematics.objects.filter(
|
||
user_config=user_config,
|
||
thematic=default_thematic,
|
||
can_edit_interventions=True
|
||
).exists()
|
||
if not has_edit_rights:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Vous n'avez pas les droits pour créer des interventions.")
|
||
}, status=403)
|
||
|
||
# Récupérer les données du formulaire
|
||
location_id = request.POST.get('location_id')
|
||
category_id = request.POST.get('category_id')
|
||
asset_id = request.POST.get('asset_id')
|
||
title = request.POST.get('title', '').strip()
|
||
|
||
# Validation
|
||
errors = []
|
||
if not location_id:
|
||
errors.append(_("La localisation est obligatoire."))
|
||
if not category_id:
|
||
errors.append(_("La catégorie est obligatoire."))
|
||
if not title:
|
||
errors.append(_("Le titre est obligatoire."))
|
||
|
||
if errors:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': ' '.join(errors)
|
||
}, status=400)
|
||
|
||
# Récupérer la localisation
|
||
try:
|
||
location = StructureLocation.objects.select_related('structure').get(id=location_id)
|
||
except StructureLocation.DoesNotExist:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Localisation non trouvée.")
|
||
}, status=400)
|
||
|
||
# Récupérer la catégorie
|
||
try:
|
||
category = AssetCategory.objects.get(id=category_id)
|
||
except AssetCategory.DoesNotExist:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Catégorie non trouvée.")
|
||
}, status=400)
|
||
|
||
# Récupérer les coordonnées de la structure ou de la localisation
|
||
structure = location.structure
|
||
lon = None
|
||
lat = None
|
||
address = None
|
||
|
||
if structure:
|
||
if structure.main_location:
|
||
main_loc = structure.main_location
|
||
if main_loc.lon and main_loc.lat:
|
||
lon = main_loc.lon
|
||
lat = main_loc.lat
|
||
address = main_loc.address if hasattr(main_loc, 'address') else None
|
||
|
||
# Déterminer l'équipe et le prestataire
|
||
assigned_team = None
|
||
assigned_provider = None
|
||
assigned_member = None
|
||
|
||
company_member = CompanyMember.objects.filter(user=request.user).prefetch_related('teams__company').first()
|
||
if company_member:
|
||
m_teams = company_member.teams.all()
|
||
assigned_team = m_teams[0] if m_teams else None
|
||
assigned_provider = assigned_team.company if assigned_team else None
|
||
assigned_member = company_member
|
||
|
||
# Chercher un contrat actif pour structures
|
||
contract = None
|
||
if structure:
|
||
structure_contract = StructureContract.objects.filter(
|
||
structure=structure,
|
||
contract__is_active=True
|
||
).select_related('contract').first()
|
||
if structure_contract:
|
||
contract = structure_contract.contract
|
||
|
||
with transaction.atomic():
|
||
new_intervention = Intervention(
|
||
title=title,
|
||
description=_("Intervention de réparation créée depuis l'application web."),
|
||
maintain_type='corrective',
|
||
type='repair',
|
||
origin_type='damage',
|
||
priority='3',
|
||
thematic=default_thematic,
|
||
asset_category=category,
|
||
contract=contract,
|
||
assigned_provider=assigned_provider,
|
||
assigned_team=assigned_team,
|
||
assigned_member=assigned_member,
|
||
lon=lon,
|
||
lat=lat,
|
||
address=address,
|
||
location_code=location.code if location else None,
|
||
status='to_be_processed',
|
||
status_order=STATUS_ORDERS['to_be_processed'],
|
||
created_by=request.user,
|
||
)
|
||
new_intervention.save()
|
||
# Auto-vérification si l'utilisateur a le droit can_check_interventions
|
||
auto_check_if_eligible(request.user, new_intervention)
|
||
|
||
# Ajouter la localisation
|
||
location_ct = ContentType.objects.get_for_model(StructureLocation)
|
||
InterventionLocation.objects.create(
|
||
intervention=new_intervention,
|
||
content_type=location_ct,
|
||
object_id=location.id,
|
||
)
|
||
|
||
# Ajouter l'équipement si spécifié
|
||
if asset_id:
|
||
from assets.models import StructureGeoAsset
|
||
try:
|
||
asset = StructureGeoAsset.objects.get(id=asset_id)
|
||
asset_ct = ContentType.objects.get_for_model(StructureGeoAsset)
|
||
InterventionAsset.objects.create(
|
||
intervention=new_intervention,
|
||
content_type=asset_ct,
|
||
object_id=asset.id,
|
||
)
|
||
except StructureGeoAsset.DoesNotExist:
|
||
pass
|
||
|
||
# Créer l'entrée dans la timeline
|
||
InterventionTimeLine.objects.create(
|
||
intervention=new_intervention,
|
||
event_user=request.user,
|
||
event_time=now(),
|
||
event_type='creation',
|
||
from_status=None,
|
||
to_status=new_intervention.status,
|
||
)
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'code': new_intervention.code,
|
||
'id': new_intervention.id,
|
||
})
|
||
|
||
|
||
|
||
|
||
def choose_thematic_before_add(request):
|
||
intervention_type = request.GET.get('type', 'corrective') # 'corrective' | 'preventive' | 'ameliorative'
|
||
symptoms = []
|
||
|
||
# Paramètres additionnels éventuellement propagés (commande/projet/redirection)
|
||
order_id = request.GET.get('order_id')
|
||
project_id = request.GET.get('project_id')
|
||
redirect_to_order = request.GET.get('redirect_to_order')
|
||
|
||
# 1) Récupère les thématiques éditables pour l'utilisateur
|
||
if not getattr(request.user, 'config', None):
|
||
return HttpResponseForbidden(_("Aucune configuration utilisateur n'est associée à votre compte."))
|
||
|
||
editable_links_qs = (
|
||
UserThematics.objects
|
||
.filter(user_config=request.user.config, can_edit_interventions=True)
|
||
.select_related('thematic')
|
||
)
|
||
|
||
# Filtrage optionnel si on reçoit une liste de thématiques autorisées (pour un projet donné)
|
||
allowed_codes = request.GET.getlist('allowed_thematic')
|
||
if allowed_codes:
|
||
editable_links_qs = editable_links_qs.filter(thematic__code__in=allowed_codes)
|
||
|
||
# Aucun droit d'édition -> 403 (ou un message dans la page si tu préfères)
|
||
if not editable_links_qs.exists():
|
||
return HttpResponseForbidden(_("Vous n'avez pas de droit d'édition sur une thématique d'intervention."))
|
||
|
||
# 2) Bypass si une seule thématique
|
||
if editable_links_qs.count() == 1:
|
||
thematic = editable_links_qs.first().thematic
|
||
base_params = {}
|
||
|
||
if intervention_type == 'preventive':
|
||
path = reverse('interventions:intervention_add_preventive', kwargs={'thematic_code': thematic.code})
|
||
else:
|
||
path = reverse('interventions:intervention_add', kwargs={'thematic_code': thematic.code})
|
||
if intervention_type == 'ameliorative':
|
||
base_params['maintain_type'] = 'ameliorative'
|
||
else: # corrective
|
||
base_params['maintain_type'] = 'corrective'
|
||
|
||
if order_id:
|
||
base_params['order_id'] = order_id
|
||
if redirect_to_order is not None:
|
||
base_params['redirect_to_order'] = redirect_to_order
|
||
if project_id:
|
||
base_params['project_id'] = project_id
|
||
|
||
if base_params:
|
||
return redirect(f"{path}?{urlencode(base_params)}")
|
||
return redirect(path)
|
||
|
||
# 3) Construit la grille des thématiques autorisées (nom selon langue, icône depuis Thematic.icon)
|
||
lang = (get_language() or "fr").lower()
|
||
allowed_thematics = [ut.thematic for ut in editable_links_qs]
|
||
|
||
def build_url(thematic_code: str) -> str:
|
||
base_params = {}
|
||
|
||
if intervention_type == 'preventive':
|
||
path = reverse('interventions:intervention_add_preventive', kwargs={'thematic_code': thematic_code})
|
||
else:
|
||
path = reverse('interventions:intervention_add', kwargs={'thematic_code': thematic_code})
|
||
if intervention_type == 'ameliorative':
|
||
base_params['maintain_type'] = 'ameliorative'
|
||
else: # corrective
|
||
base_params['maintain_type'] = 'corrective'
|
||
|
||
if order_id:
|
||
base_params['order_id'] = order_id
|
||
if redirect_to_order is not None:
|
||
base_params['redirect_to_order'] = redirect_to_order
|
||
if project_id:
|
||
base_params['project_id'] = project_id
|
||
|
||
if base_params:
|
||
return f"{path}?{urlencode(base_params)}"
|
||
return path
|
||
|
||
thematics = []
|
||
for th in allowed_thematics:
|
||
thematics.append({
|
||
'name': th.name, # property -> FR/NL selon langue active
|
||
'url': build_url(th.code),
|
||
'icon': th.icon or 'bi-collection', # fallback si pas d’icône
|
||
'color': getattr(th, 'color', '#000000'),
|
||
'code': th.code,
|
||
})
|
||
|
||
thematics = sorted(
|
||
thematics,
|
||
key=lambda t: t['name'].lower() if t['name'] else ""
|
||
)
|
||
|
||
# 4) Symptômes (uniquement pour le correctif, comme avant), limités aux thématiques autorisées
|
||
if intervention_type == 'corrective':
|
||
allowed_codes = [th.code for th in allowed_thematics]
|
||
qs = (Symptom.objects
|
||
.filter(is_active=True, thematic__code__in=allowed_codes)
|
||
.select_related("thematic"))
|
||
|
||
# affichage selon la langue (NL => fallback FR, sinon FR)
|
||
if lang.startswith("nl"):
|
||
qs = qs.annotate(display_name=Coalesce(F("name_nl"), F("name_fr"), output_field=CharField()))
|
||
else:
|
||
qs = qs.annotate(display_name=F("name_fr"))
|
||
|
||
# tri ergonomique
|
||
qs = qs.order_by(Lower("display_name"), "thematic__id", "order_number")
|
||
|
||
for s in qs:
|
||
symptoms.append({
|
||
"label": f"{s.display_name} — {s.thematic.name}",
|
||
"value": s.display_name,
|
||
"id": s.id,
|
||
"thematic": s.thematic.code,
|
||
})
|
||
|
||
return render(
|
||
request,
|
||
'interventions/add/choose_thematic.html',
|
||
{
|
||
'thematics': thematics,
|
||
'intervention_type': intervention_type,
|
||
'symptoms': symptoms,
|
||
}
|
||
)
|
||
|
||
|
||
def check_assets_locations_status(request):
|
||
"""
|
||
AJAX endpoint to check if selected assets/locations have active status.
|
||
|
||
POST parameters:
|
||
- locations: JSON array [{"ct": content_type_id, "obj": object_id}, ...]
|
||
- assets: JSON array [{"ct": content_type_id, "obj": object_id}, ...]
|
||
- maintain_type: 'corrective', 'preventive', 'ameliorative'
|
||
|
||
Returns:
|
||
{
|
||
"inactive_items": [
|
||
{
|
||
"type": "location" | "asset",
|
||
"model_name": "trafficlightintersection",
|
||
"object_id": 123,
|
||
"status": "inactive",
|
||
"display_name": "TL-001",
|
||
"status_label": "Désactivé"
|
||
},
|
||
...
|
||
]
|
||
}
|
||
"""
|
||
try:
|
||
locations_json = request.POST.get('locations', '[]')
|
||
assets_json = request.POST.get('assets', '[]')
|
||
maintain_type = request.POST.get('maintain_type', 'corrective')
|
||
|
||
# Only check for corrective interventions
|
||
if maintain_type != 'corrective':
|
||
return JsonResponse({'inactive_items': []})
|
||
|
||
inactive_items = []
|
||
|
||
# Parse location and asset pairs
|
||
location_pairs = _parse_pairs_json(locations_json)
|
||
asset_pairs = _parse_pairs_json(assets_json)
|
||
|
||
# Fallback: flux mono-sélection quand les champs JSON sont vides.
|
||
if not location_pairs:
|
||
location_ct = request.POST.get('location_ct')
|
||
location_id = request.POST.get('location_id')
|
||
if location_ct and location_id:
|
||
location_pairs = [(int(location_ct), int(location_id))]
|
||
|
||
if not asset_pairs:
|
||
equipment_ct = request.POST.get('equipment_ct')
|
||
equipment_id = request.POST.get('equipment_id')
|
||
if equipment_ct and equipment_id:
|
||
asset_pairs = [(int(equipment_ct), int(equipment_id))]
|
||
|
||
# Check locations (only TrafficLightIntersection has a status field)
|
||
for ct_id, obj_id in location_pairs:
|
||
try:
|
||
ct = ContentType.objects.get_for_id(ct_id)
|
||
Model = ct.model_class()
|
||
|
||
# Only check TrafficLightIntersection for now (it's the only location with status)
|
||
if Model.__name__ == 'TrafficLightIntersection':
|
||
obj = Model.objects.filter(pk=obj_id).first()
|
||
if obj and hasattr(obj, 'status') and obj.status != 'active':
|
||
status_label = obj.get_status_display() if hasattr(obj, 'get_status_display') else obj.status
|
||
|
||
inactive_items.append({
|
||
'type': 'location',
|
||
'model_name': ct.model,
|
||
'object_id': obj_id,
|
||
'status': obj.status,
|
||
'display_name': obj.code if hasattr(obj, 'code') else str(obj),
|
||
'status_label': str(status_label),
|
||
})
|
||
except Exception as e:
|
||
# Silently skip errors
|
||
pass
|
||
|
||
# Check assets (all assets have a status field)
|
||
for ct_id, obj_id in asset_pairs:
|
||
try:
|
||
ct = ContentType.objects.get_for_id(ct_id)
|
||
Model = ct.model_class()
|
||
obj = Model.objects.filter(pk=obj_id).first()
|
||
|
||
if obj and hasattr(obj, 'status') and obj.status != 'active':
|
||
status_label = obj.get_status_display() if hasattr(obj, 'get_status_display') else obj.status
|
||
|
||
inactive_items.append({
|
||
'type': 'asset',
|
||
'model_name': ct.model,
|
||
'object_id': obj_id,
|
||
'status': obj.status,
|
||
'display_name': obj.code if hasattr(obj, 'code') else str(obj),
|
||
'status_label': str(status_label),
|
||
})
|
||
except Exception as e:
|
||
# Silently skip errors
|
||
pass
|
||
|
||
return JsonResponse({'inactive_items': inactive_items})
|
||
|
||
except Exception as e:
|
||
return JsonResponse({'inactive_items': [], 'error': str(e)}, status=400)
|
||
|
||
|
||
def interventions_add(request, thematic_code):
|
||
thematic = Thematic.objects.filter(code=thematic_code).first()
|
||
|
||
inspection_config = None
|
||
inspection_config_id = request.GET.get('inspection_config_id') or request.POST.get('inspection_config_id')
|
||
if inspection_config_id:
|
||
from interventions.models import InspectionConfiguration
|
||
inspection_config = InspectionConfiguration.objects.select_related('default_category').filter(pk=inspection_config_id).first()
|
||
|
||
allowed_couples = []
|
||
if inspection_config:
|
||
from interventions.permissions import can_user_create_inspection_for_contract
|
||
# Récupérer les couples autorisés pour cet utilisateur
|
||
for cp in inspection_config.contracts_providers.select_related('contract', 'provider').all():
|
||
if can_user_create_inspection_for_contract(request.user, cp.contract):
|
||
allowed_couples.append(cp)
|
||
|
||
# Si l'utilisateur n'est pas admin et qu'aucun couple n'est autorisé
|
||
try:
|
||
is_admin = request.user.config.roles.filter(name='admin').exists()
|
||
except Exception:
|
||
is_admin = False
|
||
|
||
if not is_admin and not allowed_couples and inspection_config.contracts_providers.exists():
|
||
from django.http import HttpResponseForbidden
|
||
return HttpResponseForbidden(_("Vous n'avez pas la permission de créer cette inspection pour les contrats associés."))
|
||
|
||
if inspection_config:
|
||
maintain_type = inspection_config.maintain_type
|
||
else:
|
||
maintain_type = request.GET.get('maintain_type', 'corrective')
|
||
symptom_id = request.GET.get('symptom_id')
|
||
order_id = request.GET.get('order_id') or request.POST.get('order_id')
|
||
project_id_from_params = request.GET.get('project_id') or request.POST.get('project_id')
|
||
redirect_to_project = request.GET.get('redirect_to_project') or request.POST.get('redirect_to_project')
|
||
|
||
if not thematic:
|
||
return redirect('/interventions/choose-thematic/') # Redirect if thematic unknown
|
||
|
||
# Sublocation and per-thematic scripts are now inlined via {% if %} conditionals
|
||
# in the shared partials (_intervention_add_form.html, _intervention_add_scripts.html).
|
||
# The old per-thematic child templates (intervention_add_structures.html, etc.)
|
||
# are no longer used.
|
||
|
||
symptoms = Symptom.objects.filter(thematic=thematic, is_active=True).prefetch_related('asset_categories')
|
||
user_config = request.user.config
|
||
|
||
# Construire la liste des symptômes avec leurs données
|
||
lang = (get_language() or "fr").lower()
|
||
|
||
symptoms_data = []
|
||
for s in symptoms:
|
||
cat_ids = [ac.id for ac in s.asset_categories.all()]
|
||
symptoms_data.append({
|
||
"id": s.id,
|
||
"name": s.name_nl if lang.startswith("nl") and s.name_nl else s.name_fr,
|
||
"order_number": s.order_number,
|
||
"require_equipment_selection": bool(s.require_equipment_selection),
|
||
# Uniquement les IDs des catégories directement liées au symptôme.
|
||
# Ainsi, quand une sous-catégorie est sélectionnée, seuls les symptômes
|
||
# explicitement liés à cette sous-catégorie apparaissent.
|
||
"asset_categories": cat_ids,
|
||
"priority": s.priority,
|
||
"origin_type": s.origin_type or '',
|
||
"check_priority": s.check_priority,
|
||
})
|
||
|
||
# Trier: d'abord par order_number (si défini), puis alphabétiquement par nom
|
||
def symptom_sort_key(s):
|
||
has_order = 0 if s["order_number"] is not None else 1
|
||
order_num = s["order_number"] if s["order_number"] is not None else 999999
|
||
name = (s["name"] or "").lower()
|
||
return (has_order, order_num, name, s["id"])
|
||
|
||
symptoms_data.sort(key=symptom_sort_key)
|
||
|
||
# Données des catégories de source pour le JavaScript
|
||
source_categories_data = [
|
||
{
|
||
"id": sc.id,
|
||
"name": sc.getName(),
|
||
"has_reference": sc.has_reference,
|
||
}
|
||
for sc in SourceCategory.objects.filter(is_active=True)
|
||
]
|
||
|
||
if request.method == 'POST':
|
||
maintain_type = request.POST.get('maintain_type', 'corrective')
|
||
form = InterventionForm(request.POST, request.FILES, thematic=thematic, user=request.user)
|
||
|
||
if form.is_valid():
|
||
intervention = form.save(commit=False)
|
||
|
||
intervention.thematic = thematic
|
||
intervention.created_by = request.user
|
||
|
||
intervention.lon = float(request.POST.get('lon')) if request.POST.get('lon') else None
|
||
intervention.lat = float(request.POST.get('lat')) if request.POST.get('lat') else None
|
||
|
||
geom_json = request.POST.get('geom')
|
||
if geom_json:
|
||
# Si une géométrie est fournie (en GeoJSON)
|
||
try:
|
||
geom = GEOSGeometry(geom_json, srid=4326) # GeoJSON est en WGS84
|
||
geom.transform(3812)
|
||
intervention.geom = geom
|
||
except Exception as e:
|
||
raise ValueError(f"{_("Géométrie invalide")} : {e}")
|
||
|
||
else:
|
||
# Crée un buffer autour du point (lon, lat)
|
||
if intervention.lon is not None and intervention.lat is not None:
|
||
point = Point(intervention.lon, intervention.lat, srid=4326)
|
||
point.transform(3812)
|
||
buffer = point.buffer(1) # 1 mètre
|
||
intervention.geom = MultiPolygon(buffer)
|
||
|
||
intervention.expected_begin_time = now() + timedelta(hours=2)
|
||
|
||
# Lien éventuel avec une commande passée dans les paramètres
|
||
if order_id:
|
||
try:
|
||
order = ContractOrder.objects.select_related('contract').prefetch_related('projects').get(pk=order_id)
|
||
if not user_has_access_to_contract_order(request.user, order.id):
|
||
return HttpResponseForbidden(_("Vous n'avez pas accès à cette commande."))
|
||
intervention.order = order
|
||
intervention.contract = order.contract
|
||
intervention.assigned_provider = order.contract.company
|
||
# Si la commande a un seul projet, on l'associe à l'intervention
|
||
if order.projects.count() == 1:
|
||
intervention.project = order.projects.first()
|
||
except ContractOrder.DoesNotExist:
|
||
pass
|
||
|
||
if inspection_config:
|
||
intervention.type = 'inspection'
|
||
resolved_cp = None
|
||
if allowed_couples:
|
||
# Tenter de faire correspondre avec le contrat de la commande si présent
|
||
if intervention.contract:
|
||
resolved_cp = next((cp for cp in allowed_couples if cp.contract == intervention.contract), None)
|
||
if not resolved_cp:
|
||
resolved_cp = allowed_couples[0]
|
||
elif is_admin and inspection_config.contracts_providers.exists():
|
||
resolved_cp = inspection_config.contracts_providers.first()
|
||
|
||
if resolved_cp:
|
||
intervention.contract = resolved_cp.contract
|
||
intervention.assigned_provider = resolved_cp.provider
|
||
|
||
intervention.save()
|
||
|
||
# Si le statut est "assigned" (pris en charge) et aucun membre n'est assigné, assigner l'utilisateur créateur et sa première équipe
|
||
if intervention.status == 'assigned' and not getattr(intervention, 'assigned_member_id', None):
|
||
from contracts.models import CompanyMember
|
||
company_member = CompanyMember.objects.filter(user=request.user).first()
|
||
if company_member:
|
||
intervention.assigned_member = company_member
|
||
first_team = company_member.teams.first()
|
||
if first_team:
|
||
intervention.assigned_team = first_team
|
||
|
||
intervention.save()
|
||
|
||
redirect_to_project = False
|
||
# Lien au projet (paramètre explicite ou champ du formulaire)
|
||
project_param_id = project_id_from_params or request.POST.get('project')
|
||
if project_param_id:
|
||
project = Project.objects.filter(id=project_param_id).first()
|
||
if project:
|
||
ProjectIntervention.objects.create(
|
||
intervention=intervention,
|
||
project=project
|
||
)
|
||
# Récupérer redirect_to_project depuis GET ou POST
|
||
redirect_to_project = (
|
||
request.GET.get('redirect_to_project') == 'true' or
|
||
request.POST.get('redirect_to_project') == 'true'
|
||
)
|
||
if project.osiris_number and not intervention.osiris:
|
||
intervention.osiris = project.osiris_number
|
||
intervention.save(update_fields=['osiris'])
|
||
|
||
init_note = request.POST.get('init_description')
|
||
if init_note:
|
||
note = InterventionNote.objects.create(
|
||
intervention=intervention,
|
||
note_author=request.user,
|
||
note_time=now(),
|
||
content=request.POST.get('init_description', ''),
|
||
note_type=InterventionNote.NoteType.ORIGIN,
|
||
)
|
||
|
||
event = InterventionTimeLine.objects.create(
|
||
intervention=intervention,
|
||
event_user=request.user,
|
||
event_time=now(),
|
||
event_type='creation',
|
||
from_status=None,
|
||
to_status=intervention.status
|
||
)
|
||
|
||
expected_begin = InterventionPlanificationTimeLine.objects.create(
|
||
intervention=intervention,
|
||
event_user=request.user,
|
||
event_time=now() + timedelta(hours=2),
|
||
event_type='expected_begin',
|
||
event_description=_("Début prévu initial.")
|
||
)
|
||
|
||
files = form.cleaned_data["documents"]
|
||
for file in files:
|
||
InterventionDocument.objects.create(intervention=intervention, file=file, uploaded_by=request.user)
|
||
|
||
mismatches = getattr(form, "fms_filename_mismatch", [])
|
||
reference = getattr(form, "fms_filename_reference", None)
|
||
if mismatches and reference:
|
||
documents_list = ", ".join(mismatches)
|
||
messages.warning(
|
||
request,
|
||
_(
|
||
"Le numéro FMS %(fms)s n'apparaît pas dans le nom des documents suivants : %(documents)s"
|
||
) % {"fms": reference, "documents": documents_list},
|
||
)
|
||
|
||
|
||
# -------- Localisations --------
|
||
# Priorité au JSON 'locations', sinon pair unique 'location_ct'/'location_id'
|
||
location_pairs = _parse_pairs_json(request.POST.get('locations'))
|
||
if not location_pairs:
|
||
ct = request.POST.get('location_ct')
|
||
oid = request.POST.get('location_id')
|
||
if ct and oid:
|
||
location_pairs = [(int(ct), int(oid))]
|
||
|
||
first_loc_obj = None
|
||
for ct_id, obj_id in location_pairs:
|
||
ct = ContentType.objects.get_for_id(ct_id)
|
||
InterventionLocation.objects.get_or_create(
|
||
intervention=intervention,
|
||
content_type=ct,
|
||
object_id=obj_id,
|
||
)
|
||
# mémoriser le 1er objet pour updater location_code
|
||
if first_loc_obj is None:
|
||
Model = ct.model_class()
|
||
first_loc_obj = Model.objects.filter(pk=obj_id).first()
|
||
|
||
if first_loc_obj and hasattr(first_loc_obj, 'code'):
|
||
intervention.location_code = first_loc_obj.code
|
||
intervention.save(update_fields=['location_code'])
|
||
|
||
# -------- Assets (optionnel) --------
|
||
# 1) JSON 'assets' = [{ct,obj}] (recommandé)
|
||
asset_pairs = _parse_pairs_json(request.POST.get('assets'))
|
||
# 2) Fallbacks: equipment_ct/equipment_id ou equipment_type/equipment_id
|
||
if not asset_pairs:
|
||
equip_ct = request.POST.get('equipment_ct')
|
||
equip_id = request.POST.get('equipment_id')
|
||
equip_type = request.POST.get('equipment_type') # ancien flux
|
||
if equip_ct and equip_id:
|
||
asset_pairs = [(int(equip_ct), int(equip_id))]
|
||
elif equip_type and equip_id:
|
||
ct = ContentType.objects.filter(app_label='assets', model=equip_type.lower()).first() or ContentType.objects.filter(model=equip_type.lower()).first()
|
||
if ct:
|
||
asset_pairs = [(ct.id, int(equip_id))]
|
||
|
||
first_asset_obj = None
|
||
for ct_id, obj_id in asset_pairs:
|
||
ct = ContentType.objects.get_for_id(ct_id)
|
||
InterventionAsset.objects.get_or_create(
|
||
intervention=intervention,
|
||
content_type=ct,
|
||
object_id=obj_id,
|
||
)
|
||
if first_asset_obj is None:
|
||
Model = ct.model_class()
|
||
first_asset_obj = Model.objects.filter(pk=obj_id).first()
|
||
|
||
if inspection_config:
|
||
templates = list(inspection_config.operation_templates.all())
|
||
if templates:
|
||
intervention.equipment_operation_templates.add(*templates)
|
||
_assign_guided_template(intervention, templates)
|
||
_assign_team_from_templates(intervention, templates)
|
||
_assign_contract_posts_from_templates(intervention, templates)
|
||
_add_operations_from_templates(intervention, templates, only_generic=True)
|
||
if asset_pairs:
|
||
resolved_assets = _resolve_assets_from_pairs(asset_pairs)
|
||
_create_equipment_operations_for_assets(intervention, resolved_assets, ignore_filters=True)
|
||
|
||
# -------- Résolution contrat et prestataire --------
|
||
if not intervention.contract:
|
||
resolved_contract, resolved_provider, force_to_be_approved = resolve_intervention_contract_and_provider(
|
||
thematic=thematic,
|
||
symptom=intervention.symptom,
|
||
asset=first_asset_obj,
|
||
location=first_loc_obj,
|
||
user=request.user
|
||
)
|
||
if resolved_contract:
|
||
intervention.contract = resolved_contract
|
||
if inspection_config and templates:
|
||
_assign_contract_posts_from_templates(intervention, templates)
|
||
if resolved_provider and not intervention.assigned_provider:
|
||
intervention.assigned_provider = resolved_provider
|
||
if resolved_contract or resolved_provider:
|
||
intervention.save(update_fields=['contract', 'assigned_provider'])
|
||
|
||
if force_to_be_approved and intervention.status != 'to_be_approved':
|
||
intervention.status = 'to_be_approved'
|
||
intervention.status_order = STATUS_ORDERS.get('to_be_approved', 2)
|
||
intervention.save(update_fields=['status', 'status_order'])
|
||
event.to_status = 'to_be_approved'
|
||
event.save(update_fields=['to_status'])
|
||
|
||
# Determine redirect URL based on whether we came from a project or order
|
||
if redirect_to_project and project_param_id:
|
||
messages.success(request, _("L'intervention %(code)s a été créée avec succès et liée au projet.") % {'code': intervention.code})
|
||
return redirect(reverse('projects:projects_detail', kwargs={'project_id': project_param_id}))
|
||
elif order_id and request.GET.get('redirect_to_order') is not None:
|
||
messages.success(request, _("L'intervention %(code)s a été créée avec succès et liée à la commande.") % {'code': intervention.code})
|
||
return redirect(reverse('contracts:contracts_orders_detail', kwargs={'order_id': order_id}))
|
||
else:
|
||
# Si on vient d'une observation (observation_id en query), lier l'observation à l'intervention créée
|
||
# et copier SEULEMENT les photos cochées par l'utilisateur.
|
||
obs_id = request.GET.get('observation_id') or request.POST.get('observation_id')
|
||
if obs_id:
|
||
try:
|
||
from observations.models import Observation, ObservationPhoto, ObservationComment
|
||
obs = Observation.objects.get(pk=int(obs_id))
|
||
obs.intervention = intervention
|
||
obs.save(update_fields=['intervention'])
|
||
|
||
# Mettre l'observation à 'in_progress' quand l'intervention est créée
|
||
if obs.status != 'processed' and obs.status != 'archived':
|
||
obs.status = 'in_progress'
|
||
obs.save(update_fields=['status'])
|
||
# Consigner un commentaire automatique
|
||
try:
|
||
ObservationComment.objects.create(
|
||
observation=obs,
|
||
author=request.user if request.user.is_authenticated else None,
|
||
comment=f"Observation mise en cours via création de l'intervention {intervention.code}",
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
# Copier uniquement les photos sélectionnées dans le formulaire
|
||
selected_ids = request.POST.getlist('selected_obs_photos')
|
||
if selected_ids:
|
||
from django.core.files.base import ContentFile
|
||
from os.path import basename
|
||
photos_qs = ObservationPhoto.objects.filter(observation=obs, id__in=selected_ids)
|
||
for photo in photos_qs:
|
||
try:
|
||
if not photo.photo:
|
||
continue
|
||
photo.photo.open('rb')
|
||
InterventionDocument.objects.create(
|
||
intervention=intervention,
|
||
file=ContentFile(photo.photo.read(), name=basename(photo.photo.name)),
|
||
uploaded_by=request.user,
|
||
)
|
||
finally:
|
||
try:
|
||
photo.photo.close()
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
|
||
# Redirect to mobile detail if coming from mobile, otherwise desktop detail
|
||
if '/mobile/' in request.path or 'add-mobile' in request.path:
|
||
return redirect(reverse('mobile:intervention_detail_mobile', kwargs={'intervention_id': intervention.id}))
|
||
return redirect(reverse('interventions:intervention_detail', kwargs={'intervention_id': intervention.id}))
|
||
|
||
else:
|
||
initial = {"maintain_type": maintain_type}
|
||
if inspection_config:
|
||
lang = get_language()
|
||
name = inspection_config.name_nl if lang == 'nl' and inspection_config.name_nl else inspection_config.name_fr
|
||
import re
|
||
cleaned_name = name
|
||
cleaned_name = re.sub(r'^(inspection|inspectie)\b\s*[-–—]?\s*', '', cleaned_name, flags=re.IGNORECASE)
|
||
cleaned_name = re.sub(r'\s*[-–—]?\s*\b(inspection|inspectie)$', '', cleaned_name, flags=re.IGNORECASE)
|
||
cleaned_name = cleaned_name.strip()
|
||
page_title = _("Nouvelle inspection - %(name)s") % {'name': cleaned_name}
|
||
initial.update({
|
||
"type": "inspection",
|
||
"status": "assigned",
|
||
"title": page_title
|
||
})
|
||
if inspection_config.default_category_id:
|
||
if inspection_config.default_category.parent_id:
|
||
initial["asset_category"] = inspection_config.default_category.parent_id
|
||
else:
|
||
initial["asset_category"] = inspection_config.default_category_id
|
||
if request.method == "GET":
|
||
# Support both 'symptom' and 'symptom_id'
|
||
selected_symptom_id = request.GET.get('symptom') or symptom_id
|
||
if selected_symptom_id:
|
||
initial["symptom"] = selected_symptom_id
|
||
# Le formulaire gère le pré-remplissage de asset_category selon le nombre de catégories
|
||
|
||
# Hydrate initial values from GET for known fields
|
||
for key in [
|
||
'title', 'lat', 'lon', 'address', 'asset_category', 'status',
|
||
'source_category', 'source_ref', 'source_detail', 'init_description',
|
||
'type', 'origin_type', 'priority'
|
||
]:
|
||
val = request.GET.get(key)
|
||
if val not in (None, ""):
|
||
initial[key] = val
|
||
|
||
# Pre-fill project_id if coming from a project
|
||
project_id_param = request.GET.get('project_id')
|
||
if project_id_param:
|
||
initial["project"] = project_id_param
|
||
|
||
form = InterventionForm(thematic=thematic, user=request.user, initial=initial)
|
||
|
||
# Extract initial sub-category ID from GET params (e.g. when coming from an observation)
|
||
initial_interv_sub_category_id = request.GET.get('interv_sub_category_id') if request.method == 'GET' else None
|
||
if not initial_interv_sub_category_id and request.method == 'GET' and inspection_config and inspection_config.default_category_id:
|
||
if inspection_config.default_category.parent_id:
|
||
initial_interv_sub_category_id = str(inspection_config.default_category_id)
|
||
# Pre-load subcategories server-side so the sub-category block renders immediately (no AJAX needed)
|
||
initial_subcategories = []
|
||
if initial_interv_sub_category_id:
|
||
try:
|
||
sub_cat_obj = AssetCategory.objects.select_related('parent').get(pk=int(initial_interv_sub_category_id))
|
||
if sub_cat_obj.parent_id:
|
||
lang = (get_language() or 'fr').lower()
|
||
order_field = 'name_nl' if lang.startswith('nl') else 'name_fr'
|
||
siblings = AssetCategory.objects.filter(parent_id=sub_cat_obj.parent_id).order_by(order_field)
|
||
initial_subcategories = [
|
||
{'id': s.id, 'name': s.get_name(), 'selected': s.id == sub_cat_obj.id}
|
||
for s in siblings
|
||
]
|
||
except (AssetCategory.DoesNotExist, ValueError):
|
||
pass
|
||
|
||
# GET branch: ajouter observation et photos pour affichage si présent
|
||
observation = None
|
||
observation_photos = []
|
||
if request.method == 'GET':
|
||
obs_id_ctx = request.GET.get('observation_id')
|
||
if obs_id_ctx:
|
||
try:
|
||
from observations.models import Observation
|
||
observation = Observation.objects.select_related('created_by').prefetch_related('photos').get(pk=int(obs_id_ctx))
|
||
# Construire les URLs des photos pour le private storage
|
||
observation_photos = [
|
||
{
|
||
'id': photo.id,
|
||
'url': build_private_url('observations', 'ObservationPhoto', photo.pk, 'photo')
|
||
}
|
||
for photo in observation.photos.all()
|
||
]
|
||
except Exception:
|
||
observation = None
|
||
observation_photos = []
|
||
|
||
page_title = None
|
||
if inspection_config:
|
||
lang = get_language()
|
||
name = inspection_config.name_nl if lang == 'nl' and inspection_config.name_nl else inspection_config.name_fr
|
||
import re
|
||
cleaned_name = name
|
||
cleaned_name = re.sub(r'^(inspection|inspectie)\b\s*[-–—]?\s*', '', cleaned_name, flags=re.IGNORECASE)
|
||
cleaned_name = re.sub(r'\s*[-–—]?\s*\b(inspection|inspectie)$', '', cleaned_name, flags=re.IGNORECASE)
|
||
cleaned_name = cleaned_name.strip()
|
||
page_title = _("Nouvelle inspection - %(name)s") % {'name': cleaned_name}
|
||
|
||
# Render mobile template when accessed via the add-mobile URL
|
||
is_mobile_form = 'add-mobile' in request.path
|
||
chosen_template = 'interventions/add/intervention_add_mobile.html' if is_mobile_form else 'interventions/add/intervention_add.html'
|
||
|
||
return render(request, chosen_template,
|
||
{
|
||
'form': form,
|
||
'thematic': thematic,
|
||
'symptoms_data': json.dumps(symptoms_data),
|
||
'source_categories_data': json.dumps(source_categories_data),
|
||
'maintain_type': maintain_type,
|
||
'observation': observation,
|
||
'observation_photos': observation_photos,
|
||
'redirect_to_project': redirect_to_project,
|
||
'initial_interv_sub_category_id': initial_interv_sub_category_id,
|
||
'initial_subcategories': initial_subcategories,
|
||
'inspection_config': inspection_config,
|
||
'page_title': page_title,
|
||
'structure_location_ct_id': ContentType.objects.get_for_model(StructureLocation).id,
|
||
'traffic_light_intersection_ct_id': ContentType.objects.get_for_model(TrafficLightIntersection).id,
|
||
'control_center_location_ct_id': ContentType.objects.get_for_model(apps.get_model('assets', 'ControlCenterLocation')).id if apps.is_installed('assets') else None,
|
||
})
|
||
|
||
|
||
def intervention_link_assets(request, intervention_id):
|
||
intervention = get_object_or_404(Intervention, id=intervention_id)
|
||
|
||
form = AssetsLinkForm(request.POST)
|
||
if not form.is_valid():
|
||
messages.error(request, form.errors.as_text())
|
||
return redirect("interventions:detail", intervention_id)
|
||
|
||
normalized = form.cleaned_data["assets"] # [{'model', 'object_id'}, ...]
|
||
|
||
# ContentTypes en une fois
|
||
model_names = sorted({a['model'] for a in normalized})
|
||
cts = {ct.model: ct for ct in ContentType.objects.filter(model__in=model_names)}
|
||
missing = [m for m in model_names if m not in cts]
|
||
if missing:
|
||
messages.error(request, f"{_("Modèles inconnus")}: {', '.join(missing)}")
|
||
return redirect("interventions:intervention_detail", intervention_id)
|
||
|
||
# Paires cibles
|
||
target_pairs = {(cts[a['model']].id, a['object_id']) for a in normalized}
|
||
|
||
# Existant
|
||
existing_qs = InterventionAsset.objects.filter(intervention=intervention)
|
||
existing_pairs = set(existing_qs.values_list('content_type_id', 'object_id'))
|
||
|
||
# Diff
|
||
to_add = target_pairs - existing_pairs
|
||
to_del = existing_pairs - target_pairs
|
||
|
||
created_count = 0
|
||
deleted_count = 0
|
||
operations_deleted = 0
|
||
assets_with_values = 0
|
||
try:
|
||
with transaction.atomic():
|
||
if to_del:
|
||
q = Q()
|
||
for ct_id, obj_id in to_del:
|
||
q |= Q(content_type_id=ct_id, object_id=obj_id)
|
||
deleted_count = existing_qs.filter(q).delete()[0]
|
||
operations_deleted, assets_with_values = _delete_operations_for_assets(
|
||
intervention,
|
||
to_del,
|
||
)
|
||
|
||
if to_add:
|
||
objs = [
|
||
InterventionAsset(
|
||
intervention=intervention,
|
||
content_type_id=ct_id,
|
||
object_id=obj_id
|
||
)
|
||
for (ct_id, obj_id) in to_add
|
||
]
|
||
InterventionAsset.objects.bulk_create(objs)
|
||
created_count = len(objs)
|
||
|
||
except Exception as e:
|
||
messages.error(request, f"{_("Erreur de synchro")}: {e}")
|
||
return redirect("interventions:intervention_detail", intervention_id)
|
||
|
||
operations_created = 0
|
||
# For preventive interventions, store newly linked assets in session for template selection
|
||
# For corrective interventions, create operations automatically
|
||
if to_add:
|
||
assets_with_ct = _resolve_assets_from_pairs(to_add)
|
||
if intervention.maintain_type == 'preventive':
|
||
# Store newly linked asset IDs in session for template selection modal
|
||
new_asset_keys = [f"{ct_id}:{obj_id}" for ct_id, obj_id in to_add]
|
||
session_key = f'new_linked_assets_{intervention_id}'
|
||
request.session[session_key] = new_asset_keys
|
||
request.session.modified = True
|
||
else:
|
||
# Corrective: create operations automatically as before
|
||
operations_created = _create_equipment_operations_for_assets(intervention, assets_with_ct)
|
||
|
||
base_message = _(
|
||
"Assets synchronisés. Ajoutés: %(created)d • Supprimés: %(deleted)d • Total: %(total)d"
|
||
) % {
|
||
"created": created_count,
|
||
"deleted": deleted_count,
|
||
"total": len(target_pairs),
|
||
}
|
||
if operations_deleted:
|
||
base_message += _(" • Opérations supprimées: %(ops)d") % {"ops": operations_deleted}
|
||
if operations_created:
|
||
base_message += _(" • Opérations ajoutées: %(ops)d") % {"ops": operations_created}
|
||
|
||
messages.success(request, base_message)
|
||
if assets_with_values:
|
||
warning_message = ngettext(
|
||
"Certaines opérations n'ont pas été supprimées car des valeurs ont été encodées pour %(count)d asset.",
|
||
"Certaines opérations n'ont pas été supprimées car des valeurs ont été encodées pour %(count)d assets.",
|
||
assets_with_values,
|
||
) % {"count": assets_with_values}
|
||
messages.warning(request, warning_message)
|
||
# Redirige vers la page finale attendue (détail, liste, etc.)
|
||
return redirect("interventions:intervention_detail", intervention_id)
|
||
|
||
|
||
def intervention_link_order(request, intervention_id):
|
||
data = json.loads(request.body)
|
||
try:
|
||
intervention = Intervention.objects.get(pk=intervention_id)
|
||
allowed_update_fields = get_allowed_update_fields(request.user, intervention)
|
||
|
||
if not data.get('order_id'):
|
||
intervention.order = None
|
||
else:
|
||
intervention.order = ContractOrder.objects.get(pk=data['order_id'])
|
||
if intervention.order and not intervention.contract:
|
||
intervention.contract = intervention.order.contract
|
||
intervention.save()
|
||
|
||
quote_items = []
|
||
quote_total = None
|
||
if intervention.order and 'posts' in allowed_update_fields:
|
||
quote = getattr(intervention.order, 'quote', None)
|
||
if quote and quote.is_finalized and quote.items.exists():
|
||
lang = get_language()
|
||
|
||
def format_decimal(value):
|
||
return f"{Decimal(value):.2f}"
|
||
|
||
for item in quote.get_remaining_items(
|
||
exclude_intervention_id=intervention.id,
|
||
include_zero=True,
|
||
include_current_intervention_periods=intervention.id
|
||
):
|
||
post = item['contract_post']
|
||
description = post.description_fr
|
||
if lang and lang.startswith('nl'):
|
||
description = post.description_nl or description
|
||
|
||
quote_items.append({
|
||
'contract_post_id': post.id,
|
||
'order_number': post.order_number,
|
||
'code': post.code,
|
||
'description': description,
|
||
'unit': post.unit,
|
||
'planned_quantity': format_decimal(item['planned_quantity']),
|
||
'consumed_quantity': format_decimal(item['consumed_quantity']),
|
||
'remaining_quantity': format_decimal(item['remaining_quantity']),
|
||
})
|
||
|
||
quote_total = format_decimal(intervention.order.get_quote_total_amount())
|
||
|
||
active_contract = intervention.contract or (intervention.order.contract if intervention.order else None)
|
||
return JsonResponse({
|
||
'success': True,
|
||
'contract_id': active_contract.id if active_contract else None,
|
||
'contract_posts_count': active_contract.posts.count() if active_contract else 0,
|
||
'quote_items': quote_items,
|
||
'quote_total': quote_total,
|
||
'can_manage_posts': 'posts' in allowed_update_fields,
|
||
'order_display': f"{intervention.order.contract.contract_number} - {intervention.order.order_code}" if intervention.order else None,
|
||
})
|
||
except (Intervention.DoesNotExist, ContractOrder.DoesNotExist) as e:
|
||
return JsonResponse({'success': False, 'message': str(e)})
|
||
|
||
|
||
def intervention_apply_quote(request, intervention_id):
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
allowed_update_fields = get_allowed_update_fields(request.user, intervention)
|
||
|
||
if 'posts' not in allowed_update_fields:
|
||
return JsonResponse({'success': False, 'message': _('Vous ne pouvez pas modifier les postes de cette intervention.')}, status=403)
|
||
|
||
if not intervention.order:
|
||
return JsonResponse({'success': False, 'message': _('Aucune commande n\'est associée à cette intervention.')}, status=400)
|
||
|
||
quote = getattr(intervention.order, 'quote', None)
|
||
if not quote or not quote.items.exists():
|
||
return JsonResponse({'success': False, 'message': _('Aucun devis n\'est disponible pour la commande liée.')}, status=404)
|
||
|
||
if not quote.is_finalized:
|
||
return JsonResponse({'success': False, 'message': _('Le devis doit être figé avant de pouvoir être appliqué.')}, status=400)
|
||
|
||
items = quote.get_remaining_items(
|
||
exclude_intervention_id=intervention.id,
|
||
include_zero=True,
|
||
include_current_intervention_periods=intervention.id
|
||
)
|
||
created_count = 0
|
||
|
||
with transaction.atomic():
|
||
for item in items:
|
||
remaining = item['remaining_quantity']
|
||
# if remaining <= Decimal('0'):
|
||
# continue
|
||
post = item['contract_post']
|
||
_, created = InterventionContractPost.objects.get_or_create(
|
||
intervention=intervention,
|
||
contract_post=post,
|
||
)
|
||
if created:
|
||
created_count += 1
|
||
|
||
return JsonResponse({'success': True, 'created': created_count, 'updated': created_count})
|
||
|
||
|
||
def update_contract_post(request, intervention_id):
|
||
user_config = get_object_or_404(UserConfig, user=request.user)
|
||
|
||
|
||
|
||
if request.method == "POST":
|
||
data = json.loads(request.body)
|
||
try:
|
||
intervention = Intervention.objects.get(pk=intervention_id)
|
||
contract_post = ContractPost.objects.get(pk=data['contract_post_id'])
|
||
|
||
defaults = {}
|
||
if quantity := data.get('quantity'):
|
||
# Check if the user has the right roles to update contract posts
|
||
# Only users with 'admin' or 'external_manager' roles can update
|
||
if not user_config.roles.filter(name__in=['admin', 'external_manager']).exists():
|
||
return JsonResponse({'status': 'error', 'message': 'Permission denied'}, status=403)
|
||
defaults['quantity'] = quantity
|
||
if comment := data.get('comment'):
|
||
# Check if the user has the right roles to update contract posts
|
||
# Only users with 'admin' or 'external_manager' roles can update
|
||
if not user_config.roles.filter(name__in=['admin', 'external_manager']).exists():
|
||
return JsonResponse({'status': 'error', 'message': 'Permission denied'}, status=403)
|
||
defaults['comment'] = comment
|
||
if control_comment := data.get('control_comment'):
|
||
# Check if the user has the right roles to update contract posts
|
||
# Only users with 'admin', 'manager', or 'controller' roles can update
|
||
if not user_config.roles.filter(name__in=['admin', 'manager', 'controller']).exists():
|
||
return JsonResponse({'status': 'error', 'message': 'Permission denied'}, status=403)
|
||
defaults['control_comment'] = control_comment
|
||
|
||
post, created = InterventionContractPost.objects.update_or_create(
|
||
intervention=intervention,
|
||
contract_post=contract_post,
|
||
defaults=defaults
|
||
)
|
||
|
||
quote_remaining_quantity = None
|
||
if intervention.order:
|
||
quote = getattr(intervention.order, 'quote', None)
|
||
if quote:
|
||
remaining_items = quote.get_remaining_items(
|
||
exclude_intervention_id=intervention.id,
|
||
include_zero=True,
|
||
include_current_intervention_periods=intervention.id,
|
||
)
|
||
for item in remaining_items:
|
||
if item['contract_post'].id == contract_post.id:
|
||
quote_remaining_quantity = item['remaining_quantity']
|
||
break
|
||
|
||
return JsonResponse({
|
||
'status': 'updated' if not created else 'created',
|
||
'quote_remaining_quantity': str(quote_remaining_quantity) if quote_remaining_quantity is not None else None,
|
||
})
|
||
except Exception as e:
|
||
return JsonResponse({'status': 'error', 'error': str(e)}, status=400)
|
||
|
||
elif request.method == "DELETE":
|
||
# Check if the user has the right roles to update contract posts
|
||
# Only users with 'admin', 'manager', or 'external_manager' roles can update
|
||
if not user_config.roles.filter(name__in=['admin', 'external_manager']).exists():
|
||
return JsonResponse({'status': 'error', 'message': 'Permission denied'}, status=403)
|
||
|
||
data = json.loads(request.body)
|
||
try:
|
||
post = InterventionContractPost.objects.get(
|
||
intervention_id=intervention_id,
|
||
contract_post_id=data['contract_post_id']
|
||
)
|
||
post.delete()
|
||
return JsonResponse({'status': 'deleted'})
|
||
except InterventionContractPost.DoesNotExist:
|
||
return JsonResponse({'status': 'not_found'}, status=404)
|
||
|
||
return JsonResponse({'status': 'invalid'}, status=405)
|
||
|
||
|
||
def update_contract_post_status(request, intervention_id):
|
||
post_id = request.POST.get('post_id')
|
||
status = request.POST.get('status')
|
||
|
||
user_config = get_object_or_404(UserConfig, user=request.user)
|
||
|
||
# Check if the user has the right roles to update contract posts
|
||
# Only users with 'admin', 'manager', or 'controller' roles can update the status
|
||
if not user_config.roles.filter(name__in=['admin', 'manager', 'controller']).exists():
|
||
return JsonResponse({'status': 'error', 'message': 'Permission denied'}, status=403)
|
||
|
||
try:
|
||
post = InterventionContractPost.objects.get(pk=post_id)
|
||
post.control_status = status
|
||
post.save()
|
||
|
||
# Si le statut passe à 'validated', mettre à jour les périodes 'pending' de ce poste
|
||
if status == 'validated':
|
||
InterventionPostPeriodQuantity.objects.filter(
|
||
intervention=post.intervention,
|
||
contract_post=post.contract_post,
|
||
status='pending'
|
||
).update(status='validated')
|
||
|
||
return JsonResponse({'success': True})
|
||
except InterventionContractPost.DoesNotExist:
|
||
return JsonResponse({'success': False, 'error': 'Post not found'}, status=404)
|
||
|
||
|
||
def intervention_posts_bulk_update(request, intervention_id):
|
||
"""
|
||
Mise à jour en masse des postes de contrat d'une intervention.
|
||
Permet de mettre à jour quantités, commentaires, statuts de contrôle et remarques.
|
||
|
||
Format JSON attendu:
|
||
{
|
||
"posts": [
|
||
{
|
||
"contract_post_id": 123,
|
||
"quantity": "10.50", // optionnel
|
||
"comment": "...", // optionnel
|
||
"control_status": "validated", // optionnel: validated, pending, refused
|
||
"control_comment": "...", // optionnel
|
||
"delete": true // optionnel: si true, supprime la ligne existante
|
||
},
|
||
...
|
||
]
|
||
}
|
||
"""
|
||
try:
|
||
data = json.loads(request.body)
|
||
posts_data = data.get('posts', [])
|
||
|
||
if not posts_data:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'message': _('Aucune donnée à mettre à jour')
|
||
}, status=400)
|
||
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
user_config = get_object_or_404(UserConfig, user=request.user)
|
||
|
||
# Vérifier les permissions selon le type de modification
|
||
can_update_posts = user_config.roles.filter(name__in=['admin', 'external_manager']).exists()
|
||
can_control_posts = user_config.roles.filter(name__in=['admin', 'manager', 'controller']).exists()
|
||
|
||
updated_count = 0
|
||
errors = []
|
||
|
||
with transaction.atomic():
|
||
for post_data in posts_data:
|
||
try:
|
||
contract_post_id = post_data.get('contract_post_id')
|
||
if not contract_post_id:
|
||
continue
|
||
|
||
# Suppression directe si drapeau présent
|
||
if post_data.get('delete'):
|
||
if not can_update_posts:
|
||
errors.append(_('Permission refusée pour la suppression de postes'))
|
||
continue
|
||
# Interdire la suppression si une quantité a été enregistrée dans une période précédente
|
||
has_history = InterventionPostPeriodQuantity.objects.filter(
|
||
intervention=intervention,
|
||
contract_post_id=contract_post_id
|
||
).exists()
|
||
if has_history:
|
||
errors.append(_('Suppression interdite: une quantité a été enregistrée pour ce poste dans une période précédente.'))
|
||
continue
|
||
try:
|
||
to_delete = InterventionContractPost.objects.get(
|
||
intervention=intervention,
|
||
contract_post__pk=contract_post_id
|
||
)
|
||
to_delete.delete()
|
||
updated_count += 1
|
||
except InterventionContractPost.DoesNotExist:
|
||
# Rien à supprimer : ignorer silencieusement
|
||
pass
|
||
continue # Passer à l'élément suivant
|
||
|
||
contract_post = get_object_or_404(ContractPost, pk=contract_post_id)
|
||
|
||
# Préparer les données à mettre à jour
|
||
defaults = {}
|
||
quantity_value = None
|
||
|
||
# Quantité et commentaire (nécessitent permission de gestion des postes)
|
||
if 'quantity' in post_data:
|
||
if not can_update_posts:
|
||
errors.append(_('Permission refusée pour la mise à jour des quantités'))
|
||
continue
|
||
quantity = post_data['quantity']
|
||
if quantity not in (None, ''):
|
||
try:
|
||
quantity_value = Decimal(str(quantity))
|
||
defaults['quantity'] = quantity_value
|
||
except (ValueError, InvalidOperation):
|
||
errors.append(_('Quantité invalide pour le poste %(post)s') % {'post': contract_post.code})
|
||
continue
|
||
|
||
if 'comment' in post_data:
|
||
if not can_update_posts:
|
||
errors.append(_('Permission refusée pour la mise à jour des commentaires'))
|
||
continue
|
||
defaults['comment'] = post_data['comment']
|
||
|
||
# Statut et remarque de contrôle (nécessitent permission de contrôle)
|
||
if 'control_status' in post_data:
|
||
if not can_control_posts:
|
||
errors.append(_('Permission refusée pour la mise à jour des statuts'))
|
||
continue
|
||
defaults['control_status'] = post_data['control_status']
|
||
|
||
if 'control_comment' in post_data:
|
||
if can_control_posts:
|
||
defaults['control_comment'] = post_data['control_comment']
|
||
|
||
# Créer ou mettre à jour le poste
|
||
if defaults:
|
||
post_exists = InterventionContractPost.objects.filter(
|
||
intervention=intervention,
|
||
contract_post=contract_post
|
||
).exists()
|
||
|
||
if not post_exists and (quantity_value is None or quantity_value == 0):
|
||
# Ne pas créer de nouvelles lignes sans quantité non nulle
|
||
continue
|
||
|
||
post, created = InterventionContractPost.objects.update_or_create(
|
||
intervention=intervention,
|
||
contract_post=contract_post,
|
||
defaults=defaults
|
||
)
|
||
|
||
# Si le statut passe à 'validated', mettre à jour les périodes 'pending' de ce poste
|
||
if defaults.get('control_status') == 'validated':
|
||
InterventionPostPeriodQuantity.objects.filter(
|
||
intervention=intervention,
|
||
contract_post=contract_post,
|
||
status='pending'
|
||
).update(status='validated')
|
||
|
||
updated_count += 1
|
||
|
||
except ContractPost.DoesNotExist:
|
||
errors.append(_('Poste de contrat non trouvé: %(id)s') % {'id': contract_post_id})
|
||
except Exception as e:
|
||
errors.append(_('Erreur lors de la mise à jour du poste %(id)s: %(error)s') % {
|
||
'id': contract_post_id,
|
||
'error': str(e)
|
||
})
|
||
|
||
if errors:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'message': _('Des erreurs se sont produites lors de la mise à jour'),
|
||
'errors': errors,
|
||
'updated_count': updated_count
|
||
}, status=400)
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'message': ngettext(
|
||
'%(count)d prestation mise à jour avec succès.',
|
||
'%(count)d prestations mises à jour avec succès.',
|
||
updated_count
|
||
) % {'count': updated_count},
|
||
'updated_count': updated_count
|
||
})
|
||
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'message': _('Données JSON invalides')
|
||
}, status=400)
|
||
except Exception as e:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'message': _('Erreur lors de la mise à jour: %(error)s') % {'error': str(e)}
|
||
}, status=500)
|
||
|
||
|
||
def intervention_posts_period_quantities(request, intervention_id):
|
||
"""Retourne l'historique des quantités par période pour chaque poste d'une intervention.
|
||
Réponse JSON:
|
||
{
|
||
success: true,
|
||
periods: [1,2,...],
|
||
items: [
|
||
{ contract_post_id: 123, quantities: {"1": "5.00", "2": "3.50"} },
|
||
...
|
||
]
|
||
}
|
||
"""
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
|
||
# Récupérer toutes les lignes d'historique pour cette intervention
|
||
hist = (InterventionPostPeriodQuantity.objects
|
||
.filter(intervention=intervention)
|
||
.select_related('contract_post')
|
||
.order_by('period_number', 'contract_post__order_number'))
|
||
|
||
periods = sorted(set(hist.values_list('period_number', flat=True)))
|
||
by_post = defaultdict(dict)
|
||
for h in hist:
|
||
by_post[str(h.contract_post_id)][str(h.period_number)] = f"{h.quantity}"
|
||
|
||
items = [
|
||
{ 'contract_post_id': int(pid), 'quantities': qmap }
|
||
for pid, qmap in by_post.items()
|
||
]
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'periods': periods,
|
||
'items': items,
|
||
})
|
||
|
||
|
||
|
||
|
||
def update_intervention(request, intervention_id):
|
||
try:
|
||
intervention = Intervention.objects.get(pk=intervention_id)
|
||
if not can_edit_intervention(request.user, intervention):
|
||
return JsonResponse({'success': False, 'error': _('Vous n\'avez pas la permission de modifier cette intervention.')}, status=403)
|
||
allowed_update_fields = get_allowed_update_fields(request.user, intervention)
|
||
|
||
# Récupérer la configuration utilisateur pour vérifier les permissions sur les contrats
|
||
user_config = UserConfig.objects.filter(user=request.user).first()
|
||
|
||
# Check if request is JSON (CMS-style batch update)
|
||
content_type = request.content_type
|
||
if content_type and 'application/json' in content_type:
|
||
try:
|
||
data = json.loads(request.body)
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({'success': False, 'error': _('Invalid JSON data')}, status=400)
|
||
|
||
# Process multiple fields at once
|
||
updated_fields = []
|
||
errors = []
|
||
permission_denied = False
|
||
|
||
# Handle notes separately (special case)
|
||
notes_data = data.pop('notes', None)
|
||
|
||
# Sort fields to process assigned_provider before contract
|
||
def _field_priority(item):
|
||
f_name = item[0]
|
||
if f_name == 'assigned_provider':
|
||
return 0
|
||
if f_name == 'contract':
|
||
return 1
|
||
return 2
|
||
|
||
for field, new_value in sorted(data.items(), key=_field_priority):
|
||
if field not in allowed_update_fields:
|
||
errors.append(_('You do not have permission to update field: %(field)s') % {'field': field})
|
||
permission_denied = True
|
||
continue
|
||
|
||
success, error = _update_intervention_field(intervention, field, new_value, user_config=user_config)
|
||
if success:
|
||
updated_fields.append(field)
|
||
else:
|
||
errors.append(error)
|
||
|
||
if updated_fields:
|
||
intervention.save()
|
||
|
||
# Handle notes if provided
|
||
if notes_data:
|
||
notes_success, notes_errors = _handle_notes_update(request, intervention, notes_data)
|
||
if notes_success:
|
||
updated_fields.append('notes')
|
||
if notes_errors:
|
||
errors.extend(notes_errors)
|
||
|
||
if errors:
|
||
status_code = 403 if permission_denied and not updated_fields else 400
|
||
return JsonResponse({
|
||
'success': len(updated_fields) > 0,
|
||
'updated_fields': updated_fields,
|
||
'errors': errors,
|
||
'message': _('%(count)s field(s) updated with some errors') % {'count': len(updated_fields)}
|
||
}, status=status_code)
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'updated_fields': updated_fields,
|
||
'message': _('%(count)s field(s) updated successfully') % {'count': len(updated_fields)}
|
||
})
|
||
|
||
# Legacy single-field update (POST form data)
|
||
field = request.POST.get("field")
|
||
new_value = request.POST.get("value")
|
||
|
||
if field not in allowed_update_fields:
|
||
return JsonResponse({'success': False, 'error': _('You do not have permission to update this field')}, status=403)
|
||
|
||
# Handle special fields that require request object
|
||
if field == "planification":
|
||
return _handle_planification_update(request, intervention)
|
||
elif field == "realization":
|
||
return _handle_realization_update(request, intervention)
|
||
|
||
# Handle standard fields
|
||
success, error = _update_intervention_field(intervention, field, new_value, user_config=user_config)
|
||
if not success:
|
||
return JsonResponse({'success': False, 'error': error}, status=400)
|
||
|
||
intervention.save()
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'title': intervention.title,
|
||
'assigned_provider': intervention.assigned_provider.name if intervention.assigned_provider else None,
|
||
'contract': intervention.contract.contract_number if intervention.contract else None,
|
||
'maintain_type': intervention.get_maintain_type_display(),
|
||
'type': intervention.get_type_display(),
|
||
'origin_type': intervention.get_origin_type_display()
|
||
})
|
||
|
||
except Intervention.DoesNotExist:
|
||
return JsonResponse({'success': False, 'error': _('Intervention not found')}, status=404)
|
||
except Exception as e:
|
||
logger.exception("Error updating intervention")
|
||
return JsonResponse({'success': False, 'error': str(e)}, status=500)
|
||
|
||
|
||
def add_intervention_location(request, intervention_id):
|
||
"""Ajoute un objet de localisation (InterventionLocation) à une intervention existante."""
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
|
||
allowed_fields = get_allowed_update_fields(request.user, intervention)
|
||
if 'location' not in allowed_fields:
|
||
return JsonResponse({'success': False, 'error': _('You do not have permission to update the location')}, status=403)
|
||
|
||
try:
|
||
ct_id = int(request.POST.get('ct_id', ''))
|
||
obj_id = int(request.POST.get('obj_id', ''))
|
||
except (ValueError, TypeError):
|
||
return JsonResponse({'success': False, 'error': _('Invalid parameters')}, status=400)
|
||
|
||
try:
|
||
content_type = ContentType.objects.get(pk=ct_id)
|
||
except ContentType.DoesNotExist:
|
||
return JsonResponse({'success': False, 'error': _('Content type not found')}, status=404)
|
||
|
||
model_class = content_type.model_class()
|
||
if model_class is None or not model_class.objects.filter(pk=obj_id).exists():
|
||
return JsonResponse({'success': False, 'error': _('Location object not found')}, status=404)
|
||
|
||
InterventionLocation.objects.get_or_create(
|
||
intervention=intervention,
|
||
content_type_id=ct_id,
|
||
object_id=obj_id,
|
||
)
|
||
|
||
# Mettre à jour les coordonnées et la géométrie depuis l'objet de localisation s'il en a
|
||
coords_updated = False
|
||
loc_obj = model_class.objects.get(pk=obj_id)
|
||
if hasattr(loc_obj, 'lon') and hasattr(loc_obj, 'lat') and loc_obj.lon and loc_obj.lat:
|
||
intervention.lon = loc_obj.lon
|
||
intervention.lat = loc_obj.lat
|
||
pt = Point(loc_obj.lon, loc_obj.lat, srid=4326)
|
||
pt.transform(3812)
|
||
intervention.geom = to_multipolygon(pt.buffer(1))
|
||
intervention.save(update_fields=['lon', 'lat', 'geom'])
|
||
coords_updated = True
|
||
|
||
localisations = resolve_intervention_localisations(intervention)
|
||
return JsonResponse({
|
||
'success': True,
|
||
'localisations': localisations,
|
||
'lon': intervention.lon,
|
||
'lat': intervention.lat,
|
||
'coords_updated': coords_updated,
|
||
})
|
||
|
||
|
||
def remove_intervention_location(request, intervention_id, loc_id):
|
||
"""Supprime un lien InterventionLocation d'une intervention existante."""
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
|
||
allowed_fields = get_allowed_update_fields(request.user, intervention)
|
||
if 'location' not in allowed_fields:
|
||
return JsonResponse({'success': False, 'error': _('You do not have permission to update the location')}, status=403)
|
||
|
||
loc = get_object_or_404(InterventionLocation, pk=loc_id, intervention=intervention)
|
||
loc.delete()
|
||
|
||
localisations = resolve_intervention_localisations(intervention)
|
||
return JsonResponse({'success': True, 'localisations': localisations})
|
||
|
||
|
||
|
||
|
||
|
||
def get_contracts_by_provider(request):
|
||
provider_id = request.GET.get("provider_id")
|
||
thematic_id = request.GET.get("thematic_id")
|
||
|
||
if not provider_id:
|
||
return JsonResponse({"error": _("Missing provider_id")}, status=400)
|
||
|
||
user_config = get_object_or_404(UserConfig, user=request.user)
|
||
if not user_config.roles.filter(name__in=['admin', 'manager', 'operator', 'external_manager', 'controller', 'top_manager']).exists() and not request.user.is_superuser:
|
||
return JsonResponse({"error": "Permission denied"}, status=403)
|
||
|
||
if user_config.limit_interventions_to_contracts or not user_config.is_intern:
|
||
accessible_contracts_ids = UserContractAccess.objects.filter(
|
||
user_config=user_config,
|
||
contract__company_id=provider_id,
|
||
can_view_interventions=True
|
||
).values_list('contract__id', flat=True)
|
||
contracts = Contract.objects.filter(id__in=accessible_contracts_ids, company_id=provider_id)
|
||
else:
|
||
contracts = Contract.objects.filter(company_id=provider_id)
|
||
|
||
# Filter by thematic if provided
|
||
if thematic_id:
|
||
contracts = contracts.filter(thematics__id=thematic_id)
|
||
|
||
contracts = contracts.distinct().order_by("contract_number")
|
||
|
||
contract_data = []
|
||
for c in contracts:
|
||
desc = (c.description or '').strip()
|
||
if desc:
|
||
truncated = desc[:45] + ('…' if len(desc) > 45 else '')
|
||
label = f"{c.contract_number} ({truncated})"
|
||
else:
|
||
label = c.contract_number
|
||
contract_data.append({
|
||
"id": c.id,
|
||
"contract_number": c.contract_number,
|
||
"description": desc,
|
||
"label": label,
|
||
})
|
||
|
||
return JsonResponse(contract_data, safe=False)
|
||
|
||
|
||
def add_custom_operation(request, intervention_id):
|
||
"""Add a custom operation to an intervention (supports multiple assets)"""
|
||
from django.http import JsonResponse
|
||
from interventions.views.forms import CustomOperationForm
|
||
from interventions.models import Operation
|
||
from django.contrib.contenttypes.models import ContentType
|
||
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
|
||
# Check permissions - only technician and external_manager can add operations
|
||
user_config = UserConfig.objects.filter(user=request.user).first()
|
||
if not user_config:
|
||
return JsonResponse({'success': False, 'error': _("Accès non autorisé")}, status=403)
|
||
|
||
allowed_update_fields = get_allowed_update_fields(request.user, intervention)
|
||
if 'operations' not in allowed_update_fields:
|
||
return JsonResponse({'success': False, 'error': _("Vous n'avez pas la permission d'ajouter des opérations.")}, status=403)
|
||
|
||
form = CustomOperationForm(request.POST)
|
||
|
||
if not form.is_valid():
|
||
errors = []
|
||
for field, error_list in form.errors.items():
|
||
for error in error_list:
|
||
errors.append(f"{field}: {error}")
|
||
return JsonResponse({'success': False, 'errors': errors}, status=400)
|
||
|
||
data = form.cleaned_data
|
||
operation_type = data['operation_type']
|
||
|
||
# Determine the next order number
|
||
max_order = Operation.objects.filter(intervention=intervention).aggregate(Max('order'))['order__max'] or 0
|
||
|
||
# Get assets list (supports multiple selection)
|
||
assets_params = request.POST.getlist('assets')
|
||
|
||
# If no assets provided or only empty string, create one general operation
|
||
if not assets_params or (len(assets_params) == 1 and assets_params[0] == ''):
|
||
assets_to_create = [(None, None)] # One general operation
|
||
else:
|
||
assets_to_create = []
|
||
for asset_param in assets_params:
|
||
if not asset_param: # Empty string = general operation
|
||
assets_to_create.append((None, None))
|
||
else:
|
||
# Format: "content_type_id:object_id"
|
||
try:
|
||
ct_id, obj_id = asset_param.split(':')
|
||
asset_ct = ContentType.objects.get(pk=int(ct_id))
|
||
assets_to_create.append((asset_ct, int(obj_id)))
|
||
except (ValueError, ContentType.DoesNotExist):
|
||
pass
|
||
|
||
# Create operations for each selected asset
|
||
created_operations = []
|
||
for idx, (asset_ct, asset_id) in enumerate(assets_to_create):
|
||
operation = Operation.objects.create(
|
||
intervention=intervention,
|
||
name_fr=data['name_fr'],
|
||
name_nl=data['name_nl'],
|
||
template_group=data.get('template_group') or _("Opérations personnalisées"),
|
||
order=max_order + 1 + idx,
|
||
is_custom=True,
|
||
created_by=request.user,
|
||
asset_content_type=asset_ct,
|
||
asset_object_id=asset_id,
|
||
)
|
||
|
||
if operation_type == 'status':
|
||
operation.status_set = data['status_set']
|
||
elif operation_type == 'measurement':
|
||
operation.measurement_definition = data['measurement_definition']
|
||
|
||
operation.save()
|
||
created_operations.append(operation.id)
|
||
|
||
# Build success message
|
||
count = len(created_operations)
|
||
if count == 1:
|
||
message = _("Opération personnalisée ajoutée avec succès")
|
||
else:
|
||
message = _("%(count)s opérations personnalisées ajoutées avec succès") % {'count': count}
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'message': message,
|
||
'operation_ids': created_operations,
|
||
'count': count
|
||
})
|
||
|
||
|
||
def add_operation_photo(request, operation_id):
|
||
"""Add a photo to an operation"""
|
||
from interventions.models import OperationPhoto
|
||
|
||
operation = get_object_or_404(Operation, pk=operation_id)
|
||
|
||
# Check permissions - only technician and external_manager can add photos to operations
|
||
user_config = UserConfig.objects.filter(user=request.user).first()
|
||
if not user_config:
|
||
return JsonResponse({'success': False, 'error': _("Accès non autorisé")}, status=403)
|
||
|
||
allowed_update_fields = get_allowed_update_fields(request.user, operation.intervention)
|
||
if 'operations' not in allowed_update_fields:
|
||
return JsonResponse({'success': False, 'error': _("Vous n'avez pas la permission d'ajouter des photos aux opérations.")}, status=403)
|
||
|
||
photo_file = request.FILES.get('photo')
|
||
if not photo_file:
|
||
return JsonResponse({'success': False, 'error': _("Aucune photo fournie")}, status=400)
|
||
|
||
description = request.POST.get('description', '')
|
||
|
||
try:
|
||
photo = OperationPhoto.objects.create(
|
||
operation=operation,
|
||
photo=photo_file,
|
||
uploaded_by=request.user,
|
||
description=description
|
||
)
|
||
|
||
photo_url = build_private_url("interventions", "OperationPhoto", photo.id, "photo") if photo.photo else None
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'message': _("Photo ajoutée avec succès"),
|
||
'photo_id': photo.id,
|
||
'photo_url': photo_url
|
||
})
|
||
except Exception:
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.exception("Error adding operation photo")
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Erreur lors de l'ajout de la photo")
|
||
}, status=500)
|
||
|
||
|
||
def delete_operation_photo(request, photo_id):
|
||
"""Delete an operation photo"""
|
||
from interventions.models import OperationPhoto
|
||
|
||
photo = get_object_or_404(OperationPhoto, pk=photo_id)
|
||
|
||
# Check permissions - only technician and external_manager can delete operation photos
|
||
user_config = UserConfig.objects.filter(user=request.user).first()
|
||
if not user_config:
|
||
return JsonResponse({'success': False, 'error': _("Accès non autorisé")}, status=403)
|
||
|
||
allowed_update_fields = get_allowed_update_fields(request.user, photo.operation.intervention)
|
||
if 'operations' not in allowed_update_fields:
|
||
return JsonResponse({'success': False, 'error': _("Vous n'avez pas la permission de supprimer des photos d'opérations.")}, status=403)
|
||
|
||
if photo.uploaded_by != request.user and not request.user.is_staff:
|
||
return JsonResponse({'success': False, 'error': _("Vous ne pouvez supprimer que vos propres photos")}, status=403)
|
||
|
||
try:
|
||
photo.delete()
|
||
return JsonResponse({
|
||
'success': True,
|
||
'message': _("Photo supprimée avec succès")
|
||
})
|
||
except Exception:
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.exception("Error deleting operation photo")
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Erreur lors de la suppression de la photo")
|
||
}, status=500)
|
||
|
||
|
||
def save_guided_operation_ajax(request, intervention_id):
|
||
"""
|
||
Sauvegarde les données JSON saisies via un template d'opérations guidées.
|
||
Body JSON: { "data": { ... } }
|
||
"""
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
|
||
if not intervention.guided_operation_template:
|
||
return JsonResponse({'success': False, 'error': _("Cette intervention n'a pas de template d'opérations guidées.")}, status=400)
|
||
|
||
allowed_update_fields = get_allowed_update_fields(request.user, intervention)
|
||
if 'operations' not in allowed_update_fields:
|
||
return JsonResponse({'success': False, 'error': _("Vous n'avez pas la permission de modifier les opérations.")}, status=403)
|
||
|
||
try:
|
||
body = json.loads(request.body)
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({'success': False, 'error': _("Format JSON invalide")}, status=400)
|
||
|
||
incoming_data = body.get('data')
|
||
if not isinstance(incoming_data, dict):
|
||
return JsonResponse({'success': False, 'error': _("Le champ 'data' doit être un objet JSON.")}, status=400)
|
||
|
||
guided_data, _created = GuidedOperationData.objects.get_or_create(
|
||
intervention=intervention,
|
||
defaults={'guided_template': intervention.guided_operation_template}
|
||
)
|
||
guided_data.data = incoming_data
|
||
guided_data.save(update_fields=['data', 'updated_at'])
|
||
|
||
return JsonResponse({'success': True, 'message': _("Opérations guidées enregistrées.")})
|
||
|
||
|
||
def autocomplete_giep_assets_ajax(request, intervention_id):
|
||
"""Autocomplete search for NatureRWIAsset."""
|
||
from assets.models import NatureRWIAsset
|
||
from django.db.models import Q
|
||
|
||
q = (request.GET.get('q') or '').strip()
|
||
if len(q) < 1:
|
||
return JsonResponse([], safe=False)
|
||
|
||
qs = NatureRWIAsset.objects.exclude(status='removed')
|
||
qs = qs.filter(
|
||
Q(code__icontains=q) |
|
||
Q(structure_type__icontains=q) |
|
||
Q(comment__icontains=q)
|
||
).order_by('code')[:20]
|
||
|
||
results = []
|
||
for asset in qs:
|
||
label = f"{asset.code}"
|
||
if asset.structure_type:
|
||
label += f" ({asset.get_structure_type_display()})"
|
||
if asset.comment:
|
||
label += f" — {asset.comment[:40]}"
|
||
|
||
results.append({
|
||
'id': asset.id,
|
||
'code': asset.code,
|
||
'label': label,
|
||
'structure_type': asset.structure_type,
|
||
'lon': asset.lon,
|
||
'lat': asset.lat,
|
||
})
|
||
|
||
return JsonResponse(results, safe=False)
|
||
|
||
|
||
def nearby_giep_assets_ajax(request, intervention_id):
|
||
"""Fetch nearby GIEP assets for the current intervention."""
|
||
from assets.models import NatureRWIAsset
|
||
from django.contrib.gis.db.models.functions import Distance
|
||
from django.contrib.gis.geos import Point
|
||
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
lon = intervention.lon
|
||
lat = intervention.lat
|
||
|
||
if lon is None or lat is None:
|
||
qs = NatureRWIAsset.objects.exclude(status='removed').order_by('code')[:30]
|
||
else:
|
||
user_point = Point(float(lon), float(lat), srid=4326)
|
||
user_point.transform(3812)
|
||
|
||
qs = (
|
||
NatureRWIAsset.objects.exclude(status='removed')
|
||
.filter(geom__isnull=False)
|
||
.annotate(distance=Distance('geom', user_point))
|
||
.order_by('distance')[:20]
|
||
)
|
||
|
||
results = []
|
||
for asset in qs:
|
||
dist_m = None
|
||
if hasattr(asset, 'distance') and asset.distance is not None:
|
||
dist_m = int(asset.distance.m)
|
||
|
||
label = f"{asset.code}"
|
||
if asset.structure_type:
|
||
label += f" ({asset.get_structure_type_display()})"
|
||
|
||
results.append({
|
||
'id': asset.id,
|
||
'code': asset.code,
|
||
'label': label,
|
||
'distance': dist_m,
|
||
'structure_type': asset.structure_type,
|
||
'lon': asset.lon,
|
||
'lat': asset.lat,
|
||
})
|
||
|
||
return JsonResponse(results, safe=False)
|
||
|
||
|
||
def nearby_giep_assets_geojson_ajax(request, intervention_id):
|
||
"""Fetch nearby GIEP assets in GeoJSON format for the map picker."""
|
||
import json
|
||
from assets.models import NatureRWIAsset
|
||
from django.contrib.gis.db.models.functions import Distance
|
||
from django.contrib.gis.geos import Point
|
||
from django.db.models import Q
|
||
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
lon = intervention.lon
|
||
lat = intervention.lat
|
||
|
||
# Extract selected asset IDs from the GuidedOperationData model
|
||
selected_asset_ids = []
|
||
guided_data = GuidedOperationData.objects.filter(intervention=intervention).first()
|
||
if guided_data and isinstance(guided_data.data, dict):
|
||
data = guided_data.data
|
||
if 'rwi_asset_id' in data and data['rwi_asset_id']:
|
||
selected_asset_ids.append(data['rwi_asset_id'])
|
||
if 'ouvrages' in data and isinstance(data['ouvrages'], list):
|
||
for ouv in data['ouvrages']:
|
||
if isinstance(ouv, dict) and ouv.get('rwi_asset_id'):
|
||
selected_asset_ids.append(ouv['rwi_asset_id'])
|
||
|
||
selected_q = Q(id__in=selected_asset_ids) if selected_asset_ids else Q(pk__in=[])
|
||
|
||
if lon is None or lat is None:
|
||
qs = (
|
||
NatureRWIAsset.objects.exclude(status='removed')
|
||
.filter(Q(geom__isnull=False) | selected_q)
|
||
.order_by('code')[:100]
|
||
)
|
||
else:
|
||
user_point = Point(float(lon), float(lat), srid=4326)
|
||
user_point.transform(3812)
|
||
|
||
# Select all GIEP assets within 2000m OR explicitly selected to display on the map
|
||
qs = (
|
||
NatureRWIAsset.objects.exclude(status='removed')
|
||
.filter(geom__isnull=False)
|
||
.annotate(distance=Distance('geom', user_point))
|
||
.filter(Q(distance__lte=2000) | selected_q)
|
||
.order_by('distance')
|
||
)
|
||
|
||
features = []
|
||
for asset in qs:
|
||
if asset.geom:
|
||
try:
|
||
# Transform geometry from SRID 3812 to 4326
|
||
geom_4326 = asset.geom.transform(4326, clone=True)
|
||
geojson_geom = json.loads(geom_4326.geojson)
|
||
except Exception:
|
||
continue
|
||
|
||
features.append({
|
||
"type": "Feature",
|
||
"id": asset.id,
|
||
"geometry": geojson_geom,
|
||
"properties": {
|
||
"id": asset.id,
|
||
"code": asset.code,
|
||
"structure_type": asset.structure_type or "",
|
||
"structure_type_display": asset.get_structure_type_display() if asset.structure_type else "",
|
||
}
|
||
})
|
||
|
||
response = JsonResponse({
|
||
"type": "FeatureCollection",
|
||
"features": features,
|
||
}, safe=False)
|
||
response['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||
return response
|
||
|
||
|
||
def upload_guided_operation_media(request, intervention_id):
|
||
"""
|
||
Upload d'un fichier (photo ou document) attaché à une réponse guidée.
|
||
POST: multipart/form-data
|
||
- file: le fichier
|
||
- field_key: identifiant de la question (vide = général)
|
||
- media_type: 'photo' | 'document'
|
||
- description: texte libre (optionnel)
|
||
"""
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
|
||
if not intervention.guided_operation_template:
|
||
return JsonResponse({'success': False, 'error': _("Cette intervention n'a pas de template d'opérations guidées.")}, status=400)
|
||
|
||
allowed_update_fields = get_allowed_update_fields(request.user, intervention)
|
||
if 'operations' not in allowed_update_fields:
|
||
return JsonResponse({'success': False, 'error': _("Vous n'avez pas la permission de modifier les opérations.")}, status=403)
|
||
|
||
uploaded_file = request.FILES.get('file')
|
||
if not uploaded_file:
|
||
return JsonResponse({'success': False, 'error': _("Aucun fichier fourni.")}, status=400)
|
||
|
||
field_key = request.POST.get('field_key', '').strip()[:100]
|
||
media_type = request.POST.get('media_type', 'photo')
|
||
if media_type not in ('photo', 'document'):
|
||
media_type = 'photo'
|
||
description = request.POST.get('description', '').strip()[:255]
|
||
|
||
guided_data, _created = GuidedOperationData.objects.get_or_create(
|
||
intervention=intervention,
|
||
defaults={'guided_template': intervention.guided_operation_template}
|
||
)
|
||
|
||
media = GuidedOperationMedia.objects.create(
|
||
guided_data=guided_data,
|
||
field_key=field_key,
|
||
file=uploaded_file,
|
||
media_type=media_type,
|
||
description=description,
|
||
uploaded_by=request.user,
|
||
)
|
||
|
||
# Build private URL for response
|
||
from common.private_files.helpers import build_private_url
|
||
file_url = build_private_url('interventions', 'guidedoperationmedia', media.pk, 'file')
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'media_id': media.id,
|
||
'file_url': file_url,
|
||
'media_type': media.media_type,
|
||
'field_key': media.field_key,
|
||
'description': media.description,
|
||
})
|
||
|
||
|
||
def delete_guided_operation_media(request, media_id):
|
||
"""
|
||
Supprime un média d'opérations guidées.
|
||
"""
|
||
media = get_object_or_404(GuidedOperationMedia, pk=media_id)
|
||
intervention = media.guided_data.intervention
|
||
|
||
allowed_update_fields = get_allowed_update_fields(request.user, intervention)
|
||
if 'operations' not in allowed_update_fields:
|
||
return JsonResponse({'success': False, 'error': _("Vous n'avez pas la permission de supprimer ce fichier.")}, status=403)
|
||
|
||
try:
|
||
media.file.delete(save=False)
|
||
media.delete()
|
||
return JsonResponse({'success': True})
|
||
except Exception:
|
||
import logging
|
||
logging.getLogger(__name__).exception("Error deleting guided operation media")
|
||
return JsonResponse({'success': False, 'error': _("Erreur lors de la suppression.")}, status=500)
|
||
|
||
|
||
def save_operations_ajax(request, intervention_id):
|
||
"""
|
||
Save operations via AJAX without page reload.
|
||
Expects JSON payload with operations data.
|
||
"""
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
|
||
# Check if user has permission to edit operations (only technician and external_manager)
|
||
allowed_update_fields = get_allowed_update_fields(request.user, intervention)
|
||
if 'operations' not in allowed_update_fields:
|
||
return JsonResponse({'success': False, 'error': _("Vous n'avez pas la permission de modifier les opérations.")}, status=403)
|
||
|
||
try:
|
||
data = json.loads(request.body)
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({'success': False, 'error': _("Format JSON invalide")}, status=400)
|
||
|
||
operations_data = data.get('operations', [])
|
||
saved_count = 0
|
||
errors = []
|
||
|
||
for op_data in operations_data:
|
||
op_id = op_data.get('id')
|
||
if not op_id:
|
||
continue
|
||
|
||
try:
|
||
operation = Operation.objects.get(pk=op_id, intervention=intervention)
|
||
|
||
# Handle "Not Applicable" flag
|
||
if 'is_not_applicable' in op_data:
|
||
is_na = op_data['is_not_applicable']
|
||
if is_na:
|
||
operation.is_not_applicable = True
|
||
operation.measured_value = None
|
||
operation.measured_duration = None
|
||
operation.measured_datetime = None
|
||
operation.status = None
|
||
operation.save()
|
||
saved_count += 1
|
||
continue
|
||
else:
|
||
operation.is_not_applicable = False
|
||
|
||
# Handle measurement value
|
||
if 'measured_value' in op_data:
|
||
value = op_data['measured_value']
|
||
if value is not None and value != '':
|
||
try:
|
||
operation.measured_value = Decimal(str(value))
|
||
operation.measured_duration = None
|
||
operation.measured_datetime = None
|
||
operation.status = None
|
||
operation.is_not_applicable = False
|
||
except (InvalidOperation, ValueError):
|
||
errors.append(_("évaleur de mesure invalide pour l'opération %(id)s") % {'id': op_id})
|
||
continue
|
||
else:
|
||
operation.measured_value = None
|
||
|
||
# Handle duration value
|
||
if 'measured_duration' in op_data:
|
||
value = op_data['measured_duration']
|
||
if value is not None and value != '':
|
||
try:
|
||
parts = str(value).split(':')
|
||
if len(parts) == 3:
|
||
h, m, s = int(parts[0]), int(parts[1]), int(parts[2])
|
||
operation.measured_duration = timedelta(hours=h, minutes=m, seconds=s)
|
||
operation.measured_value = None
|
||
operation.measured_datetime = None
|
||
operation.status = None
|
||
operation.is_not_applicable = False
|
||
else:
|
||
errors.append(_("Format de durée invalide pour l'opération %(id)s") % {'id': op_id})
|
||
continue
|
||
except (ValueError, AttributeError):
|
||
errors.append(_("Valeur de durée invalide pour l'opération %(id)s") % {'id': op_id})
|
||
continue
|
||
else:
|
||
operation.measured_duration = None
|
||
|
||
# Handle datetime value
|
||
if 'measured_datetime' in op_data:
|
||
value = op_data['measured_datetime']
|
||
if value is not None and value != '':
|
||
try:
|
||
dt = parse_datetime(str(value))
|
||
if dt is None:
|
||
dt = datetime.fromisoformat(str(value))
|
||
if dt and is_naive(dt):
|
||
dt = make_aware(dt, get_current_timezone())
|
||
operation.measured_datetime = dt
|
||
operation.measured_value = None
|
||
operation.measured_duration = None
|
||
operation.status = None
|
||
operation.is_not_applicable = False
|
||
except (ValueError, TypeError):
|
||
errors.append(_("Valeur de date invalide pour l'opération %(id)s") % {'id': op_id})
|
||
continue
|
||
else:
|
||
operation.measured_datetime = None
|
||
|
||
# Handle status
|
||
if 'status_id' in op_data:
|
||
status_id = op_data['status_id']
|
||
if status_id:
|
||
try:
|
||
status = OperationStatus.objects.get(pk=status_id)
|
||
operation.status = status
|
||
operation.measured_value = None
|
||
operation.is_not_applicable = False
|
||
except OperationStatus.DoesNotExist:
|
||
errors.append(_("Statut invalide pour l'opération %(id)s") % {'id': op_id})
|
||
continue
|
||
else:
|
||
operation.status = None
|
||
|
||
operation.save()
|
||
saved_count += 1
|
||
|
||
except Operation.DoesNotExist:
|
||
errors.append(_("Opération %(id)s introuvable") % {'id': op_id})
|
||
except Exception as e:
|
||
errors.append(_("Erreur pour l'opération %(id)s : %(err)s") % {'id': op_id, 'err': str(e)})
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'saved_count': saved_count,
|
||
'errors': [str(e) for e in errors],
|
||
'message': _("%(count)s opération(s) sauvegardée(s)") % {'count': saved_count}
|
||
})
|
||
|
||
|
||
def check_asset_templates_for_intervention(request, intervention_id):
|
||
"""
|
||
Vérifie si un asset a des templates d'opérations correspondants pour une intervention préventive.
|
||
Si aucun template ne correspond, retourne la liste des templates disponibles pour l'intervention.
|
||
|
||
Body JSON:
|
||
{
|
||
"asset_type": "structuregeoasset", # content_type.model
|
||
"asset_id": 123
|
||
}
|
||
|
||
Response JSON:
|
||
{
|
||
"success": true,
|
||
"has_matching_templates": false,
|
||
"available_templates": [
|
||
{"id": 1, "name": "Template 1", "description": "..."},
|
||
...
|
||
]
|
||
}
|
||
"""
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
|
||
# Vérifier que c'est une intervention préventive
|
||
if intervention.maintain_type != 'preventive':
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Cette fonctionnalité n'est disponible que pour les interventions préventives")
|
||
}, status=400)
|
||
|
||
try:
|
||
data = json.loads(request.body)
|
||
asset_type = data.get('asset_type')
|
||
asset_id = data.get('asset_id')
|
||
|
||
if not asset_type or not asset_id:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("asset_type et asset_id requis")
|
||
}, status=400)
|
||
|
||
# Récupérer l'asset
|
||
ct = ContentType.objects.filter(app_label='assets', model=asset_type.lower()).first() or ContentType.objects.filter(model=asset_type.lower()).first()
|
||
model_class = ct.model_class() if ct else None
|
||
if not model_class:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Type d'asset invalide")
|
||
}, status=400)
|
||
|
||
asset_obj = model_class.objects.get(pk=asset_id)
|
||
|
||
# Récupérer les templates d'équipement de l'intervention
|
||
templates = _get_equipment_templates_for_intervention(intervention)
|
||
|
||
if not templates:
|
||
return JsonResponse({
|
||
'success': True,
|
||
'has_templates': False,
|
||
'available_templates': [],
|
||
'message': _("Cette intervention n'a pas de templates d'opérations équipement")
|
||
})
|
||
|
||
# Retourner tous les templates disponibles pour que l'utilisateur choisisse
|
||
# On n'applique pas les filtres car l'utilisateur a choisi manuellement d'ajouter cet asset
|
||
available_templates_data = [
|
||
{
|
||
'id': t.id,
|
||
'name': t.name,
|
||
'description': t.description or ''
|
||
}
|
||
for t in templates
|
||
]
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'has_templates': True,
|
||
'available_templates': available_templates_data,
|
||
'message': _("Choisissez le(s) template(s) d'opérations à appliquer à cet équipement.")
|
||
})
|
||
|
||
except ContentType.DoesNotExist:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Type d'asset non trouvé")
|
||
}, status=404)
|
||
except model_class.DoesNotExist:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Équipement non trouvé")
|
||
}, status=404)
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Format JSON invalide")
|
||
}, status=400)
|
||
except Exception as e:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': str(e)
|
||
}, status=500)
|
||
|
||
|
||
def create_operations_for_linked_assets(request, intervention_id):
|
||
"""
|
||
Create operations for multiple newly linked assets from user-selected templates.
|
||
Each asset can have different templates selected.
|
||
"""
|
||
try:
|
||
data = json.loads(request.body)
|
||
assets_with_templates = data.get('assets', []) # [{'content_type_id': X, 'object_id': Y, 'template_ids': [...]}, ...]
|
||
|
||
if not isinstance(assets_with_templates, list) or len(assets_with_templates) == 0:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _('Liste d\'assets requise')
|
||
}, status=400)
|
||
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
|
||
total_operations = 0
|
||
total_assets_processed = 0
|
||
|
||
for asset_data in assets_with_templates:
|
||
content_type_id = asset_data.get('content_type_id')
|
||
object_id = asset_data.get('object_id')
|
||
template_ids = asset_data.get('template_ids', [])
|
||
|
||
if not content_type_id or not object_id or not template_ids:
|
||
continue
|
||
|
||
try:
|
||
content_type = ContentType.objects.get(pk=content_type_id)
|
||
asset = content_type.get_object_for_this_type(pk=object_id)
|
||
|
||
# Get selected templates
|
||
templates = intervention.equipment_operation_templates.filter(
|
||
pk__in=template_ids
|
||
)
|
||
|
||
if templates.exists():
|
||
# Create operations for this asset
|
||
operations_created = _add_operations_from_templates(
|
||
intervention,
|
||
list(templates),
|
||
asset_obj=asset,
|
||
asset_ct=content_type
|
||
)
|
||
total_operations += len(operations_created)
|
||
total_assets_processed += 1
|
||
except (ContentType.DoesNotExist, Exception) as e:
|
||
logger.warning(f"Error processing asset {content_type_id}:{object_id}: {e}")
|
||
continue
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'assets_processed': total_assets_processed,
|
||
'operations_created': total_operations,
|
||
'message': ngettext(
|
||
'%(ops)d opération créée pour %(assets)d équipement',
|
||
'%(ops)d opérations créées pour %(assets)d équipements',
|
||
total_operations
|
||
) % {'ops': total_operations, 'assets': total_assets_processed}
|
||
})
|
||
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _('Format JSON invalide')
|
||
}, status=400)
|
||
except Exception as e:
|
||
logger.exception("Error creating operations for linked assets")
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': str(e)
|
||
}, status=500)
|
||
|
||
|
||
def get_newly_linked_assets_for_templates(request, intervention_id):
|
||
"""
|
||
Get newly linked assets from session and available templates for each.
|
||
Returns asset details and available operation templates.
|
||
"""
|
||
try:
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
|
||
# Get newly linked assets from session
|
||
session_key = f'new_linked_assets_{intervention_id}'
|
||
new_asset_keys = request.session.get(session_key, [])
|
||
|
||
if not new_asset_keys:
|
||
return JsonResponse({
|
||
'success': True,
|
||
'has_new_assets': False,
|
||
'assets': []
|
||
})
|
||
|
||
# Parse asset keys and get asset details
|
||
assets_data = []
|
||
for key in new_asset_keys:
|
||
try:
|
||
ct_id, obj_id = key.split(':')
|
||
content_type = ContentType.objects.get(pk=int(ct_id))
|
||
asset = content_type.get_object_for_this_type(pk=int(obj_id))
|
||
|
||
# Get asset display info
|
||
asset_code = getattr(asset, 'code', '')
|
||
asset_name = asset.get_name() if hasattr(asset, 'get_name') else str(asset)
|
||
|
||
assets_data.append({
|
||
'content_type_id': int(ct_id),
|
||
'object_id': int(obj_id),
|
||
'code': asset_code,
|
||
'name': asset_name,
|
||
'display': f"{asset_code} - {asset_name}" if asset_code else asset_name
|
||
})
|
||
except (ValueError, ContentType.DoesNotExist, Exception) as e:
|
||
logger.warning(f"Could not resolve asset from key {key}: {e}")
|
||
continue
|
||
|
||
# Get available templates (all templates for this intervention)
|
||
templates = intervention.equipment_operation_templates.all()
|
||
|
||
templates_data = [{
|
||
'id': template.id,
|
||
'name': template.name,
|
||
'description': template.description or '',
|
||
'code': template.code or ''
|
||
} for template in templates]
|
||
|
||
# Clear session data after retrieving
|
||
if session_key in request.session:
|
||
del request.session[session_key]
|
||
request.session.modified = True
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'has_new_assets': len(assets_data) > 0,
|
||
'assets': assets_data,
|
||
'templates': templates_data
|
||
})
|
||
|
||
except Exception as e:
|
||
logger.exception("Error getting newly linked assets")
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': str(e)
|
||
}, status=500)
|
||
|
||
|
||
def create_operations_from_selected_template(request, intervention_id):
|
||
"""
|
||
Crée des opérations pour un asset à partir d'un ou plusieurs templates sélectionnés manuellement.
|
||
Utilisé quand l'utilisateur choisit manuellement les templates à appliquer.
|
||
|
||
Body JSON:
|
||
{
|
||
"asset_type": "structuregeoasset", # content_type.model
|
||
"asset_id": 123,
|
||
"template_ids": [5, 7, 9] # Liste de template IDs
|
||
}
|
||
|
||
Response JSON:
|
||
{
|
||
"success": true,
|
||
"operations_created": 12
|
||
}
|
||
"""
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
|
||
# Vérifier que c'est une intervention préventive
|
||
if intervention.maintain_type != 'preventive':
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Cette fonctionnalité n'est disponible que pour les interventions préventives")
|
||
}, status=400)
|
||
|
||
try:
|
||
data = json.loads(request.body)
|
||
asset_type = data.get('asset_type')
|
||
asset_id = data.get('asset_id')
|
||
template_ids = data.get('template_ids', [])
|
||
|
||
if not asset_type or not asset_id or not template_ids:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("asset_type, asset_id et template_ids requis")
|
||
}, status=400)
|
||
|
||
if not isinstance(template_ids, list) or len(template_ids) == 0:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("template_ids doit être une liste non vide")
|
||
}, status=400)
|
||
|
||
# Récupérer l'asset
|
||
ct = ContentType.objects.filter(app_label='assets', model=asset_type.lower()).first() or ContentType.objects.filter(model=asset_type.lower()).first()
|
||
model_class = ct.model_class() if ct else None
|
||
if not model_class:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Type d'asset invalide")
|
||
}, status=400)
|
||
|
||
asset_obj = model_class.objects.get(pk=asset_id)
|
||
|
||
# Récupérer tous les templates sélectionnés
|
||
templates = OperationTemplate.objects.filter(pk__in=template_ids)
|
||
|
||
if templates.count() != len(template_ids):
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Certains templates n'ont pas été trouvés")
|
||
}, status=404)
|
||
|
||
# Créer les opérations pour cet asset avec tous les templates sélectionnés
|
||
created_ops = _add_operations_from_templates(
|
||
intervention,
|
||
list(templates),
|
||
asset_obj=asset_obj,
|
||
asset_ct=ct,
|
||
)
|
||
|
||
template_names = ', '.join([t.name for t in templates])
|
||
return JsonResponse({
|
||
'success': True,
|
||
'operations_created': len(created_ops),
|
||
'templates_applied': templates.count(),
|
||
'message': ngettext(
|
||
'%(count)d opération créée avec succès depuis %(templates)d template',
|
||
'%(count)d opérations créées avec succès depuis %(templates)d templates',
|
||
len(created_ops)
|
||
) % {'count': len(created_ops), 'templates': templates.count()}
|
||
})
|
||
|
||
except ContentType.DoesNotExist:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Type d'asset non trouvé")
|
||
}, status=404)
|
||
except model_class.DoesNotExist:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Équipement non trouvé")
|
||
}, status=404)
|
||
except OperationTemplate.DoesNotExist:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Un ou plusieurs templates non trouvés")
|
||
}, status=404)
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Format JSON invalide")
|
||
}, status=400)
|
||
except Exception as e:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': str(e)
|
||
}, status=500)
|
||
|
||
|
||
@login_required
|
||
@require_POST
|
||
def toggle_material_used(request, intervention_id, item_id):
|
||
"""
|
||
Bascule l'état is_used d'un article de matériel (catalogue ou autre item) lié à l'intervention.
|
||
"""
|
||
from interventions.permissions import can_edit_intervention
|
||
from stock.models import PreparationOrderItem, PreparationOrderOtherItem
|
||
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
if not can_edit_intervention(request.user, intervention):
|
||
return JsonResponse({'success': False, 'error': _("Vous n'avez pas la permission de modifier cette intervention.")}, status=403)
|
||
|
||
is_other = request.POST.get('is_other') == 'true' or request.GET.get('is_other') == 'true'
|
||
set_value = request.POST.get('is_used')
|
||
|
||
order_ids = intervention.preparation_orders.values_list('id', flat=True)
|
||
|
||
if is_other:
|
||
try:
|
||
item = PreparationOrderOtherItem.objects.get(pk=item_id, preparation_order_id__in=order_ids)
|
||
except PreparationOrderOtherItem.DoesNotExist:
|
||
return JsonResponse({'success': False, 'error': _("Article introuvable pour cette intervention.")}, status=404)
|
||
else:
|
||
try:
|
||
item = PreparationOrderItem.objects.get(pk=item_id, preparation_order_id__in=order_ids)
|
||
except PreparationOrderItem.DoesNotExist:
|
||
return JsonResponse({'success': False, 'error': _("Article introuvable pour cette intervention.")}, status=404)
|
||
|
||
if set_value is not None:
|
||
item.is_used = (set_value in ('true', '1', True))
|
||
else:
|
||
item.is_used = not item.is_used
|
||
|
||
item.save(update_fields=['is_used'])
|
||
|
||
stats = intervention.get_material_stats()
|
||
order = item.preparation_order
|
||
order_all_used = order.all_items_used
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'item_id': item.id,
|
||
'is_other': is_other,
|
||
'is_used': item.is_used,
|
||
'order_id': order.id,
|
||
'order_all_used': order_all_used,
|
||
'used_count': stats['used'],
|
||
'total_count': stats['total'],
|
||
'all_used': stats['all_used'],
|
||
})
|
||
|
||
|
||
@login_required
|
||
@require_POST
|
||
def toggle_all_material_used(request, intervention_id):
|
||
"""
|
||
Coche ou décoche l'ensemble du matériel de l'intervention (ou d'un bon de préparation spécifique).
|
||
Paramètres optionnels dans POST :
|
||
- order_id : si spécifié, applique uniquement au bon indiqué
|
||
- used : booléen optionnel ('true'/'false'). Si absent, bascule :
|
||
si tous sont utilisés -> passe à False, sinon -> passe à True.
|
||
"""
|
||
from interventions.permissions import can_edit_intervention
|
||
from stock.models import PreparationOrder, PreparationOrderItem, PreparationOrderOtherItem
|
||
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
if not can_edit_intervention(request.user, intervention):
|
||
return JsonResponse({'success': False, 'error': _("Vous n'avez pas la permission de modifier cette intervention.")}, status=403)
|
||
|
||
order_id = request.POST.get('order_id')
|
||
orders = intervention.preparation_orders.all()
|
||
if order_id:
|
||
orders = orders.filter(pk=order_id)
|
||
if not orders.exists():
|
||
return JsonResponse({'success': False, 'error': _("Bon de préparation introuvable.")}, status=404)
|
||
|
||
order_ids = list(orders.values_list('id', flat=True))
|
||
std_items = PreparationOrderItem.objects.filter(preparation_order_id__in=order_ids)
|
||
other_items = PreparationOrderOtherItem.objects.filter(preparation_order_id__in=order_ids)
|
||
|
||
total_target = std_items.count() + other_items.count()
|
||
if total_target == 0:
|
||
return JsonResponse({'success': True, 'all_used': False, 'used_count': 0, 'total_count': 0})
|
||
|
||
used_param = request.POST.get('used')
|
||
if used_param is not None:
|
||
target_state = (used_param in ('true', '1', True))
|
||
else:
|
||
target_used_count = std_items.filter(is_used=True).count() + other_items.filter(is_used=True).count()
|
||
target_state = not (target_used_count == total_target)
|
||
|
||
std_items.update(is_used=target_state)
|
||
other_items.update(is_used=target_state)
|
||
|
||
stats = intervention.get_material_stats()
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'all_used': target_state,
|
||
'intervention_all_used': stats['all_used'],
|
||
'used_count': stats['used'],
|
||
'total_count': stats['total'],
|
||
'order_id': int(order_id) if order_id else None,
|
||
})
|
||
|