2268 lines
99 KiB
Python
2268 lines
99 KiB
Python
from django.http import HttpResponse, JsonResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseRedirect
|
||
from django.shortcuts import render, get_object_or_404, redirect
|
||
from django.template.loader import render_to_string
|
||
from django.urls import reverse, NoReverseMatch
|
||
from django.core.serializers import serialize
|
||
from django.core.exceptions import FieldDoesNotExist
|
||
from django.core.files.base import ContentFile
|
||
from django.contrib.gis.db.models.functions import Transform, Centroid, AsGeoJSON, Distance
|
||
from django.contrib.gis.geos import GEOSGeometry, Point, MultiPolygon
|
||
from django.contrib.gis.measure import D
|
||
from django.contrib.contenttypes.models import ContentType
|
||
from django.contrib import messages
|
||
from django.views.decorators.http import require_POST, require_GET, require_http_methods
|
||
from django.contrib.auth.decorators import login_required
|
||
from django.utils.timezone import now, localtime, make_aware, get_current_timezone, is_naive
|
||
from django.utils import timezone as django_timezone
|
||
from django.utils import timezone
|
||
from django.utils.dateparse import parse_datetime
|
||
from django.utils.dateformat import format as dj_format_date
|
||
from django.utils.text import Truncator
|
||
from django.utils.html import strip_tags
|
||
from django.template.defaultfilters import linebreaksbr
|
||
from django.utils.translation import get_language, gettext as _, gettext_lazy as _lazy, ngettext
|
||
from django.apps import apps
|
||
from django.conf import settings
|
||
from django.core.mail import EmailMultiAlternatives
|
||
from django.db.models import F, DateTimeField, Q, Case, When, IntegerField, CharField, Value, Count, Prefetch, Sum, Max, QuerySet, OuterRef, Subquery, Exists
|
||
from django.db.models.functions import Coalesce, Lower, Cast
|
||
from django.db.models.expressions import ExpressionWrapper
|
||
from django.db.models import DurationField
|
||
from django.db.models.fields.related import ForeignKey
|
||
from django.db import transaction
|
||
|
||
from collections import defaultdict, OrderedDict
|
||
from decimal import Decimal, InvalidOperation
|
||
import json
|
||
import os
|
||
import io
|
||
import zipfile
|
||
from fpdf import FPDF
|
||
import pytz
|
||
from urllib.parse import urlencode
|
||
import re
|
||
from html import unescape
|
||
from typing import List, Iterable, Tuple, Dict, Optional, Set
|
||
import mimetypes
|
||
import csv
|
||
from io import BytesIO
|
||
|
||
from datetime import timedelta, datetime
|
||
from dateutil.relativedelta import relativedelta
|
||
|
||
from common.private_files.helpers import build_private_url
|
||
from common.utils import get_short_name
|
||
|
||
from interventions import views
|
||
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 import views
|
||
from interventions.models import STATUS_CHOICES, STATUS_ORDERS, MAINTAIN_CHOICES, TYPE_CHOICES, ORIGIN_TYPE_CHOICES, PAUSE_REASON_CHOICES
|
||
from interventions.views.forms import (InterventionForm, InterventionNoteForm, InterventionDocumentForm,
|
||
InterventionOccupationForm, PreventiveInterventionForm, AssetsLinkForm,
|
||
LinkExistingInterventionsForm)
|
||
from interventions.permissions import (intervention_permission_required, intervention_add_permission_required, intervention_edit_permission_required,
|
||
intervention_add_preventive_permission_required,
|
||
get_allowed_statuses_for_user, get_allowed_update_fields,
|
||
can_view_intervention, can_control_intervention, can_edit_intervention,
|
||
can_view_intervention_summary,
|
||
get_accessible_interventions_for_technician, filter_viewable_interventions_for_user,
|
||
can_edit_note, can_delete_document, can_manage_occupations,
|
||
can_add_intervention, can_add_repair_intervention,
|
||
intervention_manage_occupations_required,
|
||
get_contracts_for_intervention_creation, is_external_manager_with_contract_creation_rights,
|
||
should_show_created_by_provider_badge, can_view_correction_messages)
|
||
from interventions.permissions import ALLOWED_TRANSITIONS, get_allowed_transitions, user_allowed_transitions
|
||
from interventions.views.drafts import InterventionDraftManager
|
||
from interventions.utils import resolve_intervention_localisations
|
||
|
||
from common.models import UserConfig, UserContractAccess, UserContractStatusPermission, Thematic, UserThematics
|
||
from contracts.models import ContractOrder, ContractPost, Contract, Company, CompanyTeam, CompanyMember, UserCompanyTeamAccess, CompanyMemberHistory
|
||
from contracts.permissions import get_allowed_contracts_for_user, user_has_access_to_contract_order
|
||
from projects.models import Project, ProjectIntervention
|
||
from assets.models import (
|
||
PublicLightingStreet,
|
||
TrafficLightIntersection, TrafficLightContract,
|
||
NatureLocation,
|
||
StructureLocation, Structure, StructureContract,
|
||
RoadStreet,
|
||
SignStreet,
|
||
AssetCategory,
|
||
AbstractAsset,
|
||
AbstractAssetModel,
|
||
ITSGeoAsset, ITSGeoAssetContract,
|
||
CleanLocation, CleanLocationContract,
|
||
)
|
||
from assets.permissions import get_allowed_actions_for_asset
|
||
from assets.views.locations import LOCATION_REGISTRY, registry_items_for_thematics, base_search_qs, SEARCH_LIMIT_PER_TYPE, SEARCH_LIMIT_TOTAL
|
||
|
||
from common.utils import redirect_with_variant, measure_time, strip_accents_lower
|
||
from observations.permissions import get_observation_access_context
|
||
from observations.models import Observation as ObservationModel
|
||
|
||
from controls.permissions import user_can_view_control
|
||
|
||
from interventions.views.views_pdf_reporting import (
|
||
generate_intervention_pdf,
|
||
preview_intervention_pdf,
|
||
download_intervention_report_pdf,
|
||
preview_intervention_report_pdf,
|
||
bulk_execution_report_pdf,
|
||
start_bulk_execution_report,
|
||
poll_bulk_execution_report,
|
||
download_bulk_execution_report,
|
||
)
|
||
|
||
import logging
|
||
import smtplib
|
||
import socket
|
||
import ssl
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
from interventions.views.helpers import *
|
||
|
||
|
||
|
||
def interventions_index_mobile(request):
|
||
# Redirect if no filter parameters are provided in GET (and it's a standard, non-AJAX request)
|
||
if not request.GET and request.headers.get('x-requested-with') != 'XMLHttpRequest':
|
||
query_params = request.GET.copy()
|
||
|
||
# Load user config to get saved filters
|
||
user_config = None
|
||
try:
|
||
user_config = UserConfig.objects.get(user=request.user)
|
||
except UserConfig.DoesNotExist:
|
||
pass
|
||
|
||
saved_filters = user_config.mobile_intervention_list_filters if user_config else {}
|
||
|
||
if saved_filters:
|
||
for key, value in saved_filters.items():
|
||
if isinstance(value, list):
|
||
query_params.setlist(key, value)
|
||
elif value is not None:
|
||
query_params[key] = value
|
||
else:
|
||
# Default behavior if no preferences saved yet
|
||
# 1. default statuses
|
||
allowed_statuses = get_allowed_statuses_for_user(request.user)
|
||
allowed_statuses = [status for status in allowed_statuses if status in STATUS_CHOICES]
|
||
default_statuses = [
|
||
status for status in ('to_be_processed', 'assigned', 'in_progress', 'on_pause')
|
||
if status in allowed_statuses
|
||
]
|
||
if not default_statuses:
|
||
default_statuses = allowed_statuses
|
||
query_params.setlist('status', default_statuses)
|
||
|
||
return HttpResponseRedirect(f"{request.path}?{query_params.urlencode()}")
|
||
|
||
# permissions
|
||
interventions = filter_viewable_interventions_for_user(request.user, limit_to_default_thematic=True)
|
||
|
||
# Get user config and company member for maintain type tabs logic
|
||
user_config = None
|
||
company_member = None
|
||
try:
|
||
user_config = UserConfig.objects.get(user=request.user)
|
||
except UserConfig.DoesNotExist:
|
||
pass
|
||
|
||
if user_config and not user_config.is_intern:
|
||
company_member = CompanyMember.objects.prefetch_related('teams__company', 'visible_maintain_types').filter(user=request.user).first()
|
||
|
||
# Save the current filters to user config
|
||
if user_config and request.GET:
|
||
filters_to_save = {}
|
||
for key in request.GET:
|
||
values = request.GET.getlist(key)
|
||
if key == 'status':
|
||
filters_to_save[key] = values
|
||
else:
|
||
filters_to_save[key] = values[0] if values else ''
|
||
|
||
if filters_to_save and user_config.mobile_intervention_list_filters != filters_to_save:
|
||
user_config.mobile_intervention_list_filters = filters_to_save
|
||
user_config.save(update_fields=['mobile_intervention_list_filters'])
|
||
|
||
# Check if user can add repair interventions for structures thematic (technicians)
|
||
can_add_structures_repair = False
|
||
# Check if user (internal admin/manager/controller) can add interventions across all thematics
|
||
can_add_intervention_mobile = False
|
||
if user_config:
|
||
default_thematic = user_config.default_thematic
|
||
if default_thematic and default_thematic.code == 'structures':
|
||
# Check if user is a technician with edit permissions for structures
|
||
is_technician = user_config.roles.filter(name='technician').exists()
|
||
if is_technician:
|
||
can_add_structures_repair = UserThematics.objects.filter(
|
||
user_config=user_config,
|
||
thematic=default_thematic,
|
||
can_edit_interventions=True
|
||
).exists()
|
||
# Utilisateur interne avec rôle admin, manager ou controller (toutes thématiques)
|
||
if user_config.is_intern and user_config.roles.filter(name__in=['admin', 'manager', 'controller']).exists():
|
||
can_add_intervention_mobile = True
|
||
|
||
allowed_statuses = get_allowed_statuses_for_user(request.user)
|
||
allowed_statuses = [status for status in allowed_statuses if status in STATUS_CHOICES]
|
||
allowed_statuses = sorted(set(allowed_statuses), key=lambda status: STATUS_ORDERS.get(status, 999))
|
||
|
||
selected_statuses = []
|
||
|
||
# Save base interventions for counting BEFORE any filtering
|
||
base_interventions_for_counts = interventions
|
||
|
||
if not allowed_statuses:
|
||
interventions = interventions.none()
|
||
else:
|
||
default_statuses = [
|
||
status for status in ('to_be_processed', 'assigned', 'in_progress', 'on_pause')
|
||
if status in allowed_statuses
|
||
]
|
||
if not default_statuses:
|
||
default_statuses = allowed_statuses
|
||
|
||
selected_statuses = [
|
||
status for status in request.GET.getlist('status')
|
||
if status in allowed_statuses
|
||
]
|
||
if not selected_statuses:
|
||
selected_statuses = default_statuses
|
||
|
||
interventions = interventions.filter(status__in=selected_statuses)
|
||
|
||
# Apply same status filter to base_interventions_for_counts
|
||
if selected_statuses:
|
||
base_interventions_for_counts = base_interventions_for_counts.filter(status__in=selected_statuses)
|
||
|
||
# Find user's teams to filter team-assigned interventions
|
||
user_teams = CompanyTeam.objects.filter(members__user=request.user)
|
||
user_team_ids = list(user_teams.values_list('id', flat=True))
|
||
|
||
show_mine_only = request.GET.get('mine') == '1'
|
||
|
||
if show_mine_only:
|
||
interventions = interventions.filter(
|
||
Q(assigned_member__user=request.user) | Q(assigned_team_id__in=user_team_ids)
|
||
)
|
||
base_interventions_for_counts = base_interventions_for_counts.filter(
|
||
Q(assigned_member__user=request.user) | Q(assigned_team_id__in=user_team_ids)
|
||
)
|
||
|
||
q = request.GET.get('q')
|
||
if q:
|
||
interventions = interventions.filter(Q(code__icontains=q) | Q(title__icontains=q))
|
||
base_interventions_for_counts = base_interventions_for_counts.filter(Q(code__icontains=q) | Q(title__icontains=q))
|
||
|
||
# Needed by mobile badge rendering (stock icon next to status).
|
||
interventions = annotate_preparation_status(interventions)
|
||
|
||
# --- Tri personnalisé ---
|
||
sort_field = request.GET.get("sort")
|
||
|
||
prioritized_begin_expr = Coalesce(
|
||
'begin_time',
|
||
'planned_begin_time',
|
||
'expected_begin_time',
|
||
'creation_time',
|
||
output_field=DateTimeField(),
|
||
)
|
||
|
||
order_mapping = {
|
||
"number_asc": ("code",),
|
||
"number_desc": ("-code",),
|
||
"title_asc": ("title",),
|
||
"title_desc": ("-title",),
|
||
"date_asc": ("list_begin_time", "id"),
|
||
"date_desc": ("-list_begin_time", "-id"),
|
||
}
|
||
|
||
|
||
# Annotation pour priorité d'affichage
|
||
interventions = interventions.annotate(
|
||
custom_priority=Case(
|
||
When(status='to_be_processed', maintain_type='corrective', then=0),
|
||
When(status='assigned', maintain_type='corrective', then=1),
|
||
When(status='in_progress', maintain_type='corrective', then=2),
|
||
When(status='on_pause', maintain_type='corrective', then=3),
|
||
When(status='to_be_processed', then=4),
|
||
When(status='assigned', then=5),
|
||
When(status='in_progress', then=6),
|
||
When(status='on_pause', then=7),
|
||
When(status='finished', then=8),
|
||
default=9,
|
||
output_field=IntegerField()
|
||
),
|
||
mine_priority=Case(
|
||
When(assigned_member__user=request.user, then=Value(0)),
|
||
When(assigned_team_id__in=user_team_ids, then=Value(1)),
|
||
default=Value(2),
|
||
output_field=IntegerField()
|
||
),
|
||
list_begin_time=prioritized_begin_expr,
|
||
)
|
||
|
||
if sort_field:
|
||
order_by_clause = order_mapping.get(sort_field, ("-creation_time",))
|
||
interventions = interventions.order_by(*order_by_clause)
|
||
else:
|
||
default_order = ('mine_priority', 'custom_priority', '-list_begin_time', '-id')
|
||
interventions = interventions.order_by(*default_order)
|
||
|
||
# --- Maintain type tabs logic ---
|
||
# Determine if we should show maintain type tabs and which types are allowed
|
||
show_maintain_type_tabs = False
|
||
visible_maintain_types = None # None means all visible
|
||
allowed_maintain_types = ['corrective', 'preventive', 'ameliorative'] # Default for internal users
|
||
|
||
if user_config:
|
||
if user_config.is_intern:
|
||
# Internal users always see tabs
|
||
show_maintain_type_tabs = True
|
||
elif company_member:
|
||
# External users: check if they have multiple visible maintain types
|
||
visible_maintain_types = company_member.get_visible_maintain_type_codes()
|
||
# If None (all visible) or more than 1 type, show tabs
|
||
if visible_maintain_types is None:
|
||
# All types visible
|
||
show_maintain_type_tabs = True
|
||
elif len(visible_maintain_types) > 1:
|
||
# Filter to only show allowed types
|
||
allowed_maintain_types = [t for t in allowed_maintain_types if t in visible_maintain_types]
|
||
show_maintain_type_tabs = True
|
||
|
||
# Calculate counts per maintain_type FIRST (before any maintain_type filtering)
|
||
maintain_type_counts = {'corrective': 0, 'preventive': 0, 'ameliorative': 0}
|
||
|
||
if show_maintain_type_tabs:
|
||
# Count from the base queryset (before maintain_type filter but with status/mine/search filters)
|
||
counts = base_interventions_for_counts.values('maintain_type').annotate(count=Count('id'))
|
||
for item in counts:
|
||
if item['maintain_type'] in maintain_type_counts:
|
||
maintain_type_counts[item['maintain_type']] = item['count']
|
||
|
||
# NOW determine the selected maintain_type (after counting)
|
||
selected_maintain_type = request.GET.get('maintain_type', '')
|
||
if show_maintain_type_tabs and not selected_maintain_type:
|
||
# Set corrective as default
|
||
selected_maintain_type = 'corrective' if 'corrective' in allowed_maintain_types else allowed_maintain_types[0]
|
||
|
||
# Apply maintain_type filter if selected
|
||
if selected_maintain_type and selected_maintain_type in ('corrective', 'preventive', 'ameliorative'):
|
||
interventions = interventions.filter(maintain_type=selected_maintain_type)
|
||
|
||
# Apply limit (slice) at the very end, after all filtering
|
||
interventions = interventions[:100]
|
||
|
||
# Evaluate queryset once to avoid double DB round-trip
|
||
interventions_list = list(interventions)
|
||
|
||
# Build marker data for the map (only interventions with coordinates)
|
||
markers_data = [
|
||
{
|
||
'id': i.id,
|
||
'code': i.code,
|
||
'title': i.title,
|
||
'status_label': i.get_status_display(),
|
||
'status': i.status,
|
||
'status_color': i.get_status_color(),
|
||
'maintain_type': i.maintain_type,
|
||
'lat': i.lat,
|
||
'lon': i.lon,
|
||
'url': reverse('mobile:intervention_detail_mobile', args=[i.id]),
|
||
}
|
||
for i in interventions_list
|
||
if i.lat and i.lon
|
||
]
|
||
|
||
if request.headers.get('x-requested-with') == 'XMLHttpRequest':
|
||
html = render_to_string(
|
||
'interventions/_interventions_list_mobile.html',
|
||
{'interventions': interventions_list},
|
||
request=request,
|
||
)
|
||
return JsonResponse({'html': html, 'maintain_type_counts': maintain_type_counts, 'markers': markers_data})
|
||
|
||
return render(
|
||
request,
|
||
'interventions/interventions_index_mobile.html',
|
||
{
|
||
'interventions': interventions_list,
|
||
'show_mine_only': show_mine_only,
|
||
'status_filters': [
|
||
{
|
||
'key': status,
|
||
'label': STATUS_CHOICES.get(status, status),
|
||
'checked': status in selected_statuses,
|
||
}
|
||
for status in allowed_statuses
|
||
],
|
||
'selected_statuses': selected_statuses,
|
||
'current_sort': sort_field,
|
||
'show_maintain_type_tabs': show_maintain_type_tabs,
|
||
'maintain_type_counts': maintain_type_counts,
|
||
'selected_maintain_type': selected_maintain_type,
|
||
'allowed_maintain_types': allowed_maintain_types,
|
||
'can_add_structures_repair': can_add_structures_repair,
|
||
'can_add_intervention_mobile': can_add_intervention_mobile,
|
||
'markers_json': json.dumps(markers_data, ensure_ascii=False),
|
||
}
|
||
)
|
||
|
||
|
||
def intervention_detail_mobile(request, intervention_id):
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
intervention.prep_state = intervention.get_preparation_orders_status()
|
||
|
||
user_config = (UserConfig.objects
|
||
.select_related('user')
|
||
.prefetch_related('roles')
|
||
.filter(user=request.user).first())
|
||
if not user_config:
|
||
return HttpResponseForbidden(_("You do not have access to this page."))
|
||
|
||
# Gestion de l'historique de navigation (chaîné avec ">")
|
||
# Format: "list>intervention:123>intervention:456" = Liste -> Intervention 123 -> Intervention 456
|
||
back_history = request.GET.get('back') or request.POST.get('back', '')
|
||
|
||
if not back_history:
|
||
# Essayer de détecter depuis le referer
|
||
referer = request.META.get('HTTP_REFERER', '')
|
||
if referer:
|
||
from urllib.parse import urlparse
|
||
parsed = urlparse(referer)
|
||
path = parsed.path
|
||
# Détecter le type de page d'origine
|
||
if '/mobile/interventions/' in path and f'/interventions/{intervention_id}' not in path:
|
||
# Vient d'une autre intervention mobile
|
||
import re
|
||
match = re.search(r'/interventions/(\d+)/?', path)
|
||
if match:
|
||
back_history = f'intervention:{match.group(1)}'
|
||
elif '/projects/' in path:
|
||
import re
|
||
match = re.search(r'/projects/(\d+)/?', path)
|
||
if match:
|
||
back_history = f'project:{match.group(1)}'
|
||
elif '/controls/' in path:
|
||
import re
|
||
match = re.search(r'/controls/(\d+)/?', path)
|
||
if match:
|
||
back_history = f'control:{match.group(1)}'
|
||
elif '/observations/' in path:
|
||
import re
|
||
match = re.search(r'/observations/(\d+)/?', path)
|
||
if match:
|
||
back_history = f'observation:{match.group(1)}'
|
||
|
||
# Si aucun historique n'est détecté, on considère qu'on vient de la liste par défaut
|
||
if not back_history:
|
||
back_history = 'list'
|
||
|
||
if request.method == "POST":
|
||
# 1. Enregistrement de la note
|
||
note_content = request.POST.get("content", "").strip()
|
||
note_id = request.POST.get("note_id", "").strip()
|
||
|
||
if note_content:
|
||
note_form = InterventionNoteForm(request.POST)
|
||
if note_form.is_valid():
|
||
cleaned_data = note_form.cleaned_data
|
||
note = None
|
||
|
||
# Check if we're updating an existing note (created by auto-save)
|
||
if note_id:
|
||
try:
|
||
note = InterventionNote.objects.get(pk=note_id, intervention=intervention)
|
||
# Only allow updating if user is the author
|
||
if note.note_author == request.user:
|
||
note.content = cleaned_data['content']
|
||
note.note_type = cleaned_data['note_type']
|
||
note.updated_by = request.user
|
||
note.save()
|
||
else:
|
||
messages.error(request, _("Vous n'êtes pas autorisé à modifier cette note"))
|
||
note = None
|
||
except InterventionNote.DoesNotExist:
|
||
# Note doesn't exist, create a new one
|
||
note = None
|
||
|
||
# Create new note if no existing note was found/updated
|
||
if not note:
|
||
InterventionNote.objects.create(
|
||
intervention=intervention,
|
||
content=cleaned_data['content'],
|
||
note_type=cleaned_data['note_type'],
|
||
note_author=request.user,
|
||
note_time=now()
|
||
)
|
||
else:
|
||
messages.error(request, _("Erreur lors de l'enregistrement de la note."))
|
||
|
||
# 2. Upload de documents (photos)
|
||
for file in request.FILES.getlist("photos"):
|
||
InterventionDocument.objects.create(
|
||
intervention=intervention,
|
||
file=file,
|
||
uploaded_by=request.user
|
||
)
|
||
|
||
# 3. Traitement des opérations
|
||
for operation in intervention.operations.all():
|
||
measure_key = f"operation_measure_{operation.id}"
|
||
duration_key = f"operation_duration_{operation.id}"
|
||
datetime_key = f"operation_datetime_{operation.id}"
|
||
status_key = f"operation_status_{operation.id}"
|
||
na_key = f"operation_na_{operation.id}"
|
||
|
||
# Check if operation is marked as "Non applicable"
|
||
is_na = na_key in request.POST and request.POST.get(na_key) == '1'
|
||
|
||
if is_na:
|
||
# Mark as not applicable and clear any value/status
|
||
operation.is_not_applicable = True
|
||
operation.measured_value = None
|
||
operation.measured_duration = None
|
||
operation.measured_datetime = None
|
||
operation.status = None
|
||
operation.save()
|
||
elif measure_key in request.POST:
|
||
try:
|
||
value = request.POST.get(measure_key)
|
||
if value:
|
||
operation.measured_value = Decimal(value)
|
||
operation.measured_duration = None
|
||
operation.measured_datetime = None
|
||
operation.status = None
|
||
operation.is_not_applicable = False
|
||
operation.save()
|
||
elif operation.is_not_applicable:
|
||
# If previously N/A but now unchecked with no value, reset N/A flag
|
||
operation.is_not_applicable = False
|
||
operation.save()
|
||
except Exception:
|
||
pass # à remplacer par log si nécessaire
|
||
|
||
elif duration_key in request.POST:
|
||
try:
|
||
value = request.POST.get(duration_key)
|
||
if value:
|
||
parts = str(value).split(':')
|
||
if len(parts) == 3:
|
||
from datetime import timedelta as _td
|
||
h, m, s = int(parts[0]), int(parts[1]), int(parts[2])
|
||
operation.measured_duration = _td(hours=h, minutes=m, seconds=s)
|
||
operation.measured_value = None
|
||
operation.measured_datetime = None
|
||
operation.status = None
|
||
operation.is_not_applicable = False
|
||
operation.save()
|
||
elif operation.is_not_applicable:
|
||
operation.is_not_applicable = False
|
||
operation.save()
|
||
except Exception:
|
||
pass
|
||
|
||
elif datetime_key in request.POST:
|
||
try:
|
||
value = request.POST.get(datetime_key)
|
||
if value:
|
||
dt = parse_datetime(value)
|
||
if dt is None:
|
||
dt = datetime.fromisoformat(value)
|
||
if dt and is_naive(dt):
|
||
dt = make_aware(dt, get_current_timezone())
|
||
if dt:
|
||
operation.measured_datetime = dt
|
||
operation.measured_value = None
|
||
operation.measured_duration = None
|
||
operation.status = None
|
||
operation.is_not_applicable = False
|
||
operation.save()
|
||
elif operation.is_not_applicable:
|
||
operation.is_not_applicable = False
|
||
operation.save()
|
||
except Exception:
|
||
pass
|
||
|
||
elif status_key in request.POST:
|
||
try:
|
||
status_id = request.POST.get(status_key)
|
||
if status_id:
|
||
status = OperationStatus.objects.get(pk=status_id)
|
||
operation.status = status
|
||
operation.measured_value = None
|
||
operation.is_not_applicable = False
|
||
operation.save()
|
||
elif operation.is_not_applicable:
|
||
# If previously N/A but now unchecked with no status, reset N/A flag
|
||
operation.is_not_applicable = False
|
||
operation.save()
|
||
except OperationStatus.DoesNotExist:
|
||
pass
|
||
else:
|
||
# Check if N/A was unchecked (no na_key in POST means unchecked)
|
||
if operation.is_not_applicable:
|
||
operation.is_not_applicable = False
|
||
operation.save()
|
||
|
||
# 4. Statut et actions réelles
|
||
status_action = request.POST.get("status_action")
|
||
realization_map = {
|
||
"take_over": "real_other",
|
||
"release": "real_other",
|
||
"start": "real_begin",
|
||
"pause": "real_pause",
|
||
"finish": "real_end"
|
||
}
|
||
status_map = {
|
||
"take_over": "assigned",
|
||
"release": "to_be_processed",
|
||
"start": "in_progress",
|
||
"pause": "on_pause",
|
||
"finish": "finished"
|
||
}
|
||
|
||
if status_action in status_map:
|
||
new_status = status_map[status_action]
|
||
old_status = intervention.status
|
||
pause_reason_key = request.POST.get("pause_reason") if status_action == "pause" else None
|
||
pause_reason_other = (request.POST.get("pause_reason_other") or "").strip() if status_action == "pause" else ""
|
||
|
||
success, message, _email_failed = views.attempt_status_update(
|
||
request,
|
||
intervention,
|
||
request.user,
|
||
new_status,
|
||
pause_reason=pause_reason_key,
|
||
pause_reason_other=pause_reason_other,
|
||
)
|
||
|
||
if not success:
|
||
messages.error(request, message)
|
||
else:
|
||
# Rafraîchir l'intervention après la mise à jour (begin_time/end_time/status peuvent avoir changé)
|
||
intervention.refresh_from_db()
|
||
realization_event_time = now()
|
||
|
||
realization_type = realization_map.get(status_action)
|
||
if realization_type:
|
||
if status_action == "take_over":
|
||
realization_description = _("Prise en charge de l'intervention")
|
||
elif status_action == "release":
|
||
realization_description = _("Libération de l'intervention")
|
||
elif status_action == "pause":
|
||
pause_description = format_pause_description(pause_reason_key or "", pause_reason_other)
|
||
realization_description = pause_description or _("Mise en pause de l'intervention")
|
||
elif status_action == "start" and old_status == 'on_pause':
|
||
realization_description = _("Reprise de l'intervention")
|
||
elif status_action == "start":
|
||
realization_description = _("Démarrage de l'intervention")
|
||
elif status_action == "finish":
|
||
realization_description = _("Fin de l'intervention")
|
||
else:
|
||
realization_description = _("Action '%(action)s' depuis l'interface mobile") % {"action": status_action}
|
||
InterventionRealizationTimeLine.objects.create(
|
||
intervention=intervention,
|
||
event_user=request.user,
|
||
event_time=realization_event_time,
|
||
event_type=realization_type,
|
||
event_description=realization_description,
|
||
)
|
||
|
||
if status_action in ("release", "finish"):
|
||
return redirect("mobile:interventions_index_mobile")
|
||
|
||
return redirect("mobile:intervention_detail_mobile", intervention_id=intervention.id)
|
||
|
||
assets = list(
|
||
intervention.interventionasset_set.select_related('content_type').all()
|
||
)
|
||
assets.sort(
|
||
key=lambda link: (link.asset.get_name() if link.asset and hasattr(link.asset, 'get_name') else link.asset.code if link.asset else '').lower()
|
||
)
|
||
|
||
# Enrich assets with model name for template access (no underscores allowed in templates)
|
||
for link in assets:
|
||
link.asset_model_name = link.content_type.model if link.content_type else None
|
||
|
||
mobile_asset_replacement_data: Dict[str, Dict[str, object]] = {}
|
||
# Get categories for the intervention's thematic only
|
||
thematic_categories = list(
|
||
AssetCategory.objects.filter(thematic_id=intervention.thematic_id).order_by('name_fr')
|
||
)
|
||
categories_list = [{
|
||
"id": cat.id,
|
||
"name": cat.get_name()
|
||
} for cat in thematic_categories]
|
||
|
||
if can_edit_intervention(request.user, intervention, user_config=user_config):
|
||
for link in assets:
|
||
asset_obj = getattr(link, 'asset', None)
|
||
allowed_asset_actions = get_allowed_actions_for_asset(request.user, asset_obj) if asset_obj else []
|
||
link.can_replace_asset = 'replace' in allowed_asset_actions
|
||
if not (link.can_replace_asset and asset_obj and link.content_type_id and link.object_id):
|
||
continue
|
||
|
||
key = f"{link.content_type_id}:{link.object_id}"
|
||
# Get the category of the current asset
|
||
asset_category_id = getattr(asset_obj, "category_id", None)
|
||
asset_category_name = asset_obj.category.get_name() if hasattr(asset_obj, 'category') and asset_obj.category else ""
|
||
|
||
mobile_asset_replacement_data[key] = {
|
||
"asset_model": link.content_type.model if link.content_type else "",
|
||
"asset_id": link.object_id,
|
||
"default_model_id": getattr(asset_obj, "model_id", None),
|
||
"default_category_id": asset_category_id,
|
||
"default_category_name": asset_category_name,
|
||
"models": _build_replacement_model_options(asset_obj, asset_category_id),
|
||
"categories": categories_list,
|
||
"serial_number": getattr(asset_obj, "serial_number", "") or "",
|
||
"brand": getattr(asset_obj, "brand", "") or "",
|
||
"label": str(asset_obj) if asset_obj else "",
|
||
}
|
||
else:
|
||
for link in assets:
|
||
link.can_replace_asset = False
|
||
|
||
operations = list(
|
||
intervention.operations.all()
|
||
.select_related('status', 'measurement_definition', 'asset_content_type')
|
||
.prefetch_related('photos')
|
||
.order_by('asset_content_type_id', 'asset_object_id', 'template_group', 'order')
|
||
)
|
||
operations_grouped = _group_operations_by_asset_and_template(operations, 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()
|
||
)
|
||
|
||
linked_groups = _build_link_groups(intervention)
|
||
|
||
locations_qs = list(
|
||
InterventionLocation.objects
|
||
.filter(intervention=intervention)
|
||
.select_related('content_type')
|
||
)
|
||
|
||
|
||
one_hour_ago = now() - timedelta(hours=1)
|
||
is_admin = user_config.roles.filter(name='admin').exists() if user_config else False
|
||
|
||
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')
|
||
.filter(intervention=intervention)
|
||
.annotate(type_priority_order=note_priority_case)
|
||
.order_by('type_priority_order', 'note_time')
|
||
)
|
||
if is_admin:
|
||
editable_note_ids = set(notes.values_list('id', flat=True))
|
||
else:
|
||
editable_note_ids = set(
|
||
notes.filter(note_author=request.user, note_time__gte=one_hour_ago)
|
||
.values_list('id', flat=True)
|
||
)
|
||
|
||
|
||
documents_qs = intervention.documents.all()
|
||
if is_admin:
|
||
deletable_document_ids = list(documents_qs.values_list('id', flat=True))
|
||
else:
|
||
deletable_document_ids = list(
|
||
documents_qs.filter(uploaded_by=request.user, upload_date__gte=one_hour_ago)
|
||
.values_list('id', flat=True)
|
||
)
|
||
|
||
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)
|
||
|
||
allowed_update_fields = get_allowed_update_fields(request.user, intervention)
|
||
can_edit_notes = 'notes' in allowed_update_fields
|
||
can_edit_mobile = can_edit_intervention(request.user, intervention, user_config)
|
||
|
||
# Vérifier l'accès au contrat pour la prise en charge / démarrage
|
||
contract = intervention.contract
|
||
user_contract_access = None
|
||
if contract:
|
||
user_contract_access = UserContractAccess.objects.filter(user_config=user_config, contract=contract).first()
|
||
can_take_over = bool(contract and user_contract_access)
|
||
|
||
is_restricted_team = False
|
||
company_member = CompanyMember.objects.filter(user=request.user).first()
|
||
if company_member:
|
||
user_roles = set(user_config.roles.values_list('name', flat=True)) if user_config else set()
|
||
privileged_roles = {'admin', 'top_manager', 'manager', 'controller', 'operator', 'external_manager'}
|
||
if not (user_roles & privileged_roles):
|
||
matching_teams = company_member.teams.filter(company=intervention.assigned_provider)
|
||
if matching_teams.exists() and not matching_teams.filter(restrict_to_assigned_interventions=False).exists():
|
||
is_restricted_team = True
|
||
|
||
# --- Timeline mobile : transitions, statuts, rôles ---
|
||
allowed_transitions = get_allowed_transitions(request, intervention)
|
||
user_roles = list(user_config.roles.values_list('name', flat=True)) if user_config else []
|
||
is_technician = 'technician' in user_roles
|
||
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'
|
||
|
||
mobile_assets_config = None
|
||
mobile_asset_location_labels: List[str] = []
|
||
if can_edit_mobile:
|
||
location_payload = []
|
||
for link in locations_qs:
|
||
label = _format_location_label(getattr(link, 'location', None))
|
||
if not label:
|
||
continue
|
||
location_payload.append({
|
||
"id": link.id,
|
||
"label": label,
|
||
"content_type_id": link.content_type_id,
|
||
"object_id": link.object_id,
|
||
})
|
||
mobile_asset_location_labels = [entry['label'] for entry in location_payload]
|
||
|
||
existing_pairs_payload = []
|
||
for link in assets:
|
||
if not link.content_type_id or not link.object_id:
|
||
continue
|
||
asset_obj = getattr(link, 'asset', None)
|
||
display_label = str(asset_obj) if asset_obj else ''
|
||
existing_pairs_payload.append({
|
||
"content_type_id": link.content_type_id,
|
||
"object_id": link.object_id,
|
||
"model": link.content_type.model if link.content_type else '',
|
||
"display_label": display_label,
|
||
"code": getattr(asset_obj, 'code', None),
|
||
})
|
||
|
||
mobile_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_payload,
|
||
"locations": location_payload,
|
||
"hasLocations": bool(location_payload),
|
||
}
|
||
|
||
can_create_repair = (
|
||
intervention.maintain_type != 'corrective' and
|
||
can_add_repair_intervention(request.user, intervention)
|
||
)
|
||
|
||
# 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)
|
||
]
|
||
|
||
# Historique des interventions liées aux mêmes localisations (3 derniers mois)
|
||
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,
|
||
})
|
||
|
||
# Construire les informations de navigation pour le bouton retour
|
||
# L'historique est chaîné avec ">" : "list>intervention:123>intervention:456"
|
||
back_url = reverse('mobile:interventions_index_mobile') # Par défaut : retour à la liste
|
||
back_label = _("Retour à la liste")
|
||
back_icon = "bi-list-ul"
|
||
parent_history = "" # Historique à passer au parent lors du retour
|
||
|
||
if back_history:
|
||
# Séparer l'historique en éléments
|
||
history_parts = back_history.split('>')
|
||
# Le dernier élément est la destination de retour immédiate
|
||
current_back = history_parts[-1] if history_parts else ''
|
||
# Le reste est l'historique à passer au parent
|
||
parent_history = '>'.join(history_parts[:-1]) if len(history_parts) > 1 else ''
|
||
|
||
if current_back == 'list':
|
||
pass # Valeurs par défaut (retour à la liste)
|
||
elif current_back.startswith('intervention:'):
|
||
try:
|
||
back_id = int(current_back.split(':')[1])
|
||
# Construire l'URL avec l'historique parent
|
||
back_url = reverse('mobile:intervention_detail_mobile', args=[back_id])
|
||
if parent_history:
|
||
back_url += f'?back={parent_history}'
|
||
back_label = _("Retour à l'intervention")
|
||
back_icon = "bi-arrow-return-left"
|
||
except (ValueError, IndexError):
|
||
pass
|
||
elif current_back.startswith('project:'):
|
||
try:
|
||
back_id = int(current_back.split(':')[1])
|
||
back_url = reverse('mobile:projects_detail', args=[back_id])
|
||
if parent_history:
|
||
back_url += f'?back={parent_history}'
|
||
back_label = _("Retour au projet")
|
||
back_icon = "bi-folder"
|
||
except (ValueError, IndexError):
|
||
pass
|
||
elif current_back.startswith('control:'):
|
||
try:
|
||
back_id = int(current_back.split(':')[1])
|
||
back_url = reverse('mobile:controls_detail', args=[back_id])
|
||
if parent_history:
|
||
back_url += f'?back={parent_history}'
|
||
back_label = _("Retour au contrôle")
|
||
back_icon = "bi-clipboard-check"
|
||
except (ValueError, IndexError):
|
||
pass
|
||
elif current_back.startswith('observation:'):
|
||
try:
|
||
back_id = int(current_back.split(':')[1])
|
||
back_url = reverse('mobile:observation_detail_mobile', args=[back_id])
|
||
if parent_history:
|
||
back_url += f'?back={parent_history}'
|
||
back_label = _("Retour à l'observation")
|
||
back_icon = "bi-eye"
|
||
except (ValueError, IndexError):
|
||
pass
|
||
|
||
# Historique à passer aux liens enfants (ajoute l'intervention actuelle)
|
||
child_history = f'{back_history}>intervention:{intervention_id}' if back_history else f'intervention:{intervention_id}'
|
||
# Limiter la profondeur de l'historique à 5 niveaux pour éviter des URLs trop longues
|
||
child_history_parts = child_history.split('>')
|
||
if len(child_history_parts) > 5:
|
||
child_history = '>'.join(child_history_parts[-5:])
|
||
|
||
# Catégories pour la modale d'ajout d'équipement mobile
|
||
mobile_asset_categories = []
|
||
if can_edit_mobile and intervention.thematic:
|
||
mobile_asset_categories = list(
|
||
AssetCategory.objects.filter(thematic=intervention.thematic)
|
||
.order_by('name_fr' if get_language() != 'nl' else 'name_nl')
|
||
)
|
||
|
||
# IDs des carrefours feux liés à l'intervention (pour la carte mobile)
|
||
tl_intersection_ids = []
|
||
if intervention.thematic and intervention.thematic.code == 'trafficlights':
|
||
tl_intersection_ct = ContentType.objects.get_for_model(TrafficLightIntersection)
|
||
tl_intersection_ids = list(
|
||
InterventionLocation.objects
|
||
.filter(intervention=intervention, content_type=tl_intersection_ct)
|
||
.values_list('object_id', flat=True)
|
||
)
|
||
|
||
# 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)]
|
||
|
||
# Discussion liée à cette intervention (mobile)
|
||
discussion_messages_mobile = []
|
||
can_post_discussion_message_mobile = False
|
||
try:
|
||
from django.contrib.contenttypes.models import ContentType as _CT
|
||
from notifications.models import Discussion as _Discussion
|
||
from notifications.services import mark_discussion_seen as _mark_seen, can_post_intervention_message as _can_post
|
||
_ct = _CT.objects.get_for_model(intervention)
|
||
_discussion = _Discussion.objects.filter(content_type=_ct, object_id=intervention.pk).first()
|
||
if _discussion:
|
||
discussion_messages_mobile = list(
|
||
_discussion.messages.filter(is_deleted=False)
|
||
.select_related('author')
|
||
.prefetch_related('attachments')
|
||
.order_by('created_at')
|
||
)
|
||
_mark_seen(_discussion, user=request.user)
|
||
can_post_discussion_message_mobile = _can_post(request.user, intervention)
|
||
except Exception:
|
||
pass
|
||
|
||
context = {
|
||
'intervention': intervention,
|
||
'assets': assets,
|
||
'notes': notes,
|
||
'can_edit_intervention': can_edit_mobile,
|
||
'allowed_update_fields': allowed_update_fields,
|
||
'deletable_document_ids': deletable_document_ids,
|
||
'editable_note_ids': editable_note_ids,
|
||
'add_note_form': InterventionNoteForm(),
|
||
'can_edit_notes': can_edit_notes,
|
||
'can_edit_operation_comments': 'operations' in allowed_update_fields,
|
||
'note_type_choices': add_note_type_choices,
|
||
'default_note_type': InterventionNote.NoteType.WORK_DONE,
|
||
'occupations': occupations,
|
||
'show_occupations': show_occupations,
|
||
'occupations_required': occupations_required,
|
||
'total_occupation_minutes': total_occupation_minutes,
|
||
'can_manage_occupations': can_manage_occupation_entries,
|
||
'occupation_members': occupation_members,
|
||
'occupation_form': occupation_form,
|
||
'hide_map_by_default': user_config.hide_intervention_map_by_default,
|
||
'pause_reason_choices': PAUSE_REASON_CHOICES,
|
||
'linked_groups': linked_groups,
|
||
'can_create_repair': can_create_repair,
|
||
'mobile_assets_enabled': can_edit_mobile,
|
||
'mobile_assets_config': mobile_assets_config,
|
||
'mobile_asset_location_labels': mobile_asset_location_labels,
|
||
'mobile_assets_max_results': MAX_MOBILE_ASSET_RESULTS,
|
||
'mobile_asset_categories': mobile_asset_categories,
|
||
'mobile_asset_replacement_data': mobile_asset_replacement_data,
|
||
'operations_general_group': general_operations_group,
|
||
'operations_asset_groups': asset_operations_groups,
|
||
'location_history_interventions': location_history_interventions,
|
||
'back_url': back_url,
|
||
'back_label': back_label,
|
||
'back_icon': back_icon,
|
||
'back_history': back_history,
|
||
'child_history': child_history,
|
||
'operation_status_sets': OperationStatusSet.objects.all(),
|
||
'measurement_definitions': MeasurementDefinition.objects.all(),
|
||
# Droit de prise en charge : nécessite un contrat ET un accès au contrat
|
||
'can_take_over': can_take_over,
|
||
'is_restricted_team': is_restricted_team,
|
||
# Contrôles visibles par l'utilisateur
|
||
'visible_controls': visible_controls,
|
||
'tl_intersection_ids': tl_intersection_ids,
|
||
# --- Timeline mobile ---
|
||
'status_choices': [{"key": key, "label": label} for key, label in STATUS_CHOICES.items()],
|
||
'recommended_next_status': recommended_next_status,
|
||
'allowed_transitions': json.dumps(allowed_transitions),
|
||
'user_roles': user_roles,
|
||
'is_technician': is_technician,
|
||
'is_user_intern': user_config.is_intern if user_config else False,
|
||
'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 '',
|
||
# 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)),
|
||
# Observations liées
|
||
'linked_observations': linked_observations,
|
||
# Discussion
|
||
'discussion_messages': discussion_messages_mobile,
|
||
'can_post_discussion_message': can_post_discussion_message_mobile,
|
||
}
|
||
context['is_subscribed'] = InterventionSubscription.objects.filter(user=request.user, intervention=intervention).exists()
|
||
|
||
return render(request, "interventions/intervention_detail_mobile.html", context)
|
||
|
||
|
||
def mobile_upload_photo_api(request, intervention_id):
|
||
"""API d’upload de photo pour l’interface mobile (Canvas/getUserMedia).
|
||
|
||
Attend un fichier 'image' dans request.FILES et renvoie les infos du document créé.
|
||
"""
|
||
if not request.user.is_authenticated:
|
||
return JsonResponse({'success': False, 'error': _('Authentification requise.')}, status=401)
|
||
|
||
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 JsonResponse({'success': False, 'error': _('Vous n\'avez pas accès à cette intervention.')}, status=403)
|
||
|
||
if not can_edit_intervention(request.user, intervention, user_config=user_config):
|
||
return JsonResponse({'success': False, 'error': _('Vous n\'avez pas la permission de modifier cette intervention.')}, status=403)
|
||
|
||
upload = request.FILES.get('image') or request.FILES.get('file')
|
||
if not upload:
|
||
return JsonResponse({'success': False, 'error': _('Aucun fichier reçu.')}, status=400)
|
||
|
||
try:
|
||
doc = InterventionDocument.objects.create(
|
||
intervention=intervention,
|
||
file=upload,
|
||
uploaded_by=request.user,
|
||
)
|
||
except Exception as e:
|
||
logger.exception("Erreur lors de l\'upload de la photo mobile")
|
||
return JsonResponse({'success': False, 'error': _('Erreur lors de l\'enregistrement de la photo.')}, status=500)
|
||
|
||
file_url = build_private_url("interventions", "InterventionDocument", doc.id, "file")
|
||
thumbnail_url = build_private_url("interventions", "InterventionDocument", doc.id, "thumbnail") if doc.thumbnail else file_url
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'document_id': doc.id,
|
||
'file_url': file_url,
|
||
'thumbnail_url': thumbnail_url,
|
||
'filename': getattr(doc, 'filename', None) or getattr(doc.file, 'name', ''),
|
||
'upload_date': doc.upload_date.isoformat() if hasattr(doc, 'upload_date') and doc.upload_date else None,
|
||
})
|
||
|
||
|
||
def mobile_delete_photo_api(request, intervention_id, document_id):
|
||
"""API pour supprimer une photo uploadée via l’interface mobile (Canvas ou input).
|
||
|
||
Utilise la même logique de droits que pour intervention_delete_document, mais renvoie une réponse JSON.
|
||
"""
|
||
if not request.user.is_authenticated:
|
||
return JsonResponse({'success': False, 'error': _('Authentification requise.')}, status=401)
|
||
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
doc = get_object_or_404(InterventionDocument, pk=document_id, intervention=intervention)
|
||
|
||
user_config = (UserConfig.objects
|
||
.select_related('user')
|
||
.prefetch_related('roles')
|
||
.filter(user=request.user).first())
|
||
if not user_config:
|
||
return JsonResponse({'success': False, 'error': _('Vous n\'avez pas accès à cette intervention.')}, status=403)
|
||
|
||
if not can_delete_document(
|
||
user=request.user,
|
||
user_config=user_config,
|
||
document=doc,
|
||
):
|
||
return JsonResponse(
|
||
{'success': False, 'error': _('Vous n\'avez pas la permission de supprimer ce document.')},
|
||
status=403
|
||
)
|
||
|
||
try:
|
||
doc.delete()
|
||
except Exception:
|
||
logger.exception("Erreur lors de la suppression du document mobile")
|
||
return JsonResponse({'success': False, 'error': _('Erreur lors de la suppression du document.')}, status=500)
|
||
|
||
return JsonResponse({'success': True})
|
||
|
||
|
||
def mobile_intervention_assets_filters(request, intervention_id):
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
|
||
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)
|
||
)
|
||
|
||
categories_qs = categories_qs.order_by('name_fr')
|
||
categories_payload = [
|
||
{
|
||
"id": category.id,
|
||
"code": category.code,
|
||
"name": category.get_name(),
|
||
}
|
||
for category in categories_qs
|
||
]
|
||
|
||
category_ids = [category['id'] for category in categories_payload]
|
||
models_payload: Dict[str, List[Dict[str, str]]] = defaultdict(list)
|
||
if category_ids:
|
||
for Model in views._get_asset_model_classes():
|
||
model_label = Model._meta.model_name
|
||
queryset = Model.objects.filter(category_id__in=category_ids).only(
|
||
'id', 'code', 'name_fr', 'name_nl', 'category_id'
|
||
)
|
||
for asset_model in queryset.iterator():
|
||
label = asset_model.get_name() or ''
|
||
if label:
|
||
display = f"{label}"
|
||
else:
|
||
display = asset_model.code
|
||
|
||
models_payload[str(asset_model.category_id)].append({
|
||
"id": asset_model.id,
|
||
"code": asset_model.code,
|
||
"name": label,
|
||
"label": display,
|
||
"model": model_label,
|
||
})
|
||
|
||
for key, items in models_payload.items():
|
||
items.sort(key=lambda entry: entry['label'] or '')
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'categories': categories_payload,
|
||
'models': models_payload,
|
||
'max_results': MAX_MOBILE_ASSET_RESULTS,
|
||
})
|
||
|
||
|
||
def mobile_intervention_assets_search(request, intervention_id):
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
|
||
search_term = (request.GET.get('q') or '').strip().lower()
|
||
category_ids = set(_parse_int_query_values(request, 'category', 'categories'))
|
||
allowed_category_ids = _get_allowed_asset_category_ids(intervention)
|
||
if allowed_category_ids:
|
||
category_ids &= allowed_category_ids
|
||
|
||
if not category_ids:
|
||
category_filter = None
|
||
else:
|
||
category_filter = category_ids
|
||
|
||
model_filters = _parse_model_filters(request)
|
||
|
||
loc_mode = request.GET.get('loc_mode', '')
|
||
if loc_mode == 'custom':
|
||
# L'utilisateur a manuellement géré les localisations dans le modal
|
||
loc_pairs = _parse_location_ct_obj_params(request, 'loc')
|
||
locations = _resolve_location_pairs_to_links(loc_pairs) if loc_pairs else []
|
||
else:
|
||
# Comportement par défaut : localisations liées à l'intervention
|
||
location_ids = set(_parse_int_query_values(request, 'location', 'locations'))
|
||
locations_qs = intervention.locations.select_related('content_type')
|
||
if location_ids:
|
||
locations_qs = locations_qs.filter(id__in=location_ids)
|
||
locations = list(locations_qs)
|
||
|
||
results = views._search_mobile_assets(
|
||
intervention=intervention,
|
||
locations=locations,
|
||
category_ids=category_filter,
|
||
model_filters=model_filters,
|
||
search_term=search_term,
|
||
)
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'results': results,
|
||
'max_results': MAX_MOBILE_ASSET_RESULTS,
|
||
})
|
||
|
||
|
||
def mobile_intervention_assets_locations(request, intervention_id):
|
||
"""Recherche de localisations pour la thématique de l'intervention.
|
||
Permet à l'utilisateur d'ajouter des localisations supplémentaires dans le filtre du modal.
|
||
"""
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
|
||
q = (request.GET.get('q') or '').strip()
|
||
thematic = intervention.thematic.code if intervention.thematic else None
|
||
|
||
registry = registry_items_for_thematics([thematic] if thematic else None)
|
||
|
||
lang = (get_language() or 'fr').split('-')[0]
|
||
is_nl = (lang == 'nl')
|
||
|
||
results = []
|
||
for _key, meta in registry:
|
||
Model = meta['model']
|
||
ct = ContentType.objects.get_for_model(Model)
|
||
|
||
if q:
|
||
qn = strip_accents_lower(q)
|
||
qs = base_search_qs(Model, qn, is_nl).order_by('_prio', 'code')[:SEARCH_LIMIT_PER_TYPE]
|
||
else:
|
||
qs = Model.objects.order_by('code')[:SEARCH_LIMIT_PER_TYPE]
|
||
|
||
for loc in qs:
|
||
label = _format_location_label(loc)
|
||
if not label:
|
||
continue
|
||
results.append({
|
||
'ct_id': ct.id,
|
||
'obj_id': loc.pk,
|
||
'label': label,
|
||
'type_label': str(meta.get('label', '')),
|
||
})
|
||
|
||
return JsonResponse({'success': True, 'results': results[:SEARCH_LIMIT_TOTAL]})
|
||
|
||
|
||
def mobile_intervention_assets_sync(request, intervention_id):
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
|
||
try:
|
||
payload = json.loads(request.body or '{}')
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({'success': False, 'error': _('Requête invalide.')}, status=400)
|
||
|
||
assets_payload = payload.get('assets')
|
||
if not isinstance(assets_payload, list):
|
||
return JsonResponse({'success': False, 'error': _('Aucune sélection d\'équipements reçue.')}, status=400)
|
||
|
||
# Extract additional parameters from payload
|
||
delete_operations = payload.get('delete_operations', False)
|
||
mark_as_removed = payload.get('mark_as_removed', False)
|
||
removed_asset_key = payload.get('removed_asset_key', None)
|
||
removed_asset_keys = payload.get('removed_asset_keys', None)
|
||
if not isinstance(removed_asset_keys, list):
|
||
removed_asset_keys = None
|
||
|
||
try:
|
||
stats = views._sync_intervention_assets_data(
|
||
intervention,
|
||
assets_payload,
|
||
delete_operations=delete_operations,
|
||
mark_as_removed=mark_as_removed,
|
||
removed_asset_key=removed_asset_key,
|
||
removed_asset_keys=removed_asset_keys,
|
||
user=request.user,
|
||
request=request
|
||
)
|
||
except ValueError as exc:
|
||
return JsonResponse({'success': False, 'error': str(exc)}, status=400)
|
||
except RuntimeError as exc:
|
||
logger.exception("Erreur lors de la synchronisation des assets mobiles", exc_info=exc)
|
||
return JsonResponse({'success': False, 'error': _('Erreur lors de la synchronisation des équipements.')}, status=500)
|
||
|
||
base_message = _(
|
||
"Synchronisation terminée. Ajoutés: %(created)d • Supprimés: %(deleted)d • Total cibles: %(total)d"
|
||
) % {
|
||
'created': stats.get('created', 0),
|
||
'deleted': stats.get('deleted', 0),
|
||
'total': stats.get('target_total', 0),
|
||
}
|
||
|
||
if stats.get('operations_created'):
|
||
base_message += _(" • Opérations ajoutées: %(ops)d") % {'ops': stats['operations_created']}
|
||
if stats.get('operations_deleted'):
|
||
base_message += _(" • Opérations supprimées: %(ops)d") % {'ops': stats['operations_deleted']}
|
||
|
||
response_payload = {
|
||
'success': True,
|
||
'message': base_message,
|
||
'stats': stats,
|
||
}
|
||
|
||
if stats.get('assets_with_values'):
|
||
response_payload['warning'] = ngettext(
|
||
"Certaines opérations n'ont pas été supprimées car des valeurs ont été encodées pour %(count)d équipement.",
|
||
"Certaines opérations n'ont pas été supprimées car des valeurs ont été encodées pour %(count)d équipements.",
|
||
stats['assets_with_values'],
|
||
) % {'count': stats['assets_with_values']}
|
||
|
||
return JsonResponse(response_payload)
|
||
|
||
|
||
def add_repair_intervention_mobile(request):
|
||
"""
|
||
Vue mobile pour créer une nouvelle intervention de réparation (structures uniquement).
|
||
Accessible uniquement aux techniciens de la thématique structures.
|
||
"""
|
||
# Vérifier les permissions
|
||
try:
|
||
user_config = UserConfig.objects.get(user=request.user)
|
||
except UserConfig.DoesNotExist:
|
||
return HttpResponseForbidden(_("Configuration utilisateur non trouvée."))
|
||
|
||
# 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 HttpResponseForbidden(_("Cette fonctionnalité est réservée à la thématique structures."))
|
||
|
||
# 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 HttpResponseForbidden(_("Cette fonctionnalité est réservée aux techniciens."))
|
||
|
||
has_edit_rights = UserThematics.objects.filter(
|
||
user_config=user_config,
|
||
thematic=default_thematic,
|
||
can_edit_interventions=True
|
||
).exists()
|
||
if not has_edit_rights:
|
||
return HttpResponseForbidden(_("Vous n'avez pas les droits pour créer des interventions."))
|
||
|
||
# Récupérer les catégories de la thématique structures
|
||
categories = AssetCategory.objects.filter(thematic=default_thematic).order_by('name_fr')
|
||
|
||
# Récupérer le membre de la société
|
||
company_member = CompanyMember.objects.filter(user=request.user).prefetch_related('teams__company').first()
|
||
|
||
if request.method == 'POST':
|
||
# 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 render(request, 'interventions/add/add_repair_intervention_mobile.html', {
|
||
'categories': categories,
|
||
'errors': errors,
|
||
'form_data': request.POST,
|
||
})
|
||
|
||
# Récupérer la localisation
|
||
try:
|
||
location = StructureLocation.objects.select_related('structure').get(id=location_id)
|
||
except StructureLocation.DoesNotExist:
|
||
return render(request, 'interventions/add/add_repair_intervention_mobile.html', {
|
||
'categories': categories,
|
||
'errors': [_("Localisation non trouvée.")],
|
||
'form_data': request.POST,
|
||
})
|
||
|
||
# Récupérer la catégorie
|
||
try:
|
||
category = AssetCategory.objects.get(id=category_id)
|
||
except AssetCategory.DoesNotExist:
|
||
return render(request, 'interventions/add/add_repair_intervention_mobile.html', {
|
||
'categories': categories,
|
||
'errors': [_("Catégorie non trouvée.")],
|
||
'form_data': request.POST,
|
||
})
|
||
|
||
# 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
|
||
|
||
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, avec la même compagnie
|
||
contract = None
|
||
if structure:
|
||
structure_contract = StructureContract.objects.filter(
|
||
structure=structure,
|
||
contract__is_active=True
|
||
).select_related('contract')
|
||
if structure_contract.exists():
|
||
for sc in structure_contract:
|
||
if assigned_provider and sc.contract.company == assigned_provider:
|
||
contract = sc.contract
|
||
break
|
||
|
||
with transaction.atomic():
|
||
new_intervention = Intervention(
|
||
title=title,
|
||
description=_("Intervention de réparation créée depuis l'application mobile."),
|
||
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='in_preparation',
|
||
status_order=STATUS_ORDERS['in_preparation'],
|
||
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,
|
||
)
|
||
|
||
messages.success(
|
||
request,
|
||
_("L'intervention %(code)s a été créée avec succès.") % {'code': new_intervention.code}
|
||
)
|
||
|
||
return redirect(reverse('mobile:intervention_detail_mobile', kwargs={'intervention_id': new_intervention.id}))
|
||
|
||
# GET: afficher le formulaire
|
||
return render(request, 'interventions/add/add_repair_intervention_mobile.html', {
|
||
'categories': categories,
|
||
})
|
||
|
||
|
||
def choose_maintain_type_for_intervention_mobile(request):
|
||
"""
|
||
Étape 1 mobile : choix du type de maintenance (corrective / améliorative).
|
||
Accessible aux utilisateurs internes admin/manager/controller.
|
||
"""
|
||
try:
|
||
user_config = UserConfig.objects.get(user=request.user)
|
||
except UserConfig.DoesNotExist:
|
||
return HttpResponseForbidden(_("Configuration utilisateur non trouvée."))
|
||
|
||
if not user_config.is_intern or not user_config.roles.filter(name__in=['admin', 'manager', 'controller']).exists():
|
||
return HttpResponseForbidden(_("Accès réservé aux gestionnaires internes (admin, manager, controller)."))
|
||
|
||
return render(request, 'interventions/add/choose_maintain_type_for_intervention_mobile.html')
|
||
|
||
|
||
def choose_thematic_for_intervention_mobile(request):
|
||
"""
|
||
Étape 2 mobile : choix de la thématique avant de créer une intervention.
|
||
Accessible aux utilisateurs internes admin/manager/controller.
|
||
Si une seule thématique avec droits d'édition, redirige directement vers le formulaire desktop.
|
||
"""
|
||
try:
|
||
user_config = UserConfig.objects.get(user=request.user)
|
||
except UserConfig.DoesNotExist:
|
||
return HttpResponseForbidden(_("Configuration utilisateur non trouvée."))
|
||
|
||
if not user_config.is_intern or not user_config.roles.filter(name__in=['admin', 'manager', 'controller']).exists():
|
||
return HttpResponseForbidden(_("Accès réservé aux gestionnaires internes (admin, manager, controller)."))
|
||
|
||
maintain_type = request.GET.get('maintain_type', 'corrective')
|
||
if maintain_type not in ('corrective', 'ameliorative'):
|
||
maintain_type = 'corrective'
|
||
|
||
lang = get_language()
|
||
order_field = 'thematic__name_nl' if lang == 'nl' else 'thematic__name_fr'
|
||
user_thematics = (
|
||
UserThematics.objects
|
||
.filter(user_config=user_config, can_edit_interventions=True)
|
||
.select_related('thematic')
|
||
.order_by(order_field)
|
||
)
|
||
thematics = [ut.thematic for ut in user_thematics if ut.thematic]
|
||
|
||
if not thematics:
|
||
return HttpResponseForbidden(_("Aucune thématique avec droits de création d'intervention trouvée."))
|
||
|
||
if len(thematics) == 1:
|
||
url = reverse('mobile:intervention_add_mobile_form', kwargs={'thematic_code': thematics[0].code})
|
||
return redirect(f"{url}?maintain_type={maintain_type}")
|
||
|
||
return render(request, 'interventions/add/choose_thematic_for_intervention_mobile.html', {
|
||
'thematics': thematics,
|
||
'maintain_type': maintain_type,
|
||
})
|
||
|
||
|
||
def add_intervention_mobile(request, thematic_code):
|
||
"""
|
||
Vue mobile pour créer une nouvelle intervention (toutes thématiques).
|
||
Accessible aux utilisateurs internes avec rôle admin, manager ou controller.
|
||
Permet de créer des interventions correctives ou amélioratives.
|
||
"""
|
||
try:
|
||
user_config = UserConfig.objects.get(user=request.user)
|
||
except UserConfig.DoesNotExist:
|
||
return HttpResponseForbidden(_("Configuration utilisateur non trouvée."))
|
||
|
||
# Réservé aux utilisateurs internes admin/manager/controller
|
||
if not user_config.is_intern or not user_config.roles.filter(name__in=['admin', 'manager', 'controller']).exists():
|
||
return HttpResponseForbidden(_("Accès réservé aux gestionnaires internes (admin, manager, controller)."))
|
||
|
||
thematic = get_object_or_404(Thematic, code=thematic_code)
|
||
|
||
# Vérifier que l'utilisateur a les droits d'édition pour cette thématique
|
||
has_edit_rights = UserThematics.objects.filter(
|
||
user_config=user_config,
|
||
thematic=thematic,
|
||
can_edit_interventions=True,
|
||
).exists()
|
||
if not has_edit_rights:
|
||
return HttpResponseForbidden(_("Vous n'avez pas les droits pour créer des interventions pour cette thématique."))
|
||
|
||
categories = AssetCategory.objects.filter(thematic=thematic).order_by('name_fr')
|
||
|
||
# URL de l'autocomplete des équipements selon la thématique
|
||
_THEMATIC_ASSET_AUTOCOMPLETE = {
|
||
'structures': 'assets:structures_autocomplete_assets',
|
||
'trafficlights': 'assets:trafficlights_autocomplete_assets',
|
||
'publiclighting': 'assets:publiclighting_autocomplete_assets',
|
||
'artworks': 'assets:artworks_autocomplete_assets',
|
||
'nature': 'assets:nature_autocomplete_assets',
|
||
'controlcenters': 'assets:controlcenters_autocomplete_assets',
|
||
'sign': 'assets:sign_autocomplete_assets',
|
||
'roads': 'assets:roads_autocomplete_assets',
|
||
'its': 'assets:its_autocomplete_assets',
|
||
'water': 'assets:water_autocomplete_assets',
|
||
}
|
||
_asset_url_name = _THEMATIC_ASSET_AUTOCOMPLETE.get(thematic.code)
|
||
try:
|
||
asset_search_url = reverse(_asset_url_name) if _asset_url_name else None
|
||
except NoReverseMatch:
|
||
asset_search_url = None
|
||
|
||
# Statuts disponibles à la création
|
||
CREATION_STATUSES = [
|
||
('in_preparation', STATUS_CHOICES['in_preparation']),
|
||
('to_be_approved', STATUS_CHOICES['to_be_approved']),
|
||
('to_be_planned', STATUS_CHOICES['to_be_planned']),
|
||
('to_be_processed', STATUS_CHOICES['to_be_processed']),
|
||
]
|
||
|
||
if request.method == 'POST':
|
||
location_ct_id = request.POST.get('location_ct')
|
||
location_obj_id = request.POST.get('location_obj')
|
||
category_id = request.POST.get('category_id')
|
||
title = request.POST.get('title', '').strip()
|
||
maintain_type = request.POST.get('maintain_type', 'corrective')
|
||
if maintain_type not in ('corrective', 'ameliorative'):
|
||
maintain_type = 'corrective'
|
||
status = 'in_preparation'
|
||
location_code = request.POST.get('location_code') or None
|
||
asset_ct_id = request.POST.get('asset_ct') or None
|
||
asset_obj_id = request.POST.get('asset_obj') or None
|
||
|
||
try:
|
||
lon = float(request.POST.get('location_lon')) if request.POST.get('location_lon') else None
|
||
lat = float(request.POST.get('location_lat')) if request.POST.get('location_lat') else None
|
||
except (ValueError, TypeError):
|
||
lon, lat = None, None
|
||
|
||
def _render_form(errors):
|
||
return render(request, 'interventions/add/add_intervention_mobile.html', {
|
||
'categories': categories,
|
||
'creation_statuses': CREATION_STATUSES,
|
||
'errors': errors,
|
||
'form_data': request.POST,
|
||
'thematic': thematic,
|
||
'asset_search_url': asset_search_url,
|
||
})
|
||
|
||
errors = []
|
||
if not location_ct_id or not location_obj_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 _render_form(errors)
|
||
|
||
try:
|
||
category = AssetCategory.objects.get(id=category_id)
|
||
except AssetCategory.DoesNotExist:
|
||
return _render_form([_("Catégorie non trouvée.")])
|
||
|
||
with transaction.atomic():
|
||
new_intervention = Intervention(
|
||
title=title,
|
||
description='',
|
||
maintain_type=maintain_type,
|
||
type='repair',
|
||
priority='3',
|
||
thematic=thematic,
|
||
asset_category=category,
|
||
lon=lon,
|
||
lat=lat,
|
||
location_code=location_code,
|
||
status=status,
|
||
status_order=STATUS_ORDERS[status],
|
||
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)
|
||
|
||
# Créer le lien InterventionLocation via ContentType générique
|
||
try:
|
||
ct = ContentType.objects.get(id=int(location_ct_id))
|
||
InterventionLocation.objects.create(
|
||
intervention=new_intervention,
|
||
content_type=ct,
|
||
object_id=int(location_obj_id),
|
||
)
|
||
except (ContentType.DoesNotExist, ValueError, TypeError):
|
||
pass
|
||
|
||
# Ajouter l'équipement si spécifié (optionnel)
|
||
if asset_ct_id and asset_obj_id:
|
||
try:
|
||
asset_ct = ContentType.objects.get(id=int(asset_ct_id))
|
||
InterventionAsset.objects.create(
|
||
intervention=new_intervention,
|
||
content_type=asset_ct,
|
||
object_id=int(asset_obj_id),
|
||
)
|
||
except (ContentType.DoesNotExist, ValueError, TypeError):
|
||
pass
|
||
|
||
InterventionTimeLine.objects.create(
|
||
intervention=new_intervention,
|
||
event_user=request.user,
|
||
event_time=now(),
|
||
event_type='creation',
|
||
from_status=None,
|
||
to_status=new_intervention.status,
|
||
)
|
||
|
||
messages.success(
|
||
request,
|
||
_("L'intervention %(code)s a été créée avec succès.") % {'code': new_intervention.code}
|
||
)
|
||
return redirect(reverse('mobile:intervention_detail_mobile', kwargs={'intervention_id': new_intervention.id}))
|
||
|
||
# GET
|
||
return render(request, 'interventions/add/add_intervention_mobile.html', {
|
||
'categories': categories,
|
||
'creation_statuses': CREATION_STATUSES,
|
||
'thematic': thematic,
|
||
'asset_search_url': asset_search_url,
|
||
})
|
||
|
||
|
||
def save_intervention_mobile_ajax(request, intervention_id):
|
||
"""
|
||
Save intervention data via AJAX without page reload.
|
||
Handles: note, documents (photos), and operations.
|
||
Accepts multipart/form-data for file uploads.
|
||
"""
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
|
||
result = {
|
||
'success': True,
|
||
'note_saved': False,
|
||
'documents_count': 0,
|
||
'operations_count': 0,
|
||
'errors': []
|
||
}
|
||
|
||
# 1. Save note if provided
|
||
note_content = request.POST.get("content", "").strip()
|
||
note_type = request.POST.get("note_type", "")
|
||
note_id = request.POST.get("note_id", "").strip()
|
||
|
||
if note_content:
|
||
try:
|
||
note = None
|
||
# Check if we're updating an existing note
|
||
if note_id:
|
||
try:
|
||
note = InterventionNote.objects.get(pk=note_id, intervention=intervention)
|
||
# Only allow updating if user is the author
|
||
if note.note_author == request.user:
|
||
note.content = note_content
|
||
note.note_type = note_type or note.note_type
|
||
note.updated_by = request.user
|
||
note.save()
|
||
result['note_saved'] = True
|
||
result['note_updated'] = True
|
||
else:
|
||
result['errors'].append(_("Vous n'êtes pas autorisé à modifier cette note"))
|
||
except InterventionNote.DoesNotExist:
|
||
# Note doesn't exist, create a new one
|
||
note = None
|
||
|
||
# Create new note if no existing note was found/updated
|
||
if not note:
|
||
note = InterventionNote.objects.create(
|
||
intervention=intervention,
|
||
content=note_content,
|
||
note_type=note_type or InterventionNote.NoteType.FIELD_NOTE,
|
||
note_author=request.user,
|
||
note_time=now()
|
||
)
|
||
result['note_saved'] = True
|
||
result['note_updated'] = False
|
||
|
||
# Return note data for frontend
|
||
if note:
|
||
result['note'] = {
|
||
'id': note.id,
|
||
'note_id': note.id,
|
||
'content': note.content,
|
||
'note_type': note.note_type,
|
||
'note_type_display': note.get_note_type_display(),
|
||
'author_display': note.note_author.get_short_name() if hasattr(note.note_author, 'get_short_name') else str(note.note_author),
|
||
'note_time_display': note.note_time.strftime('%d-%m-%Y %H:%M') if note.note_time else '',
|
||
'badge_class': note.type_badge_class,
|
||
'icon_class': note.type_icon_class
|
||
}
|
||
|
||
except Exception as e:
|
||
result['errors'].append(_("Erreur lors de l'enregistrement de la note: %(error)s") % {'error': str(e)})
|
||
|
||
# 2. Save documents (photos/files)
|
||
for file in request.FILES.getlist("photos"):
|
||
try:
|
||
InterventionDocument.objects.create(
|
||
intervention=intervention,
|
||
file=file,
|
||
uploaded_by=request.user
|
||
)
|
||
result['documents_count'] += 1
|
||
except Exception as e:
|
||
result['errors'].append(_("Erreur lors de l'upload du fichier %(name)s: %(error)s") % {'name': file.name, 'error': str(e)})
|
||
|
||
# 3. Save operations
|
||
operations_json = request.POST.get("operations_json", "")
|
||
if operations_json:
|
||
try:
|
||
operations_data = json.loads(operations_json)
|
||
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()
|
||
result['operations_count'] += 1
|
||
continue
|
||
else:
|
||
operation.is_not_applicable = False
|
||
|
||
if 'measured_value' in op_data:
|
||
value = op_data['measured_value']
|
||
if value is not None and value != '':
|
||
operation.measured_value = Decimal(str(value))
|
||
operation.measured_duration = None
|
||
operation.measured_datetime = None
|
||
operation.status = None
|
||
operation.is_not_applicable = False
|
||
else:
|
||
operation.measured_value = None
|
||
|
||
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
|
||
except (ValueError, AttributeError):
|
||
result['errors'].append(_("Valeur de durée invalide pour l'opération %(id)s") % {'id': op_id})
|
||
else:
|
||
operation.measured_duration = None
|
||
|
||
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):
|
||
result['errors'].append(_("Valeur de date invalide pour l'opération %(id)s") % {'id': op_id})
|
||
else:
|
||
operation.measured_datetime = None
|
||
|
||
if 'status_id' in op_data:
|
||
status_id = op_data['status_id']
|
||
if status_id:
|
||
status = OperationStatus.objects.get(pk=status_id)
|
||
operation.status = status
|
||
operation.measured_value = None
|
||
operation.is_not_applicable = False
|
||
else:
|
||
operation.status = None
|
||
|
||
operation.save()
|
||
result['operations_count'] += 1
|
||
|
||
except Operation.DoesNotExist:
|
||
result['errors'].append(_("Opération %(id)s introuvable") % {'id': op_id})
|
||
except OperationStatus.DoesNotExist:
|
||
result['errors'].append(_("Statut invalide pour l'opération %(id)s") % {'id': op_id})
|
||
except (InvalidOperation, ValueError):
|
||
result['errors'].append(_("Valeur de mesure invalide pour l'opération %(id)s") % {'id': op_id})
|
||
except json.JSONDecodeError:
|
||
result['errors'].append(_("Format JSON invalide pour les opérations"))
|
||
|
||
# Build message
|
||
parts = []
|
||
if result['note_saved']:
|
||
parts.append(_("Note enregistrée"))
|
||
if result['documents_count'] > 0:
|
||
parts.append(_("%(count)s fichier(s)") % {'count': result['documents_count']})
|
||
if result['operations_count'] > 0:
|
||
parts.append(_("%(count)s opération(s)") % {'count': result['operations_count']})
|
||
|
||
if parts:
|
||
result['message'] = ", ".join(parts) + " ✓"
|
||
else:
|
||
result['message'] = _("Aucune modification")
|
||
|
||
result['success'] = len(result['errors']) == 0
|
||
|
||
return JsonResponse(result)
|
||
|
||
|
||
def get_transferable_members(request, intervention_id):
|
||
"""
|
||
API endpoint to get members available for intervention transfer.
|
||
Returns members from teams that share thematics with the current user's team,
|
||
and belong to the same company.
|
||
"""
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
|
||
# Get current user's member and team
|
||
current_member = CompanyMember.objects.prefetch_related('teams__company', 'teams__thematics').filter(user=request.user).first()
|
||
|
||
if not current_member or not current_member.teams.exists():
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Vous n'êtes pas membre d'une équipe."),
|
||
'members': []
|
||
})
|
||
|
||
current_teams = current_member.teams.all()
|
||
current_companies = list(set(t.company for t in current_teams if t.company))
|
||
|
||
# Get thematics of the current user's team
|
||
current_team_thematic_ids = set()
|
||
for t in current_teams:
|
||
current_team_thematic_ids.update(t.thematics.values_list('id', flat=True))
|
||
|
||
if not current_team_thematic_ids:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Votre équipe n'a pas de thématiques assignées."),
|
||
'members': []
|
||
})
|
||
|
||
# Find teams in the same company that share at least one thematic
|
||
teams_with_shared_thematics = (
|
||
CompanyTeam.objects
|
||
.filter(company__in=current_companies)
|
||
.filter(thematics__id__in=current_team_thematic_ids)
|
||
.distinct()
|
||
)
|
||
|
||
# Get all members from those teams, excluding the current user
|
||
transferable_members = (
|
||
CompanyMember.objects
|
||
.prefetch_related('teams__company')
|
||
.filter(teams__in=teams_with_shared_thematics)
|
||
.exclude(user=request.user)
|
||
.distinct()
|
||
.order_by('name')
|
||
)
|
||
|
||
# Serialize members
|
||
members_list = []
|
||
for member in transferable_members:
|
||
member_teams = member.teams.all()
|
||
shared_member_teams = [t for t in member_teams if t in teams_with_shared_thematics]
|
||
first_shared_team = shared_member_teams[0] if shared_member_teams else (member_teams[0] if member_teams else None)
|
||
members_list.append({
|
||
'id': member.id,
|
||
'name': member.name,
|
||
'team_name': first_shared_team.name if first_shared_team else '',
|
||
'company_name': first_shared_team.company.name if first_shared_team and first_shared_team.company else '',
|
||
})
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'members': members_list,
|
||
'current_member_id': current_member.id,
|
||
'current_team_name': ", ".join(t.name for t in current_teams),
|
||
})
|
||
|
||
|
||
def transfer_intervention(request, intervention_id):
|
||
"""
|
||
API endpoint to transfer an intervention to another member.
|
||
The target member must be from a team with shared thematics and same company.
|
||
"""
|
||
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||
|
||
# Get current user's member and team
|
||
current_member = CompanyMember.objects.prefetch_related('teams__company', 'teams__thematics').filter(user=request.user).first()
|
||
|
||
if not current_member or not current_member.teams.exists():
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Vous n'êtes pas membre d'une équipe.")
|
||
}, status=403)
|
||
|
||
current_teams = current_member.teams.all()
|
||
current_companies = list(set(t.company for t in current_teams if t.company))
|
||
current_company_ids = [c.id for c in current_companies]
|
||
|
||
# Get thematics of the current user's team
|
||
current_team_thematic_ids = set()
|
||
for t in current_teams:
|
||
current_team_thematic_ids.update(t.thematics.values_list('id', flat=True))
|
||
|
||
# Find teams in the same company that share at least one thematic
|
||
teams_with_shared_thematics = (
|
||
CompanyTeam.objects
|
||
.filter(company__in=current_companies)
|
||
.filter(thematics__id__in=current_team_thematic_ids)
|
||
.distinct()
|
||
)
|
||
|
||
# Get target member ID from request
|
||
try:
|
||
if request.content_type == 'application/json':
|
||
payload = json.loads(request.body.decode('utf-8') or '{}')
|
||
target_member_id = payload.get('member_id')
|
||
else:
|
||
target_member_id = request.POST.get('member_id')
|
||
|
||
if not target_member_id:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Veuillez sélectionner un membre.")
|
||
}, status=400)
|
||
|
||
target_member_id = int(target_member_id)
|
||
except (json.JSONDecodeError, ValueError, TypeError):
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Paramètre invalide.")
|
||
}, status=400)
|
||
|
||
# Get target member
|
||
target_member = CompanyMember.objects.prefetch_related('teams__company', 'teams__thematics').filter(id=target_member_id).first()
|
||
|
||
if not target_member:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Membre introuvable.")
|
||
}, status=404)
|
||
|
||
# Verify target member is from the same company
|
||
target_teams = target_member.teams.all()
|
||
if not any(t.company_id in current_company_ids for t in target_teams if t.company_id):
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Le membre cible doit être de la même société.")
|
||
}, status=403)
|
||
|
||
# Verify target team has shared thematics with current team
|
||
target_team_thematic_ids = set()
|
||
for t in target_teams:
|
||
target_team_thematic_ids.update(t.thematics.values_list('id', flat=True))
|
||
|
||
if not current_team_thematic_ids.intersection(target_team_thematic_ids):
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _("Le membre cible doit être dans une équipe avec des thématiques communes.")
|
||
}, status=403)
|
||
|
||
# Perform the transfer
|
||
old_member = intervention.assigned_member
|
||
old_status = intervention.status
|
||
|
||
shared_teams = [t for t in target_teams if t in teams_with_shared_thematics]
|
||
target_team = shared_teams[0] if shared_teams else (target_teams[0] if target_teams else None)
|
||
|
||
intervention.assigned_member = target_member
|
||
intervention.assigned_team = target_team
|
||
|
||
# If intervention is in progress, pause it automatically
|
||
status_changed = False
|
||
if intervention.status == 'in_progress':
|
||
intervention.status = 'on_pause'
|
||
status_changed = True
|
||
intervention.save(update_fields=['assigned_member', 'assigned_team', 'status'])
|
||
else:
|
||
intervention.save(update_fields=['assigned_member', 'assigned_team'])
|
||
|
||
# Record the transfer in timeline
|
||
event_time = now()
|
||
|
||
# Always record the transfer event
|
||
InterventionTimeLine.objects.create(
|
||
intervention=intervention,
|
||
event_user=request.user,
|
||
event_time=event_time,
|
||
event_type='transfer',
|
||
from_status=old_status,
|
||
to_status=old_status, # Status before any change
|
||
)
|
||
|
||
# If status changed, also record a status change event
|
||
if status_changed:
|
||
InterventionTimeLine.objects.create(
|
||
intervention=intervention,
|
||
event_user=request.user,
|
||
event_time=event_time,
|
||
event_type='status',
|
||
from_status=old_status,
|
||
to_status=intervention.status,
|
||
)
|
||
|
||
# Add a note about the transfer
|
||
transfer_note = _("Intervention transférée de %(from_member)s (%(from_team)s) vers %(to_member)s (%(to_team)s)") % {
|
||
'from_member': old_member.name if old_member else _("non assigné"),
|
||
'from_team': old_team.name if old_team else _("aucune équipe"),
|
||
'to_member': target_member.name,
|
||
'to_team': target_team.name if target_team else _("aucune équipe"),
|
||
}
|
||
|
||
if status_changed:
|
||
transfer_note += " - " + _("Statut changé automatiquement de 'Démarré' à 'En pause'.")
|
||
|
||
InterventionNote.objects.create(
|
||
intervention=intervention,
|
||
content=transfer_note,
|
||
note_type=InterventionNote.NoteType.COMMENT,
|
||
note_author=request.user,
|
||
note_time=now()
|
||
)
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'message': _("Intervention transférée avec succès vers %(member)s.") % {'member': target_member.name},
|
||
'new_member': {
|
||
'id': target_member.id,
|
||
'name': target_member.name,
|
||
'team_name': target_team.name if target_team else _("aucune équipe"),
|
||
}
|
||
})
|
||
|