loko/loko/interventions/views/mobile.py

2410 lines
106 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.services.timeline_service import record_document_event, record_note_event
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.services.document_sequencing import get_sequenced_intervention_documents
from interventions.views.forms import (InterventionForm, InterventionNoteForm, InterventionDocumentForm,
InterventionOccupationForm, PreventiveInterventionForm, AssetsLinkForm,
LinkExistingInterventionsForm)
from interventions.permissions import (intervention_permission_required, intervention_add_permission_required, intervention_edit_permission_required,
intervention_add_preventive_permission_required,
get_allowed_statuses_for_user, get_allowed_update_fields,
can_view_intervention, can_control_intervention, can_edit_intervention,
can_view_intervention_summary,
get_accessible_interventions_for_technician, filter_viewable_interventions_for_user,
can_edit_note, can_delete_document, can_manage_occupations,
can_add_intervention, can_add_repair_intervention, can_duplicate_intervention,
intervention_manage_occupations_required,
get_contracts_for_intervention_creation, is_external_manager_with_contract_creation_rights,
should_show_created_by_provider_badge, can_view_correction_messages)
from interventions.permissions import ALLOWED_TRANSITIONS, get_allowed_transitions, user_allowed_transitions
from interventions.views.drafts import InterventionDraftManager
from interventions.utils import resolve_intervention_localisations
from common.models import UserConfig, UserContractAccess, UserContractStatusPermission, Thematic, UserThematics
from contracts.models import ContractOrder, ContractPost, Contract, Company, CompanyTeam, CompanyMember, UserCompanyTeamAccess, CompanyMemberHistory
from contracts.permissions import get_allowed_contracts_for_user, user_has_access_to_contract_order
from projects.models import Project, ProjectIntervention
from assets.models import (
PublicLightingStreet,
TrafficLightIntersection, TrafficLightContract,
NatureLocation,
StructureLocation, Structure, StructureContract,
RoadStreet,
SignStreet,
AssetCategory,
AbstractAsset,
AbstractAssetModel,
ITSGeoAsset, ITSGeoAssetContract,
CleanLocation, CleanLocationContract,
)
from assets.permissions import get_allowed_actions_for_asset
from assets.views.locations import LOCATION_REGISTRY, registry_items_for_thematics, base_search_qs, SEARCH_LIMIT_PER_TYPE, SEARCH_LIMIT_TOTAL
from common.utils import redirect_with_variant, measure_time, strip_accents_lower
from observations.permissions import get_observation_access_context
from observations.models import Observation as ObservationModel
from controls.permissions import user_can_view_control
from interventions.views.views_pdf_reporting import (
generate_intervention_pdf,
preview_intervention_pdf,
download_intervention_report_pdf,
preview_intervention_report_pdf,
bulk_execution_report_pdf,
start_bulk_execution_report,
poll_bulk_execution_report,
download_bulk_execution_report,
)
import logging
import smtplib
import socket
import ssl
logger = logging.getLogger(__name__)
from interventions.views.helpers import *
def 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()}")
# Synchroniser les équipes du jour du membre avec l'historique planifié
company_member_current = CompanyMember.objects.filter(user=request.user).first()
if company_member_current:
from contracts.services_teams import sync_member_current_teams
sync_member_current_teams(company_member_current)
# 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]
# Preload preparation orders and items to avoid N+1 queries in mobile list cards
interventions = interventions.prefetch_related(
'preparation_orders__preparationorderitem_set__product',
'preparation_orders__other_items',
)
# 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(),
'is_assigned': i.is_assigned,
'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)
user_cfg = getattr(request.user, 'config', None)
if can_edit_note(request.user, user_cfg, note):
old_note_type = note.note_type
old_content = note.content
note.content = cleaned_data['content']
note.note_type = cleaned_data['note_type']
note.updated_by = request.user
note.save()
record_note_event(
intervention=intervention,
user=request.user,
action='update',
note_type=note.note_type,
content=note.content,
old_note_type=old_note_type,
old_content=old_content,
)
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:
new_note = InterventionNote.objects.create(
intervention=intervention,
content=cleaned_data['content'],
note_type=cleaned_data['note_type'],
note_author=request.user,
note_time=now()
)
record_note_event(
intervention=intervention,
user=request.user,
action='create',
note_type=new_note.note_type,
content=new_note.content,
)
else:
messages.error(request, _("Erreur lors de l'enregistrement de la note."))
# 2. Upload de documents (photos)
uploaded_photos = []
for file in request.FILES.getlist("photos"):
doc = InterventionDocument.objects.create(
intervention=intervention,
file=file,
uploaded_by=request.user
)
filename = getattr(file, 'name', '') or getattr(doc, 'filename', None) or str(doc.id)
uploaded_photos.append(filename)
if uploaded_photos:
record_document_event(
intervention=intervention,
user=request.user,
action='upload',
filenames=uploaded_photos,
)
# 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')
)
editable_note_ids = {n.id for n in notes if can_edit_note(request.user, user_config, n)}
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 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)
]
visible_inspections = intervention.get_related_inspections(user=request.user)
# 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
_latest_finished_sq = InterventionTimeLine.objects.filter(
intervention=OuterRef('pk'),
to_status='finished',
).order_by('-event_time').values('event_time')[:1]
related_interventions = (
Intervention.objects
.annotate(
effective_end = Coalesce(
Subquery(_latest_finished_sq),
'end_time',
'planned_end_time',
'expected_end_time',
'creation_time',
output_field=DateTimeField(),
)
)
.filter(
id__in=related_intervention_ids,
effective_end__gte=three_months_ago
)
.exclude(status='canceled')
.select_related('thematic', 'contract', 'assigned_provider')
.order_by('-effective_end')[:20]
)
# Vérifier les permissions pour chaque intervention
for related_intv in related_interventions:
can_view = can_view_intervention(request.user, related_intv)
can_view_summary = can_view_intervention_summary(request.user, related_intv)
location_history_interventions.append({
'intervention': related_intv,
'can_view': can_view,
'can_view_summary': can_view_summary,
})
# 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.get_associated_observations().select_related('thematic', 'category')
linked_observations = [obs for obs in all_observations if obs_access_context.allows(obs)]
# Discussion liée à cette intervention (mobile)
discussion = None
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
can_create_preparation_order = bool(
user_config and (
user_config.has_role('admin')
or (can_edit_mobile and user_config.can_manage_preparation_orders_any())
)
)
preparation_orders = (
intervention.preparation_orders.all()
.prefetch_related('preparationorderitem_set__product', 'other_items')
.order_by('-date')
if hasattr(intervention, 'preparation_orders') else []
)
material_stats = intervention.get_material_stats()
sequenced_data = get_sequenced_intervention_documents(intervention, notes_qs=notes)
context = {
'intervention': intervention,
'can_create_preparation_order': can_create_preparation_order,
'preparation_orders': preparation_orders,
'material_stats': material_stats,
'sequenced_blocks': sequenced_data['blocks'],
'document_blocks': sequenced_data['blocks'],
'all_sequenced_documents': sequenced_data['all_documents'],
'total_documents_count': sequenced_data['total_documents_count'],
'total_notes_count': sequenced_data['total_notes_count'],
'total_items_count': sequenced_data['total_items_count'],
'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,
'can_duplicate_intervention': can_duplicate_intervention(request.user, intervention),
'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,
'visible_inspections': visible_inspections,
'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': _is_photo_required_for_user(request.user, intervention, user_config=user_config),
'photos_required_since': (lambda s: s.isoformat() if s else '')(
_get_photos_required_since(intervention)
) if _is_photo_required_for_user(request.user, intervention, user_config=user_config) else '',
# 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': 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,
)
filename = getattr(upload, 'name', '') or getattr(doc, 'filename', None) or getattr(doc.file, 'name', '') or str(doc.id)
record_document_event(
intervention=intervention,
user=request.user,
action='upload',
filenames=[filename],
)
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
)
filename = getattr(doc, 'filename', None) or getattr(doc.file, 'name', '') or str(doc.id)
try:
doc.delete()
record_document_event(
intervention=intervention,
user=request.user,
action='delete',
filenames=[filename],
)
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():
if not contract:
from common.models import UserConfig
user_config = UserConfig.objects.filter(user=request.user).first()
if user_config and user_config.limit_interventions_to_contracts:
default_contract = user_config.get_default_contract()
if default_contract:
contract = default_contract
if not assigned_provider:
assigned_provider = default_contract.company
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():
mobile_contract = None
mobile_provider = None
from common.models import UserConfig
user_config = UserConfig.objects.filter(user=request.user).first()
if user_config and user_config.limit_interventions_to_contracts:
default_contract = user_config.get_default_contract()
if default_contract:
mobile_contract = default_contract
mobile_provider = default_contract.company
new_intervention = Intervention(
title=title,
description='',
maintain_type=maintain_type,
type='repair',
priority='3',
thematic=thematic,
asset_category=category,
contract=mobile_contract,
assigned_provider=mobile_provider,
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)
user_cfg = getattr(request.user, 'config', None)
if can_edit_note(request.user, user_cfg, note):
old_note_type = note.note_type
old_content = note.content
note.content = note_content
note.note_type = note_type or note.note_type
note.updated_by = request.user
note.save()
record_note_event(
intervention=intervention,
user=request.user,
action='update',
note_type=note.note_type,
content=note.content,
old_note_type=old_note_type,
old_content=old_content,
)
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()
)
record_note_event(
intervention=intervention,
user=request.user,
action='create',
note_type=note.note_type,
content=note.content,
)
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()
# 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"),
}
transfer_changes = {
'assigned_team': {
'label': str(_("Équipe assignée")),
'old': old_team.name if old_team else None,
'new': target_team.name if target_team else None,
},
'assigned_member': {
'label': str(_("Membre assigné")),
'old': old_member.name if old_member else None,
'new': target_member.name,
}
}
# 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
event_description=transfer_note,
field_changes=transfer_changes,
)
# 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_change',
from_status=old_status,
to_status=intervention.status,
event_description=_("Passage en pause automatique suite au transfert."),
)
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"),
}
})