loko/streetup/interventions/views/detail.py

3682 lines
160 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from django.http import HttpResponse, JsonResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseRedirect
from django.shortcuts import render, get_object_or_404, redirect
from django.template.loader import render_to_string
from django.urls import reverse, NoReverseMatch
from django.core.serializers import serialize
from django.core.exceptions import FieldDoesNotExist
from django.core.files.base import ContentFile
from django.contrib.gis.db.models.functions import Transform, Centroid, AsGeoJSON, Distance
from django.contrib.gis.geos import GEOSGeometry, Point, MultiPolygon
from django.contrib.gis.measure import D
from django.contrib.contenttypes.models import ContentType
from django.contrib import messages
from django.views.decorators.http import require_POST, require_GET, require_http_methods
from django.contrib.auth.decorators import login_required
from django.utils.timezone import now, localtime, make_aware, get_current_timezone, is_naive
from django.utils import timezone as django_timezone
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from django.utils.dateformat import format as dj_format_date
from django.utils.text import Truncator
from django.utils.html import strip_tags
from django.template.defaultfilters import linebreaksbr
from django.utils.translation import get_language, gettext as _, gettext_lazy as _lazy, ngettext
from django.apps import apps
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.db.models import F, DateTimeField, Q, Case, When, IntegerField, CharField, Value, Count, Prefetch, Sum, Max, QuerySet, OuterRef, Subquery, Exists
from django.db.models.functions import Coalesce, Lower, Cast
from django.db.models.expressions import ExpressionWrapper
from django.db.models import DurationField
from django.db.models.fields.related import ForeignKey
from django.db import transaction
from collections import defaultdict, OrderedDict
from decimal import Decimal, InvalidOperation
import json
import os
import io
import zipfile
from fpdf import FPDF
import pytz
from urllib.parse import urlencode
import re
from html import unescape
from typing import List, Iterable, Tuple, Dict, Optional, Set
import mimetypes
import csv
from io import BytesIO
from datetime import timedelta, datetime
from dateutil.relativedelta import relativedelta
from common.private_files.helpers import build_private_url
from common.utils import get_short_name
from interventions.models import (InterventionSubscription, Intervention, InterventionAsset, InterventionTimeLine, InterventionContractPost, InterventionDocument, InterventionNote, InterventionLocation,
InterventionLink,
ContractPauseReasonAssignment,
Symptom, SourceCategory, InterventionPlanificationTimeLine, InterventionRealizationTimeLine,
InterventionOccupation,
Operation, OperationTemplate, OperationTemplateItem, OperationStatus, OperationStatusSet, MeasurementDefinition,
GuidedOperationTemplate, GuidedOperationData, GuidedOperationMedia,
InterventionPostPeriodQuantity, InterventionEmailLog, InterventionAssignment)
from interventions.models import STATUS_CHOICES, STATUS_ORDERS, MAINTAIN_CHOICES, TYPE_CHOICES, ORIGIN_TYPE_CHOICES, PAUSE_REASON_CHOICES
from interventions.views.forms import (InterventionForm, InterventionNoteForm, InterventionDocumentForm,
InterventionOccupationForm, PreventiveInterventionForm, AssetsLinkForm,
LinkExistingInterventionsForm)
from interventions.permissions import (intervention_permission_required, intervention_add_permission_required, intervention_edit_permission_required,
intervention_add_preventive_permission_required,
get_allowed_statuses_for_user, get_allowed_update_fields,
can_view_intervention, can_control_intervention, can_edit_intervention,
can_view_intervention_summary,
get_accessible_interventions_for_technician, filter_viewable_interventions_for_user,
can_edit_note, can_delete_document, can_manage_occupations,
can_add_intervention, can_add_repair_intervention,
intervention_manage_occupations_required,
get_contracts_for_intervention_creation, is_external_manager_with_contract_creation_rights,
should_show_created_by_provider_badge, can_view_correction_messages)
from interventions.permissions import ALLOWED_TRANSITIONS, get_allowed_transitions, user_allowed_transitions
from interventions.views.drafts import InterventionDraftManager
from interventions.utils import resolve_intervention_localisations
from common.models import UserConfig, UserContractAccess, UserContractStatusPermission, Thematic, UserThematics
from contracts.models import ContractOrder, ContractPost, Contract, Company, CompanyTeam, CompanyMember, UserCompanyTeamAccess, CompanyMemberHistory
from contracts.permissions import get_allowed_contracts_for_user, user_has_access_to_contract_order
from projects.models import Project, ProjectIntervention
from assets.models import (
PublicLightingStreet,
TrafficLightIntersection, TrafficLightContract,
NatureLocation,
StructureLocation, Structure, StructureContract,
RoadStreet,
SignStreet,
AssetCategory,
AbstractAsset,
AbstractAssetModel,
ITSGeoAsset, ITSGeoAssetContract,
CleanLocation, CleanLocationContract,
)
from assets.permissions import get_allowed_actions_for_asset
from assets.views.locations import LOCATION_REGISTRY, registry_items_for_thematics, base_search_qs, SEARCH_LIMIT_PER_TYPE, SEARCH_LIMIT_TOTAL
from common.utils import redirect_with_variant, measure_time, strip_accents_lower
from observations.permissions import get_observation_access_context
from observations.models import Observation as ObservationModel
from controls.permissions import user_can_view_control
from interventions.views.views_pdf_reporting import (
generate_intervention_pdf,
preview_intervention_pdf,
download_intervention_report_pdf,
preview_intervention_report_pdf,
bulk_execution_report_pdf,
start_bulk_execution_report,
poll_bulk_execution_report,
download_bulk_execution_report,
)
import logging
import smtplib
import socket
import ssl
logger = logging.getLogger(__name__)
from interventions.views.helpers import *
def 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
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 = ContractOrder.objects.filter(contract=contract).exclude(order_status__in=['cancelled', 'paid'])
else:
has_coefficient_posts = False
contract_id = intervention.order.contract.id if intervention.order else None
# Limiter les providers et contracts selon les permissions de l'utilisateur
if user_config.is_intern:
# Utilisateurs internes : 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().values("id", "contract_number")
else:
# Utilisateurs externes (external_managers) : limiter aux contrats accessibles
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().values("id", "contract_number")
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
related_interventions = (
Intervention.objects
.annotate(
effective_end=Coalesce(
'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().order_by('-date') if hasattr(intervention, 'preparation_orders') else []
# 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.observations.select_related('thematic', 'category').order_by('-created_at')
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)
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,
'can_control': can_control,
'is_draft': is_draft,
'providers': providers,
'contracts': contracts,
'maintain_choices': MAINTAIN_CHOICES,
'type_choices': TYPE_CHOICES,
'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_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,
# 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
'photos_required': bool(intervention.assigned_team and intervention.assigned_team.photos_required),
'photos_required_since': (lambda s: s.isoformat() if s else '')(
_get_photos_required_since(intervention)
) if (intervention.assigned_team and intervention.assigned_team.photos_required) else '',
# Observations liées
'linked_observations': linked_observations,
# Observation source
'source_observation': source_observation,
# Discussion
'discussion_messages': discussion_messages,
'can_post_discussion_message': can_post_discussion_message,
# Document permissions
'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 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 aucun contrat encore défini via la commande, on utilise celui du symptôme
if not intervention.contract and intervention.symptom:
intervention.contract = intervention.symptom.contract
if intervention.symptom and not intervention.assigned_provider:
intervention.assigned_provider = intervention.symptom.provider
# 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).only('code').first()
if first_loc_obj and hasattr(first_loc_obj, 'code'):
intervention.location_code = first_loc_obj.code
intervention.save(update_fields=['location_code'])
# -------- Recherche contrat --------
# Si thématique 'structures', recherche d'une règle de routage spécifique dans contract_routing.json
if thematic.code == 'structures':
structure_location_ct = ContentType.objects.get_for_model(StructureLocation)
structure_loc = InterventionLocation.objects.filter(
intervention=intervention,
content_type=structure_location_ct
).first()
if structure_loc:
structure_location = StructureLocation.objects.filter(id=structure_loc.object_id).only('code').first()
structure = structure_location.structure if structure_location else None
if structure:
from interventions.models import DirectContractRouting
# 1. Tenter de trouver une règle avec le symptôme spécifique
routing = DirectContractRouting.objects.filter(
thematic='structures',
asset_code=structure.code,
symptom=intervention.symptom
).select_related('contract').first()
# 2. Si non trouvé, tenter de trouver la règle par défaut pour cet asset
if not routing:
routing = DirectContractRouting.objects.filter(
thematic='structures',
asset_code=structure.code,
symptom__isnull=True
).select_related('contract').first()
if routing and routing.contract:
contract = routing.contract
intervention.contract = contract
intervention.assigned_provider = contract.company
intervention.save(update_fields=['contract', 'assigned_provider'])
# Si pas de contrat défini et que le symptôme a un maintenance_type,
# chercher le contrat actif lié à ce type de maintenance.
if (
not intervention.contract
and intervention.symptom
and intervention.symptom.maintenance_type
):
contract = None
if thematic.code == 'trafficlights':
# Récupérer l'intersection depuis les localisations
tl_intersection_ct = ContentType.objects.get_for_model(TrafficLightIntersection)
intersection_location = InterventionLocation.objects.filter(
intervention=intervention,
content_type=tl_intersection_ct
).first()
if intersection_location:
# Chercher le TrafficLightContract correspondant au maintenance_type du symptôme
contract = TrafficLightContract.objects.filter(
intersection_id=intersection_location.object_id,
maintenance_type=intervention.symptom.maintenance_type,
status='active'
).select_related('contract__company').first()
elif thematic.code == 'structures':
structure_location_ct = ContentType.objects.get_for_model(StructureLocation)
structure_loc = InterventionLocation.objects.filter(
intervention=intervention,
content_type=structure_location_ct
).first()
structure_location = StructureLocation.objects.filter(id=structure_loc.object_id).first() if structure_loc else None
structure = structure_location.structure if structure_location else None
if structure:
contract = StructureContract.objects.filter(
structure_id=structure.id,
maintenance_type=intervention.symptom.maintenance_type,
status='active'
).select_related('contract__company').first()
elif thematic.code == 'clean':
location_ct = ContentType.objects.get_for_model(CleanLocation)
clean_loc = InterventionLocation.objects.filter(
intervention=intervention,
content_type=location_ct
).first()
clean_location = CleanLocation.objects.filter(id=clean_loc.object_id).first() if clean_loc else None
if clean_location:
contract = CleanLocationContract.objects.filter(
location_id=clean_location.id,
maintenance_type=intervention.symptom.maintenance_type,
status='active'
).select_related('contract__company').first()
if contract:
intervention.contract = contract.contract
if not intervention.assigned_provider:
intervention.assigned_provider = contract.contract.company
intervention.save(update_fields=['contract', 'assigned_provider'])
# -------- 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:
# ATTENTION: .get(model=...) suppose unicité du model name à travers les apps
# Préfère envoyer equip_ct depuis le front si possible.
ct = ContentType.objects.get(model=equip_type.lower())
asset_pairs = [(ct.id, int(equip_id))]
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 inspection_config:
templates = list(inspection_config.operation_templates.all())
if templates:
intervention.equipment_operation_templates.add(*templates)
_assign_guided_template(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)
# -------- Recherche contrat ITS (après sauvegarde des assets) --------
if (
thematic.code == 'its'
and not intervention.contract
and intervention.symptom
and intervention.symptom.maintenance_type
):
its_equipment_ct = ContentType.objects.get_for_model(ITSGeoAsset)
its_equipment = InterventionAsset.objects.filter(
intervention=intervention,
content_type=its_equipment_ct
).first()
if its_equipment:
its_contract = ITSGeoAssetContract.objects.filter(
asset_id=its_equipment.object_id,
maintenance_type=intervention.symptom.maintenance_type,
status='active'
).select_related('contract__company').first()
if its_contract:
intervention.contract = its_contract.contract
if not intervention.assigned_provider:
intervention.assigned_provider = its_contract.contract.company
intervention.save(update_fields=['contract', 'assigned_provider'])
# Fallback contrat par défaut pour les utilisateurs à visibilité limitée aux contrats
if not intervention.contract and user_config and user_config.limit_interventions_to_contracts:
default_contract = user_config.get_default_contract()
if default_contract:
intervention.contract = default_contract
if not intervention.assigned_provider:
intervention.assigned_provider = default_contract.company
intervention.save(update_fields=['contract', 'assigned_provider'])
# -------- Forcer le statut 'à approuver' pour les utilisateurs externes --------
# Applicable si limit_interventions_to_contracts est activé et que l'utilisateur
# n'a pas accès au contrat résolu (ou à celui du symptôme).
if user_config and not user_config.is_intern and user_config.limit_interventions_to_contracts:
_force_approved = False
_symptom = intervention.symptom
if _symptom:
if _symptom.contract_id:
# Cas 1 : le symptôme est explicitement lié à un contrat
# → l'utilisateur doit avoir accès à ce contrat pour éviter to_be_approved
_has_access = UserContractAccess.objects.filter(
user_config=user_config,
contract_id=_symptom.contract_id,
).exists()
if not _has_access:
_force_approved = True
elif _symptom.maintenance_type:
# Cas 2 : pas de contrat sur le symptôme mais maintenance_type défini
# → vérifier si le contrat résolu (depuis la localisation/asset) est accessible
if intervention.contract_id:
_has_access = UserContractAccess.objects.filter(
user_config=user_config,
contract_id=intervention.contract_id,
).exists()
if not _has_access:
_force_approved = True
else:
# Aucun contrat résolu : la localisation/asset n'est pas liée
# à un contrat de l'utilisateur → approbation requise
_force_approved = True
if _force_approved and intervention.status != 'to_be_approved':
intervention.status = 'to_be_approved'
intervention.save(update_fields=['status', 'status_order'])
# Mettre à jour l'entrée de timeline créée lors de la création
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 data['order_id'] == '':
intervention.order = None
else:
intervention.order = ContractOrder.objects.get(pk=data['order_id'])
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())
return JsonResponse({
'success': True,
'contract_id': intervention.order.contract.id if intervention.order else None,
'quote_items': quote_items,
'quote_total': quote_total,
'can_manage_posts': 'posts' in allowed_update_fields,
})
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)
for field, new_value in data.items():
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']).exists():
return JsonResponse({"error": "Permission denied"}, status=403)
if user_config.roles.filter(name__in=['external_manager']).exists():
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)
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.values("id", "contract_number")
return JsonResponse(list(contracts), 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.get(model=asset_type)
model_class = ct.model_class()
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.get(model=asset_type)
model_class = ct.model_class()
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)