loko/loko/observations/views.py

2185 lines
90 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.shortcuts import render, redirect
from django.urls import reverse
from urllib.parse import urlencode
from django.contrib.gis.geos import Point
from django.contrib.auth.decorators import login_required
from django.contrib.admin.views.decorators import staff_member_required
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
from django.views.decorators.http import require_GET, require_POST
from django.contrib.gis.db.models.functions import Transform, AsGeoJSON
from django.utils.translation import get_language
from django.db.models import Q
from django.template.loader import render_to_string
from django.contrib import messages
from django.utils.translation import gettext_lazy as _
import json
from django.contrib.auth.decorators import user_passes_test
from interventions.models import Intervention, Symptom
from common.private_files.helpers import build_private_url
from .forms import ObservationForm
from .models import (
Observation, ObservationPhoto, ObservationComment, OBSERVATION_TYPE_CHOICES,
ChecklistTemplate, ChecklistItem, ObservationChecklistResponse
)
from .permissions import (
observation_access_required,
observation_object_permission_required,
)
from common.models import UserConfig, Thematic
from common.utils import get_short_name
from assets.models import AssetCategory
def _user_can_access_thematic(access_context, thematic_id):
if access_context.user_config.has_role('admin') or access_context.user_config.has_role('top_manager'):
return True
if thematic_id is None:
return False
return thematic_id in access_context.thematic_ids
@login_required
@observation_access_required
def observations_index(request):
"""Desktop index page with table and map for observations."""
lang = get_language()
access_context = request.observation_access
order_field = f'name_{lang}' if lang == 'nl' else 'name_fr'
thematic_ids = access_context.thematic_ids
if thematic_ids:
thematics = Thematic.objects.filter(id__in=thematic_ids).order_by(order_field)
else:
thematics = Thematic.objects.none()
# Get categories for the accessible thematics
categories = AssetCategory.objects.filter(thematic__id__in=thematic_ids).order_by(order_field)
user_config = getattr(request.user, 'config', None)
saved_filters = (user_config.observation_list_filters or {}) if user_config else {}
saved_sort = (user_config.observation_list_sort or {}) if user_config else {}
# Redirect on first visit with saved preferences
if not request.GET:
if saved_filters:
query_params = request.GET.copy()
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
from django.http import HttpResponseRedirect
return HttpResponseRedirect(f"{request.path}?{query_params.urlencode()}")
# Get filter parameters from query string
selected_thematics = request.GET.getlist('thematic')
selected_categories = request.GET.getlist('category')
selected_statuses = request.GET.getlist('status')
selected_types = request.GET.getlist('type')
selected_symptoms = request.GET.getlist('symptom')
# Par défaut, exclure le type 'inventaire'
if not selected_types:
selected_types = [key for key, _label in OBSERVATION_TYPE_CHOICES if key != 'inventory']
search_query = request.GET.get('q', '')
show_mine_only = request.GET.get('mine') == '1'
show_my_team = request.GET.get('team') == '1'
date_from = request.GET.get('date_from', '')
date_to = request.GET.get('date_to', '')
from contracts.models import CompanyMember as _CM
user_has_team = _CM.objects.filter(user=request.user).exists()
# Get symptoms for accessible thematics (with category associations for client-side filtering)
symptoms = Symptom.objects.filter(
thematic__id__in=thematic_ids,
is_active=True,
).prefetch_related('asset_categories').order_by(order_field)
_status_badge_map = {
'in_preparation': 'secondary',
'to_process': 'danger',
'needs_info': 'warning',
'in_progress': 'warning',
'processed': 'success',
'not_relevant': 'secondary',
'to_redirect': 'warning',
'duplicate': 'info',
'closed': 'dark',
'archived': 'dark',
}
_type_badge_map = {
'incident': 'danger',
'inventory': 'secondary',
'remark': 'info',
}
def _symptom_cat_ids(s):
return ','.join(str(c.id) for c in s.asset_categories.all())
context = {
'thematic_choices': [(t.code, t.name, t.icon) for t in thematics],
'selected_thematics': selected_thematics,
'category_choices': sorted(
[(c.id, c.get_name(), c.thematic.code if c.thematic else '', c.parent_id) for c in categories],
key=lambda t: (t[3] if t[3] is not None else t[0], 1 if t[3] is not None else 0, t[1])
),
'selected_categories': selected_categories,
'symptom_choices': [
(s.id, s.get_name(), s.thematic.code if s.thematic else '', _symptom_cat_ids(s))
for s in symptoms
],
'selected_symptoms': selected_symptoms,
'search_query': search_query,
'status_choices': [(k, l, _status_badge_map.get(k, 'secondary')) for k, l in Observation.STATUS_CHOICES],
'selected_statuses': selected_statuses,
'observation_type_choices': [(k, l, _type_badge_map.get(k, 'secondary')) for k, l in OBSERVATION_TYPE_CHOICES],
'selected_types': selected_types,
'show_mine_only': show_mine_only,
'show_my_team': show_my_team,
'user_has_team': user_has_team,
'date_from': date_from,
'date_to': date_to,
'saved_sort': json.dumps(saved_sort, ensure_ascii=False),
}
return render(request, 'observations/observations_index.html', context)
@login_required
@observation_access_required
def observations_index_mobile(request):
"""Mobile index page with list of observations."""
access_context = request.observation_access
base_queryset = Observation.objects.select_related(
'thematic', 'category', 'created_by', 'symptom'
)
observations = access_context.filter_queryset(base_queryset).order_by('-created_at')
# Filter by search query
q = request.GET.get('q', '')
if q:
observations = observations.filter(
Q(description__icontains=q) |
Q(address__icontains=q) |
Q(category__name_fr__icontains=q) |
Q(category__name_nl__icontains=q) |
Q(symptom__name_fr__icontains=q) |
Q(symptom__name_nl__icontains=q)
)
# Filter by thematic
thematic = request.GET.get('thematic')
if thematic:
observations = observations.filter(thematic__code=thematic)
# Filter by observation type
observation_types = request.GET.getlist('type')
if observation_types:
observations = observations.filter(observation_type__in=observation_types)
else:
# Par défaut, exclure les observations de type 'inventaire'
observations = observations.exclude(observation_type='inventory')
# Filter by status
statuses = request.GET.getlist('status')
if statuses:
observations = observations.filter(status__in=statuses)
else:
# Par défaut, exclure les observations archivées
observations = observations.exclude(status='archived')
# Filter by mine (only user's observations)
show_mine_only = request.GET.get('mine') == '1'
if show_mine_only:
observations = observations.filter(created_by=request.user)
# Limit to recent observations
observations = observations[:100]
# Couleur Bootstrap par statut d'observation
_OBS_STATUS_COLOR = {
'in_preparation': 'secondary',
'to_process': 'danger',
'needs_info': 'warning',
'in_progress': 'warning',
'processed': 'success',
'not_relevant': 'secondary',
'to_redirect': 'warning',
'duplicate': 'info',
'closed': 'dark',
'archived': 'dark',
}
# Construction des marqueurs pour la carte
markers_data = [
{
'id': o.id,
'code': o.code or f'OBS#{o.id}',
'description': (o.description or '')[:60],
'status': o.status,
'status_label': o.get_status_display(),
'status_color': _OBS_STATUS_COLOR.get(o.status, 'secondary'),
'observation_type': o.observation_type,
'lat': o.latitude,
'lon': o.longitude,
'url': reverse('mobile:observation_detail_mobile', args=[o.id]),
}
for o in observations
if o.latitude and o.longitude
]
# Si c'est une requête AJAX, retourner uniquement la liste en HTML
if request.headers.get('x-requested-with') == 'XMLHttpRequest':
html = render_to_string(
'observations/_observations_list_mobile.html',
{'observations': observations},
request=request,
)
return JsonResponse({'html': html, 'markers': markers_data})
# Prepare filters for template
from .models import OBSERVATION_TYPE_CHOICES
type_filters = []
for type_key, type_label in OBSERVATION_TYPE_CHOICES:
type_filters.append({
'key': type_key,
'label': type_label,
'checked': type_key in observation_types if observation_types else type_key != 'inventaire'
})
status_filters = []
for status_key, status_label in Observation.STATUS_CHOICES:
# Par défaut, tous les statuts sauf 'archived' sont cochés
if statuses:
is_checked = status_key in statuses
else:
is_checked = status_key != 'archived'
status_filters.append({
'key': status_key,
'label': status_label,
'checked': is_checked
})
context = {
'observations': observations,
'search_query': q,
'selected_thematic': thematic,
'type_filters': type_filters,
'status_filters': status_filters,
'show_mine_only': show_mine_only,
'markers_json': json.dumps(markers_data, ensure_ascii=False),
}
return render(request, 'observations/observations_index_mobile.html', context)
@require_GET
@login_required
@observation_access_required
def observations_geojson(request):
"""Return observations as GeoJSON for map display."""
access_context = request.observation_access
qs = Observation.objects.select_related('thematic', 'category', 'created_by', 'symptom')
# Filter by thematic
thematics = request.GET.getlist('thematic')
if thematics:
qs = qs.filter(thematic__code__in=thematics)
# Filter by category
categories = request.GET.getlist('category')
if categories:
child_ids = list(AssetCategory.objects.filter(parent_id__in=categories).values_list('id', flat=True))
expanded_categories = list(categories) + [str(c) for c in child_ids]
qs = qs.filter(category__id__in=expanded_categories)
# Filter by search query
search_query = (request.GET.get('q') or '').strip()
search_mode = (request.GET.get('search_mode') or 'and').lower()
if search_mode not in ('and', 'or'):
search_mode = 'and'
if search_query:
words = search_query.split()
def _word_q(word):
return Q(code__icontains=word) | Q(description__icontains=word) | Q(address__icontains=word)
combined = Q()
if search_mode == 'and':
for w in words:
combined &= _word_q(w)
else:
for w in words:
combined |= _word_q(w)
qs = qs.filter(combined)
# Filter by status
statuses = request.GET.getlist('status')
if statuses:
qs = qs.filter(status__in=statuses)
# Filter by mine (only user's observations)
show_mine_only = request.GET.get('mine') == '1'
if show_mine_only:
qs = qs.filter(created_by=request.user)
# Filter by team (only observations assigned to user's team)
show_my_team = request.GET.get('team') == '1'
if show_my_team:
from contracts.models import CompanyMember as _CM
user_team_ids = list(_CM.objects.filter(user=request.user).values_list('teams__id', flat=True))
if user_team_ids:
qs = qs.filter(symptom__teams__in=user_team_ids)
# Filter by date range
date_from = request.GET.get('date_from', '').strip()
date_to = request.GET.get('date_to', '').strip()
if date_from:
qs = qs.filter(created_at__date__gte=date_from)
if date_to:
qs = qs.filter(created_at__date__lte=date_to)
# Filter by observation type
observation_types = request.GET.getlist('type')
if observation_types:
qs = qs.filter(observation_type__in=observation_types)
else:
# Par défaut, exclure les observations de type 'inventaire'
qs = qs.exclude(observation_type='inventory')
# Filter by symptom
symptoms = request.GET.getlist('symptom')
if symptoms:
qs = qs.filter(symptom__id__in=symptoms)
qs = access_context.filter_queryset(qs)
# Annotate with GeoJSON geometry
qs = qs.annotate(
geom_geojson=AsGeoJSON(Transform('geom', 4326))
)
# Limit results
qs = qs.order_by('-created_at')[:5000]
# Build GeoJSON features
features = []
for obs in qs:
geometry = json.loads(obs.geom_geojson) if obs.geom_geojson else None
if not geometry and getattr(obs, 'longitude', None) is not None and getattr(obs, 'latitude', None) is not None:
try:
lon = float(obs.longitude)
lat = float(obs.latitude)
geometry = {
"type": "Point",
"coordinates": [lon, lat]
}
except (TypeError, ValueError):
geometry = None
features.append({
"type": "Feature",
"id": obs.id,
"geometry": geometry,
"properties": {
"id": obs.id,
"code": obs.code or f"OBS{obs.id:05d}",
"description": obs.description or "",
"address": obs.address or "",
"thematic": obs.thematic.name if obs.thematic else "",
"thematic_icon": obs.thematic.icon if obs.thematic else "",
"category": obs.category.get_name() if obs.category else "",
"symptom": obs.symptom.get_name() if obs.symptom else "",
"created_at": obs.created_at.isoformat() if obs.created_at else "",
"created_by": obs.created_by.get_full_name() if obs.created_by else "",
"status": obs.status,
"status_label": obs.get_status_display(),
"observation_type": obs.observation_type,
"observation_type_label": obs.get_observation_type_display(),
},
})
return JsonResponse({
"type": "FeatureCollection",
"features": features
}, safe=False)
@require_GET
@login_required
@observation_access_required
def observations_export_excel(request):
"""Export filtered observations (with checklist responses) as an Excel file."""
import io
import re
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill
from openpyxl.utils import get_column_letter
access_context = request.observation_access
qs = Observation.objects.select_related(
'thematic', 'category', 'created_by', 'symptom'
).prefetch_related(
'checklist_responses__checklist_item__choice_set__choices'
)
# Apply same filters as observations_geojson
thematics = request.GET.getlist('thematic')
if thematics:
qs = qs.filter(thematic__code__in=thematics)
categories = request.GET.getlist('category')
if categories:
child_ids = list(AssetCategory.objects.filter(parent_id__in=categories).values_list('id', flat=True))
expanded_categories = list(categories) + [str(c) for c in child_ids]
qs = qs.filter(category__id__in=expanded_categories)
search_query = (request.GET.get('q') or '').strip()
search_mode = (request.GET.get('search_mode') or 'and').lower()
if search_mode not in ('and', 'or'):
search_mode = 'and'
if search_query:
words = search_query.split()
def _word_q(word):
return Q(code__icontains=word) | Q(description__icontains=word) | Q(address__icontains=word)
combined = Q()
if search_mode == 'and':
for w in words:
combined &= _word_q(w)
else:
for w in words:
combined |= _word_q(w)
qs = qs.filter(combined)
statuses = request.GET.getlist('status')
if statuses:
qs = qs.filter(status__in=statuses)
show_mine_only = request.GET.get('mine') == '1'
if show_mine_only:
qs = qs.filter(created_by=request.user)
# Filter by team (only observations assigned to user's team)
show_my_team = request.GET.get('team') == '1'
if show_my_team:
from contracts.models import CompanyMember as _CM
user_team_ids = list(_CM.objects.filter(user=request.user).values_list('teams__id', flat=True))
if user_team_ids:
qs = qs.filter(symptom__teams__in=user_team_ids)
# Filter by date range
date_from = request.GET.get('date_from', '').strip()
date_to = request.GET.get('date_to', '').strip()
if date_from:
qs = qs.filter(created_at__date__gte=date_from)
if date_to:
qs = qs.filter(created_at__date__lte=date_to)
observation_types = request.GET.getlist('type')
if observation_types:
qs = qs.filter(observation_type__in=observation_types)
else:
qs = qs.exclude(observation_type='inventory')
# Filter by symptom
symptoms = request.GET.getlist('symptom')
if symptoms:
qs = qs.filter(symptom__id__in=symptoms)
qs = access_context.filter_queryset(qs)
qs = qs.order_by('-created_at')[:5000]
observations_list = list(qs)
checklist_item_map = {}
for obs in observations_list:
for resp in obs.checklist_responses.all():
item = resp.checklist_item
if item.id not in checklist_item_map:
checklist_item_map[item.id] = item
checklist_items_ordered = sorted(
checklist_item_map.values(),
key=lambda i: (i.template_id, i.order, i.id)
)
def _get_translated_value(resp):
"""Return the translated display value for a checklist response."""
item_type = resp.checklist_item.item_type
if item_type in ('choice', 'choice_multiple'):
meta_list = resp.get_value_with_metadata()
if meta_list:
return ", ".join(m['label'] for m in meta_list)
return ""
val = resp.get_value()
return "" if val == "-" else str(val)
def _split_address_street_commune(address):
"""Extract street and commune from a free-text address.
Handles formats like:
- "80 Boulevard Industriel / Industrielaan (Anderlecht)"
- "Rue de la Loi 16, 1000 Bruxelles, Belgique"
"""
if not address:
return "", ""
address_str = str(address).strip()
street = ""
commune = ""
# First, extract commune from parentheses if present
commune_match = re.search(r'\(([^)]+)\)', address_str)
if commune_match:
commune = commune_match.group(1).strip()
# Remove the parentheses part from address for street extraction
address_str = re.sub(r'\s*\([^)]+\)', '', address_str).strip()
else:
# Try to extract commune from comma-separated parts
parts = [p.strip() for p in address_str.split(',')]
if len(parts) > 1:
# Check parts after the first one for commune
for part in parts[1:]:
# Check for postal code pattern (4 digits + space + text)
postal_match = re.match(r'^(\d{4})\s+(.+)$', part)
if postal_match:
commune = postal_match.group(2).strip()
break
# If no postal code found and commune still empty, use first non-first part
if not commune and len(parts) > 1:
commune = parts[1]
# Keep only the first part (rue) for street extraction
address_str = parts[0]
# Extract street: remove leading numbers (e.g., "80 Boulevard..." -> "Boulevard...")
street_match = re.match(r'^(\d+[\s\-]*)?(.+)$', address_str)
if street_match and street_match.group(2):
street = street_match.group(2).strip()
else:
street = address_str
return street, commune
wb = Workbook()
ws = wb.active
ws.title = str(_("Observations"))
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
fixed_headers = [
str(_("Code")),
str(_("Type")),
str(_("Statut")),
str(_("Thématique")),
str(_("Catégorie")),
str(_("Symptôme")),
str(_("Description")),
str(_("Adresse")),
str(_("Rue")),
str(_("Commune")),
str(_("Auteur")),
str(_("Longitude")),
str(_("Latitude")),
str(_("Date de création")),
]
checklist_headers = [item.get_name() for item in checklist_items_ordered]
all_headers = fixed_headers + checklist_headers
for col_idx, header in enumerate(all_headers, start=1):
cell = ws.cell(row=1, column=col_idx, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
ws.auto_filter.ref = f"A1:{get_column_letter(len(all_headers))}1"
for row_idx, obs in enumerate(observations_list, start=2):
response_by_item_id = {
resp.checklist_item_id: resp
for resp in obs.checklist_responses.all()
}
street, commune = _split_address_street_commune(obs.address)
row_data = [
obs.code or f"OBS{obs.id:05d}",
obs.get_observation_type_display(),
obs.get_status_display(),
obs.thematic.name if obs.thematic else "",
obs.category.get_name() if obs.category else "",
obs.symptom.get_name() if obs.symptom else "",
obs.description or "",
obs.address or "",
street,
commune,
get_short_name(obs.created_by) if obs.created_by else "",
obs.longitude if obs.longitude is not None else "",
obs.latitude if obs.latitude is not None else "",
obs.created_at.strftime("%d/%m/%Y %H:%M") if obs.created_at else "",
]
for item in checklist_items_ordered:
resp = response_by_item_id.get(item.id)
row_data.append(_get_translated_value(resp) if resp is not None else "")
for col_idx, value in enumerate(row_data, start=1):
ws.cell(row=row_idx, column=col_idx, value=value if value not in ("", None) else "")
for col_idx, header in enumerate(all_headers, start=1):
col_letter = get_column_letter(col_idx)
max_length = max(len(header), 12)
ws.column_dimensions[col_letter].width = min(max_length + 2, 40)
ws.freeze_panes = "A2"
output = io.BytesIO()
wb.save(output)
output.seek(0)
from datetime import date
filename = f"observations_{date.today().strftime('%Y%m%d')}.xlsx"
response = HttpResponse(
output.read(),
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
@login_required
@observation_object_permission_required(select_related=('thematic', 'category', 'created_by', 'intervention', 'project'))
def observation_detail(request, observation_id):
"""Detail page for a specific observation (desktop and mobile compatible)."""
observation = request.observation_obj
# Capturer l'URL de référence si on vient de la liste des observations
referer = request.META.get('HTTP_REFERER', '')
# Capturer l'URL si on vient de la liste des observations (avec ou sans filtres)
if referer and '/observations/' in referer and not f'/observations/{observation_id}' in referer:
# On vient de la liste (pas d'un refresh de la page détail), stocker l'URL
session_key = f'observation_{observation_id}_return_url'
request.session[session_key] = referer
request.session.modified = True # Force la sauvegarde de la session
# Get photos and comments
photos = observation.photos.all()
comments = observation.comments.select_related('author').order_by('-created_at') if hasattr(observation, 'comments') else []
# Get checklist template and responses if available
checklist_template = None
checklist_items_with_responses = []
if observation.category and observation.observation_type:
try:
checklist_template = ChecklistTemplate.objects.prefetch_related('items').get(
category=observation.category,
observation_type=observation.observation_type,
is_active=True
)
# Get existing responses for this observation
existing_responses = {
r.checklist_item_id: r
for r in observation.checklist_responses.select_related('checklist_item').all()
}
for item in checklist_template.items.order_by('order', 'name_fr'):
response = existing_responses.get(item.id)
checklist_items_with_responses.append({
'item': item,
'response': response,
})
except ChecklistTemplate.DoesNotExist:
pass
# Build GeoJSON for map display
geom_geojson = None
if observation.geom:
geom_4326 = observation.geom.transform(4326, clone=True)
geom_geojson = {
"type": "FeatureCollection",
"features": [{
"type": "Feature",
"geometry": json.loads(geom_4326.geojson),
"properties": {
"description": observation.description or "",
"address": observation.address or "",
}
}]
}
elif observation.latitude and observation.longitude:
geom_geojson = {
"type": "FeatureCollection",
"features": [{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [observation.longitude, observation.latitude]
},
"properties": {
"description": observation.description or "",
"address": observation.address or "",
}
}]
}
user_roles = []
if request.user.is_authenticated and hasattr(request.user, 'config'):
user_roles = list(request.user.config.roles.values_list('name', flat=True))
# Vérifier si l'utilisateur peut modifier l'observation
access_context = request.observation_access
can_edit = (
observation.created_by == request.user or
access_context.user_config.has_role('admin') or
access_context.user_config.has_role('top_manager')
)
# Vérifier si l'utilisateur peut supprimer l'observation
can_delete = (
observation.status in ('in_preparation', 'to_process', 'needs_info') and
can_edit
)
# Récupérer toutes les interventions associées (directes, sources, duplications/réparations)
from interventions.permissions import can_view_intervention, can_view_intervention_summary
associated_interventions = observation.get_associated_interventions()
visible_interventions = []
for itv in associated_interventions:
if can_view_intervention(request.user, itv):
visible_interventions.append({
'intervention': itv,
'can_view_detail': can_view_intervention(request.user, itv),
'can_view_summary': can_view_intervention_summary(request.user, itv),
'is_primary': (itv.id == observation.intervention_id),
})
# Vérifier les permissions pour l'intervention liée principale
can_view_intervention_detail = False
can_view_intervention_summary_modal = False
if observation.intervention:
can_view_intervention_detail = can_view_intervention(request.user, observation.intervention)
can_view_intervention_summary_modal = can_view_intervention_summary(request.user, observation.intervention)
# Vérifier les permissions pour lier l'observation à une intervention / un projet
can_link = any(access_context.user_config.has_role(r) for r in ['admin', 'operator', 'manager', 'controller', 'top_manager'])
# Vérifier si l'utilisateur est le créateur de l'observation
is_creator = (observation.created_by == request.user)
# Vérifier si l'utilisateur peut prendre une action sur l'observation :
# admins, operators, gestionnaires d'assets pour la catégorie, ou via can_process_observations
can_take_action = False
if access_context.can_process_observation(observation):
can_take_action = True
elif observation.category_id:
try:
from contracts.models import CompanyMember
is_assets_manager = CompanyMember.objects.filter(
user=request.user,
teams__is_assets_manager=True,
teams__asset_categories=observation.category,
).exists()
if is_assets_manager:
# Règle prioritaire : si le symptôme a des équipes assignées,
# l'utilisateur doit en faire partie.
if observation.symptom_id:
from interventions.models import Symptom
has_symptom_teams = Symptom.objects.filter(
pk=observation.symptom_id, teams__isnull=False
).exists()
if has_symptom_teams:
can_take_action = CompanyMember.objects.filter(
user=request.user,
teams__symptoms__id=observation.symptom_id,
).exists()
else:
can_take_action = True
else:
can_take_action = True
except Exception:
pass
# Discussion liée à cette observation
discussion = None
discussion_messages = []
can_post_discussion_message = False
try:
from django.contrib.contenttypes.models import ContentType
from notifications.models import Discussion
from notifications.services import mark_discussion_seen, can_post_observation_message
ct = ContentType.objects.get_for_model(observation)
discussion = Discussion.objects.filter(
content_type=ct, object_id=observation.pk
).prefetch_related('messages__attachments', 'participants').first()
if discussion:
discussion_messages = list(
discussion.messages.filter(is_deleted=False)
.prefetch_related('attachments')
.order_by('created_at')
)
# Marquer les notifications de l'utilisateur courant comme lues
mark_discussion_seen(discussion, user=request.user)
can_post_discussion_message = can_take_action or can_post_observation_message(request.user, observation)
except Exception:
pass
# Build symptoms list for autocomplete (used in modal "à rediriger")
detail_symptoms_autocomplete = []
if can_take_action:
from interventions.models import Symptom as _Symptom
lang = getattr(request, 'LANGUAGE_CODE', 'fr')
symptoms_qs = (
_Symptom.objects
.filter(is_active=True)
.prefetch_related('asset_categories')
.select_related('thematic')
)
if lang and lang.startswith('nl'):
symptoms_qs = symptoms_qs.order_by('name_nl', 'thematic__id')
else:
symptoms_qs = symptoms_qs.order_by('name_fr', 'thematic__id')
for s in symptoms_qs:
display_name = (s.name_nl if (lang and lang.startswith('nl')) else s.name_fr) or ''
category_ids = list(s.asset_categories.values_list('id', flat=True))
detail_symptoms_autocomplete.append({
'id': s.id,
'value': display_name,
'label': f"{display_name} — {s.thematic.name}",
'code': s.code or '',
'thematic_id': s.thematic_id,
'thematic_code': s.thematic.code,
'category_ids': category_ids,
})
context = {
'observation': observation,
'photos': photos,
'geom_geojson': geom_geojson,
'user_roles': user_roles,
'comments': comments,
'checklist_template': checklist_template,
'checklist_items_with_responses': checklist_items_with_responses,
'can_edit': can_edit,
'can_delete': can_delete,
'can_link': can_link,
'can_take_action': can_take_action,
'is_creator': is_creator,
'can_view_intervention_detail': can_view_intervention_detail,
'can_view_intervention_summary_modal': can_view_intervention_summary_modal,
'visible_interventions': visible_interventions,
'discussion': discussion,
'discussion_messages': discussion_messages,
'can_post_discussion_message': can_post_discussion_message,
'detail_symptoms_autocomplete': detail_symptoms_autocomplete,
}
# Detect if mobile request
is_mobile = '/mobile/' in request.path
template = 'observations/observation_detail_mobile.html' if is_mobile else 'observations/observation_detail.html'
return render(request, template, context)
# --- Changement de statut ---
@login_required
@observation_object_permission_required(select_related=('thematic', 'category', 'created_by'))
def observation_change_status(request, observation_id):
observation = request.observation_obj
new_status = request.POST.get('new_status')
allowed_statuses = ['to_process', 'processed', 'not_relevant', 'to_redirect', 'duplicate', 'needs_info', 'closed', 'archived']
if new_status not in allowed_statuses:
return HttpResponseForbidden("Statut non autorisé.")
referer = request.META.get('HTTP_REFERER', '')
is_mobile = '/mobile/' in referer
redirect_target = 'mobile:observation_detail_mobile' if is_mobile else 'observations:observation_detail'
# Règles de transition vers 'to_process'
if new_status == 'to_process':
if observation.status == 'in_preparation':
pass
elif observation.status == 'needs_info':
# Autorisé pour le créateur ou les gestionnaires habilités
is_creator = (observation.created_by == request.user)
access_context = request.observation_access
can_manage = access_context.can_process_observation(observation) or any(
access_context.user_config.has_role(r) for r in ['admin', 'operator', 'manager', 'controller', 'top_manager']
)
if not (is_creator or can_manage):
return HttpResponseForbidden("Vous n'avez pas la permission d'effectuer cette action.")
else:
return HttpResponseForbidden("Transition non autorisée.")
# Contrôle des rôles/permissions pour 'processed' (tous types)
if new_status == 'processed':
access_context = request.observation_access
if not access_context.can_process_observation(observation):
# Vérifier aussi les rôles manager/controller comme fallback
fallback_roles = ['manager', 'controller']
if not any(access_context.user_config.has_role(role) for role in fallback_roles):
return HttpResponseForbidden("Vous n'avez pas la permission de traiter cette observation.")
# Contrôle des rôles pour 'not_relevant', 'to_redirect', 'duplicate' et 'needs_info'
if new_status in ('not_relevant', 'to_redirect', 'duplicate', 'needs_info'):
if new_status == 'not_relevant':
if observation.status not in ('to_process', 'needs_info'):
return HttpResponseForbidden("Transition non autorisée.")
elif observation.status != 'to_process':
return HttpResponseForbidden("Transition non autorisée.")
access_context = request.observation_access
if not access_context.can_process_observation(observation):
fallback_roles = ['manager', 'controller', 'operator', 'admin']
if not any(access_context.user_config.has_role(role) for role in fallback_roles):
return HttpResponseForbidden("Vous n'avez pas la permission d'effectuer cette action.")
# Pour 'needs_info' : le motif / commentaire est obligatoire
if new_status == 'needs_info':
comment_text = (request.POST.get('comment') or '').strip()
if not comment_text:
messages.error(request, _("Veuillez préciser les informations complémentaires demandées."))
return redirect(redirect_target, observation_id=observation.id)
# Pour "à rediriger" avec choix d'un nouveau symptôme : mettre à jour symptôme/catégorie/thématique
# Dans ce cas, le statut n'est PAS modifié et un commentaire automatique est ajouté.
symptom_changed = False
if new_status == 'to_redirect':
symptom_id = request.POST.get('symptom_id', '').strip()
if symptom_id:
try:
new_symptom = (
Symptom.objects
.prefetch_related('asset_categories')
.select_related('thematic')
.get(pk=symptom_id, is_active=True)
)
old_symptom_name = str(observation.symptom) if observation.symptom else _("(aucun)")
observation.symptom = new_symptom
observation.thematic = new_symptom.thematic
first_category = new_symptom.asset_categories.first()
if first_category:
observation.category = first_category
# Commentaire automatique
lang = getattr(request, 'LANGUAGE_CODE', 'fr')
new_symptom_name = (new_symptom.name_nl if (lang and lang.startswith('nl')) else new_symptom.name_fr) or str(new_symptom)
auto_comment = _("Symptôme modifié : %(old)s → %(new)s") % {
'old': old_symptom_name,
'new': new_symptom_name,
}
ObservationComment.objects.create(
observation=observation,
author=request.user if request.user.is_authenticated else None,
comment=auto_comment,
)
symptom_changed = True
new_status = observation.status # conserver le statut actuel
except Symptom.DoesNotExist:
pass
# Enregistrer un commentaire optionnel (pour tous les autres changements de statut)
if not symptom_changed:
comment_text = (request.POST.get('comment') or '').strip()
if comment_text:
ObservationComment.objects.create(
observation=observation,
author=request.user if request.user.is_authenticated else None,
comment=comment_text,
)
# Si passage en needs_info : publier aussi dans la discussion pour alerter l'auteur
if new_status == 'needs_info':
from notifications.services import get_or_create_observation_discussion, post_message
discussion, _ = get_or_create_observation_discussion(observation)
post_message(
discussion=discussion,
author=request.user,
content=_("Demande d'informations complémentaires :\n%(comment)s") % {'comment': comment_text}
)
messages.success(request, _("La demande d'informations a été envoyée à l'observateur."))
# Si retour à to_process depuis needs_info :
elif observation.status == 'needs_info' and new_status == 'to_process':
res_content = (
_("Compléments d'information fournis :\n%(comment)s") % {'comment': comment_text}
if comment_text else
_("Compléments d'information fournis. L'observation est à nouveau à traiter.")
)
from notifications.services import get_or_create_observation_discussion, post_message
discussion, _ = get_or_create_observation_discussion(observation)
post_message(
discussion=discussion,
author=request.user,
content=res_content
)
messages.success(request, _("L'observation a été renvoyée pour traitement."))
# Archiver seulement depuis 'processed', 'not_relevant', 'to_redirect', 'duplicate' ou 'needs_info'
if new_status == 'archived' and observation.status not in ('processed', 'not_relevant', 'to_redirect', 'duplicate', 'needs_info'):
return HttpResponseForbidden("Seules les observations traitées, non relevantes, à rediriger, en attente d'infos ou doublons peuvent être archivées.")
observation.status = new_status
observation.save()
# Rediriger vers la vue mobile ou desktop selon l'origine de la requête
if is_mobile:
return redirect('mobile:observation_detail_mobile', observation_id=observation.id)
return redirect('observations:observation_detail', observation_id=observation.id)
# --- Suppression d'une observation ---
@login_required
@observation_object_permission_required()
def observation_delete(request, observation_id):
observation = request.observation_obj
access_context = request.observation_access
if observation.status not in ('in_preparation', 'to_process'):
return HttpResponseForbidden(_("Seules les observations en préparation ou à traiter peuvent être supprimées."))
if observation.created_by != request.user and not access_context.user_config.has_role('admin'):
return HttpResponseForbidden(_("Vous n'avez pas la permission de supprimer cette observation."))
if request.method == 'POST':
referer = request.META.get('HTTP_REFERER', '')
observation.delete()
messages.success(request, _("L'observation a été supprimée."))
if '/mobile/' in referer:
return redirect('mobile:observations_index_mobile')
return redirect('observations:observations_index')
return HttpResponseForbidden("Méthode non autorisée.")
def is_intervention_creator(user):
if not hasattr(user, 'config'):
return False
allowed_roles = ['admin', 'operator', 'manager', 'controller', 'top_manager']
return any(user.config.has_role(role) for role in allowed_roles)
@login_required
@observation_object_permission_required(select_related=('thematic', 'category', 'created_by', 'intervention', 'symptom'))
def create_intervention_from_observation(request, observation_id):
observation = request.observation_obj
# Pour les observations de type inventaire ou remarque, l'utilisateur choisit le type de maintenance
maintain_type = request.POST.get('maintain_type', 'corrective')
if maintain_type not in ('corrective', 'ameliorative'):
maintain_type = 'corrective'
if maintain_type == 'ameliorative':
title = f"Intervention améliorative liée à l'observation {observation.id}"
intervention_type = 'repair'
else:
title = f"Intervention corrective liée à l'observation {observation.id}"
intervention_type = 'fixing'
# Récupère la catégorie source OBSERVATION
from interventions.models import SourceCategory
source_category = SourceCategory.objects.filter(name_fr__iexact='OBSERVATION').first()
# Prépare les données initiales pour le formulaire Intervention
# Si la catégorie de l'observation est une sous-catégorie, passer la catégorie parente
# comme asset_category et l'ID de la sous-catégorie séparément.
obs_category = observation.category
if obs_category and obs_category.parent_id:
asset_category_for_interv = obs_category.parent_id # int — pas d'objet pour éviter une requête
interv_sub_category_id = obs_category.id
else:
asset_category_for_interv = obs_category
interv_sub_category_id = None
initial_data = {
'title': title,
'lat': observation.latitude,
'lon': observation.longitude,
'address': observation.address,
'asset_category': asset_category_for_interv,
'interv_sub_category_id': interv_sub_category_id,
'symptom': observation.symptom,
'thematic': observation.thematic,
'init_description': observation.description,
'maintain_type': maintain_type,
'type': intervention_type,
'status': 'in_preparation',
'source_category': source_category,
'source_ref': str(observation.id),
'source_detail': observation.description,
}
# Redirige vers le formulaire de création d'une intervention corrective avec les données pré-remplies
url = reverse('interventions:intervention_add', kwargs={'thematic_code': observation.thematic.code})
params = {k: v for k, v in initial_data.items() if v is not None}
# Pour les objets, on passe l'id si possible
for k, v in params.items():
if hasattr(v, 'id'):
params[k] = v.id
# Ajoute la référence à l'observation
params['observation_id'] = observation.id
query = urlencode(params)
return redirect(f"{url}?{query}")
@login_required
@observation_access_required
def observation_add(request, observation_id=None):
access_context = request.observation_access
# Mode édition: charger l'observation existante
observation_to_edit = None
if observation_id:
try:
observation_to_edit = Observation.objects.get(id=observation_id)
# Vérifier que l'utilisateur peut modifier cette observation
if observation_to_edit.created_by != request.user and not access_context.user_config.has_role('admin'):
messages.error(request, "Vous n'avez pas le droit de modifier cette observation.")
return redirect('observations:observation_detail', observation_id=observation_id)
except Observation.DoesNotExist:
messages.error(request, "Observation introuvable.")
return redirect('observations:observations_index')
if request.method == 'POST':
if observation_to_edit:
obs_form = ObservationForm(request.POST, instance=observation_to_edit)
else:
obs_form = ObservationForm(request.POST)
files = request.FILES.getlist('photos')
if obs_form.is_valid():
observation = obs_form.save(commit=False)
# Vérification de la thématique
if not _user_can_access_thematic(access_context, observation.thematic_id):
obs_form.add_error('thematic', "Vous n'avez pas accès à cette thématique.")
else:
# Vérification des photos
has_new_photos = bool(files)
if observation_to_edit:
# En mode édition : vérifier qu'il reste au moins une photo
delete_photo_ids = request.POST.getlist('delete_photos')
existing_photos_count = ObservationPhoto.objects.filter(observation=observation_to_edit).count()
if delete_photo_ids:
photos_to_delete = len([pid for pid in delete_photo_ids if pid.isdigit()])
remaining_photos = existing_photos_count - photos_to_delete
else:
remaining_photos = existing_photos_count
# Il faut au moins une photo (existante ou nouvelle)
if remaining_photos == 0 and not has_new_photos:
obs_form.add_error(None, "Vous devez conserver ou ajouter au moins une photo.")
else:
# Nouvelle observation : au moins une photo requise
if not has_new_photos:
obs_form.add_error(None, "Vous devez ajouter au moins une photo.")
# Si pas d'erreur, continuer avec la sauvegarde
if not obs_form.errors:
if not observation_to_edit: # Nouvelle observation
observation.created_by = request.user
# Gestion du statut selon le bouton utilisé
previous_status = observation_to_edit.status if observation_to_edit else None
if 'save' in request.POST:
observation.status = 'in_preparation'
elif 'send' in request.POST:
observation.status = 'to_process'
else:
observation.status = observation.status if observation_to_edit else 'in_preparation'
wgs84_point = Point(observation.longitude, observation.latitude, srid=4326)
wgs84_point.transform(3812) # Modifie l'objet en place
observation.geom = wgs84_point
observation.save()
# En mode édition, supprimer les photos marquées pour suppression
if observation_to_edit:
delete_photo_ids = request.POST.getlist('delete_photos')
if delete_photo_ids:
ObservationPhoto.objects.filter(
observation=observation,
id__in=[int(pid) for pid in delete_photo_ids if pid.isdigit()]
).delete()
# Ajouter les nouvelles photos
for f in files:
ObservationPhoto.objects.create(observation=observation, photo=f)
# Process checklist data if present
checklist_data_str = request.POST.get('checklist_data', '')
if checklist_data_str:
try:
checklist_responses = json.loads(checklist_data_str)
# En mode édition, supprimer les anciennes réponses
if observation_to_edit:
ObservationChecklistResponse.objects.filter(observation=observation).delete()
for response_data in checklist_responses:
item_id = response_data.get('item_id')
if not item_id:
continue
try:
checklist_item = ChecklistItem.objects.get(id=item_id)
except ChecklistItem.DoesNotExist:
continue
# Create the response
checklist_response = ObservationChecklistResponse(
observation=observation,
checklist_item=checklist_item,
comment=response_data.get('comment', '')
)
# Set value based on item type
item_type = checklist_item.item_type
value = response_data.get('value')
if item_type == 'boolean':
if value is not None:
checklist_response.value_boolean = value in [True, 'true', 'True', '1', 1]
else:
checklist_response.value_boolean = None
elif item_type == 'text':
checklist_response.value_text = value or ''
elif item_type == 'number':
if value is not None and value != '':
try:
checklist_response.value_number = float(value)
except (ValueError, TypeError):
checklist_response.value_number = None
else:
checklist_response.value_number = None
elif item_type == 'choice':
checklist_response.value_choice = value or ''
elif item_type == 'choice_multiple':
# Support for multiple choice values
if isinstance(value, list):
checklist_response.value_choices = value
elif isinstance(value, str) and value:
# If comma-separated string, convert to list
checklist_response.value_choices = [v.strip() for v in value.split(',')]
else:
checklist_response.value_choices = []
checklist_response.save()
except json.JSONDecodeError:
pass # Ignore invalid JSON
if observation_to_edit and previous_status == 'needs_info' and observation.status == 'to_process':
ObservationComment.objects.create(
observation=observation,
author=request.user,
comment=_("Observation modifiée et renvoyée par l'auteur."),
)
from notifications.services import get_or_create_observation_discussion, post_message
discussion, _ = get_or_create_observation_discussion(observation)
post_message(
discussion=discussion,
author=request.user,
content=_("L'observation a été modifiée et renvoyée par l'auteur.")
)
# After creation/update, redirect to detail
messages.success(request, "Observation enregistrée avec succès.")
# Check if request came from mobile URL pattern (/mobile/observations/...)
is_mobile = '/mobile/' in request.path
if is_mobile:
return redirect('mobile:observation_detail_mobile', observation_id=observation.id)
return redirect('observations:observation_detail', observation_id=observation.id)
else:
if observation_to_edit:
obs_form = ObservationForm(instance=observation_to_edit)
else:
obs_form = ObservationForm()
# Get current language
lang = get_language()
order_field = f'name_{lang}' if lang == 'nl' else 'name_fr'
# Get all thematics with their icons for the template, sorted by current language
if access_context.user_config.has_role('admin') or access_context.user_config.has_role('top_manager'):
thematics = Thematic.objects.all().order_by(order_field)
else:
thematics = Thematic.objects.filter(id__in=access_context.thematic_ids).order_by(order_field)
thematics_data = [
{
'id': t.id,
'code': t.code,
'name': t.name,
'icon': t.icon or '',
}
for t in thematics
]
# Build symptoms list for autocomplete in the observation form
symptoms_qs = (
Symptom.objects
.filter(is_active=True, thematic__in=thematics)
.prefetch_related('asset_categories')
.select_related('thematic')
)
if lang and lang.startswith('nl'):
symptoms_qs = symptoms_qs.order_by('name_nl', 'thematic__id')
else:
symptoms_qs = symptoms_qs.order_by('name_fr', 'thematic__id')
symptoms_autocomplete = []
for s in symptoms_qs:
display_name = (s.name_nl if (lang and lang.startswith('nl')) else s.name_fr) or ''
category_ids = list(s.asset_categories.values_list('id', flat=True))
symptoms_autocomplete.append({
'id': s.id,
'value': display_name,
'label': f"{display_name} — {s.thematic.name}",
'code': s.code or '',
'thematic_id': s.thematic_id,
'thematic_code': s.thematic.code,
'category_ids': category_ids,
})
# Définir les raccourcis pour les observateurs
observer_shortcuts = [
{
'id': 'shortcut-small-pole-damaged',
'title': _('Potelet endommagé'),
'icon_type': 'file', # 'file' or 'bootstrap'
'icon': 'observations/potelet.png', # chemin du fichier ou classe bootstrap
'thematic_code': 'roads',
'category_search': ['RD_STREET_FURNITURE', 'STREET_FURNITURE'],
'observation_type': 'incident', # optionnel
'symptom': 'URBAN_FURNITURE_POLE_DAMAGED', # optionnel, utilisé pour pré-remplir le champ symptôme
},
{
'id': 'shortcut-materials',
'title': _('Matériaux abandonnés'),
'icon_type': 'file',
'icon': 'observations/palette.png',
'thematic_code': 'clean',
'category_search': ['CL_MATERIALS', 'MATERIALS'],
'observation_type': 'incident',
'symptom': 'CL_ABANDONED_MATERIALS',
},
{
'id': 'shortcut-pothole',
'title': _('Nid-de-poule'),
'icon_type': 'file',
'icon': 'observations/pothole.jpg',
'thematic_code': 'roads',
'category_search': ['RD_ROADWAY', 'ROADWAY'],
'observation_type': 'incident',
'symptom': 'RD_POTHOLE',
},
{
'id': 'shortcut-panel-missing',
'title': _('Panneau manquant'),
'icon_type': 'file',
'icon': 'observations/missing_road_sign.png',
'thematic_code': 'sign',
'category_search': ['SI_PANEL', 'PANEL'],
'observation_type': 'incident',
'symptom': 'SI_PANEL_MISSING',
},
{
'id': 'shortcut-drain-blocked',
'title': _('Avaloir'),
'icon_type': 'file',
'icon': 'observations/avaloir.jpg',
'thematic_code': 'clean',
'category_search': ['CL_DRAIN', 'DRAIN'],
'observation_type': 'incident',
'symptom': 'CL_DRAIN_BLOCKED',
},
{
'id': 'shortcut-taxi',
'title': _('Emplacement taxi'),
'icon_type': 'file',
'icon': 'observations/taxi-stop.svg',
'thematic_code': 'sign',
'category_search': ['TAXI', 'SIGN_TAXI'],
'observation_type': 'inventory',
},
]
# Définir les raccourcis pour les éditeurs
editor_shortcuts = [
{
'id': 'shortcut-street-furniture',
'title': _('Mobilier urbain'),
'icon_type': 'bootstrap',
'icon': 'bi-ticket',
'thematic_code': 'roads',
'category_search': ['RD_STREET_FURNITURE', 'MOBILIER', 'MEUBILAIR', 'FURNITURE'],
'observation_type': 'inventory',
},
]
# Préparer les données de l'observation existante pour le template
observation_data = None
existing_photos = []
existing_checklist_responses = []
if observation_to_edit:
# Convertir les coordonnées Lambert vers WGS84 pour l'affichage
if observation_to_edit.geom:
wgs84_geom = observation_to_edit.geom.clone()
wgs84_geom.transform(4326)
latitude = wgs84_geom.y
longitude = wgs84_geom.x
else:
latitude = None
longitude = None
observation_data = {
'id': observation_to_edit.id,
'thematic_id': observation_to_edit.thematic_id,
'category_id': observation_to_edit.category_id,
'observation_type': observation_to_edit.observation_type,
'symptom_id': observation_to_edit.symptom_id,
'description': observation_to_edit.description,
'address': observation_to_edit.address,
'latitude': latitude,
'longitude': longitude,
}
# Charger les photos existantes
existing_photos = list(ObservationPhoto.objects.filter(observation=observation_to_edit))
# Charger les réponses du checklist existantes
responses = ObservationChecklistResponse.objects.filter(
observation=observation_to_edit
).select_related('checklist_item')
for response in responses:
item = response.checklist_item
value = None
if item.item_type == 'boolean':
value = response.value_boolean
elif item.item_type == 'text':
value = response.value_text
elif item.item_type == 'number':
value = float(response.value_number) if response.value_number is not None else None
elif item.item_type == 'choice':
value = response.value_choice
elif item.item_type == 'choice_multiple':
value = response.value_choices
existing_checklist_responses.append({
'item_id': item.id,
'value': value,
'comment': response.comment
})
elif request.method == 'POST':
# Lors d'une erreur de validation sur une nouvelle observation, on re-transmet
# les données saisies au template pour que le JS puisse restaurer la carte,
# la catégorie, le symptôme, l'adresse, etc.
def _safe_int(val):
try:
return int(val) if val not in (None, '') else None
except (ValueError, TypeError):
return None
def _safe_float(val):
try:
return float(val) if val not in (None, '') else None
except (ValueError, TypeError):
return None
observation_data = {
'id': None,
'thematic_id': _safe_int(request.POST.get('thematic')),
'category_id': _safe_int(request.POST.get('category')),
'observation_type': request.POST.get('observation_type'),
'symptom_id': _safe_int(request.POST.get('symptom')),
'description': request.POST.get('description', ''),
'address': request.POST.get('address', ''),
'latitude': _safe_float(request.POST.get('latitude')),
'longitude': _safe_float(request.POST.get('longitude')),
}
# Convertir les raccourcis en format JSON-serializable (convertir les lazy strings)
def make_serializable(shortcuts):
return [
{
'id': s['id'],
'title': str(s['title']), # Convertir les lazy strings en chaînes normales
'icon_type': s['icon_type'],
'icon': s['icon'],
'thematic_code': s['thematic_code'],
'category_search': s['category_search'],
'observation_type': s.get('observation_type'),
'symptom': s.get('symptom'), # Ajouter le symptôme
}
for s in shortcuts
]
return render(request, 'observations/observation_form.html', {
'obs_form': obs_form,
'thematics_data': thematics_data,
'is_observer': access_context.user_config.roles.filter(name__in=['admin', 'observer', 'top_manager']).exists(),
'is_editor': access_context.user_config.roles.filter(name__in=['admin', 'editor', 'top_manager']).exists(),
'observer_shortcuts': observer_shortcuts,
'editor_shortcuts': editor_shortcuts,
'observer_shortcuts_json': json.dumps(make_serializable(observer_shortcuts)),
'editor_shortcuts_json': json.dumps(make_serializable(editor_shortcuts)),
'observation_data_json': json.dumps(observation_data) if observation_data else 'null',
'existing_photos_json': json.dumps([{
'id': p.id,
'url': build_private_url('observations', 'ObservationPhoto', p.pk, 'photo'),
'thumb_url': build_private_url('observations', 'ObservationPhoto', p.pk, 'thumbnail') if p.thumbnail else build_private_url('observations', 'ObservationPhoto', p.pk, 'photo'),
} for p in existing_photos]),
'existing_checklist_responses_json': json.dumps(existing_checklist_responses),
'is_edit_mode': observation_to_edit is not None,
'symptoms_autocomplete': symptoms_autocomplete,
})
@login_required
def observation_success(request):
return render(request, 'observations/observation_success.html')
@require_GET
@login_required
@observation_access_required
def get_categories_by_thematic(request):
"""AJAX endpoint to get categories filtered by thematic."""
access_context = request.observation_access
thematic_id = request.GET.get('thematic_id')
if not thematic_id:
return JsonResponse({'categories': []})
try:
thematic_id_int = int(thematic_id)
except (TypeError, ValueError):
return JsonResponse({'categories': []})
if not _user_can_access_thematic(access_context, thematic_id_int):
return JsonResponse({'categories': []})
try:
thematic = Thematic.objects.get(id=thematic_id_int)
lang = get_language()
order_field = f'name_{lang}' if lang == 'nl' else 'name_fr'
categories = AssetCategory.objects.filter(thematic=thematic).order_by(order_field)
category_list = [
{
'id': cat.id,
'name': cat.name_nl if lang == 'nl' else cat.name_fr,
'code': cat.code,
'parent_id': cat.parent_id,
}
for cat in categories
]
return JsonResponse({'categories': category_list})
except Thematic.DoesNotExist:
return JsonResponse({'categories': []})
@require_GET
@login_required
@observation_access_required
def get_subcategories_by_category(request):
"""AJAX endpoint to get subcategories (children) of a given category."""
access_context = request.observation_access
category_id = request.GET.get('category_id')
if not category_id:
return JsonResponse({'subcategories': []})
try:
category = AssetCategory.objects.select_related('thematic').get(id=category_id)
if not _user_can_access_thematic(access_context, category.thematic_id):
return JsonResponse({'subcategories': []})
lang = get_language()
order_field = f'name_{lang}' if lang == 'nl' else 'name_fr'
children = category.get_children().order_by(order_field)
subcategory_list = [
{
'id': child.id,
'name': child.name_nl if lang == 'nl' else child.name_fr,
'code': child.code,
}
for child in children
]
return JsonResponse({'subcategories': subcategory_list})
except AssetCategory.DoesNotExist:
return JsonResponse({'subcategories': []})
@require_GET
@staff_member_required
def get_categories_by_thematic_admin(request):
"""AJAX endpoint for admin to get categories filtered by thematic (staff only)."""
thematic_id = request.GET.get('thematic_id')
if not thematic_id:
return JsonResponse({'categories': []})
try:
thematic_id_int = int(thematic_id)
thematic = Thematic.objects.get(id=thematic_id_int)
lang = get_language()
order_field = f'name_{lang}' if lang == 'nl' else 'name_fr'
categories = AssetCategory.objects.filter(thematic=thematic).order_by(order_field)
category_list = [
{
'id': cat.id,
'name': cat.name_nl if lang == 'nl' else cat.name_fr,
'parent_id': cat.parent_id,
}
for cat in categories
]
return JsonResponse({'categories': category_list})
except (TypeError, ValueError, Thematic.DoesNotExist):
return JsonResponse({'categories': []})
@require_GET
@login_required
@observation_access_required
def get_symptoms_by_category(request):
"""AJAX endpoint to get symptoms filtered by category."""
access_context = request.observation_access
category_id = request.GET.get('category_id')
if not category_id:
return JsonResponse({'symptoms': []})
try:
category = AssetCategory.objects.select_related('thematic').get(id=category_id)
if not _user_can_access_thematic(access_context, category.thematic_id):
return JsonResponse({'symptoms': []})
lang = get_language()
order_field = f'name_{lang}' if lang == 'nl' else 'name_fr'
# Filtrer uniquement les symptômes liés à cette catégorie précise.
# Si l'utilisateur a choisi une sous-catégorie, seuls les symptômes
# explicitement liés à cette sous-catégorie sont affichés.
symptoms = Symptom.objects.filter(
asset_categories=category,
is_active=True
).distinct().order_by(order_field)
symptom_list = [
{
'id': symp.id,
'name': symp.name_nl if lang == 'nl' else symp.name_fr,
'code': symp.code
}
for symp in symptoms
]
return JsonResponse({'symptoms': symptom_list})
except AssetCategory.DoesNotExist:
return JsonResponse({'symptoms': []})
# ============================================================================
# Checklist Views
# ============================================================================
@require_GET
@login_required
@observation_access_required
def get_checklist_template(request):
"""
AJAX endpoint to get checklist template items for a given category and observation type.
Returns the template with its items if one exists.
"""
category_id = request.GET.get('category_id')
observation_type = request.GET.get('observation_type', 'inventory')
if not category_id:
return JsonResponse({'has_checklist': False, 'items': []})
try:
template = ChecklistTemplate.objects.prefetch_related('items').get(
category_id=category_id,
observation_type=observation_type,
is_active=True
)
lang = get_language()
items = []
for item in template.items.select_related('choice_set').order_by('order', 'name_fr'):
# Pour les choix, retourner les clés et toutes les métadonnées (icône, couleur, image)
choices_with_keys = item.get_choices_list_with_keys()
items.append({
'id': item.id,
'name': item.get_name(),
'description': item.get_description(),
'item_type': item.item_type,
'choices': [c['label'] for c in choices_with_keys], # Pour compatibilité
'choices_with_keys': choices_with_keys, # Format complet avec icônes, couleurs, images
'is_required': item.is_required,
'is_multiple': item.is_multiple_choice(),
'order': item.order,
})
return JsonResponse({
'has_checklist': True,
'template_id': template.id,
'template_name': template.get_name(),
'items': items
})
except ChecklistTemplate.DoesNotExist:
return JsonResponse({'has_checklist': False, 'items': []})
@require_POST
@login_required
@observation_object_permission_required()
def save_checklist_responses(request, observation_id):
"""
Save checklist responses for an observation.
Expects JSON body with responses array.
"""
observation = request.observation_obj
try:
data = json.loads(request.body)
responses = data.get('responses', [])
saved_count = 0
for response_data in responses:
item_id = response_data.get('item_id')
if not item_id:
continue
try:
checklist_item = ChecklistItem.objects.get(id=item_id)
except ChecklistItem.DoesNotExist:
continue
# Get or create the response
checklist_response, created = ObservationChecklistResponse.objects.get_or_create(
observation=observation,
checklist_item=checklist_item,
defaults={'comment': response_data.get('comment', '')}
)
# Update values based on item type
item_type = checklist_item.item_type
value = response_data.get('value')
if item_type == 'boolean':
if value is not None:
checklist_response.value_boolean = value in [True, 'true', 'True', '1', 1]
else:
checklist_response.value_boolean = None
elif item_type == 'text':
checklist_response.value_text = value or ''
elif item_type == 'number':
if value is not None and value != '':
try:
checklist_response.value_number = float(value)
except (ValueError, TypeError):
checklist_response.value_number = None
else:
checklist_response.value_number = None
elif item_type == 'choice':
checklist_response.value_choice = value or ''
elif item_type == 'choice_multiple':
# Support for multiple choice values
if isinstance(value, list):
checklist_response.value_choices = value
elif isinstance(value, str) and value:
checklist_response.value_choices = [v.strip() for v in value.split(',')]
else:
checklist_response.value_choices = []
checklist_response.comment = response_data.get('comment', '')
checklist_response.save()
saved_count += 1
return JsonResponse({
'success': True,
'saved_count': saved_count,
'message': f'{saved_count} réponse(s) enregistrée(s)'
})
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'message': 'Format JSON invalide'
}, status=400)
except Exception as e:
return JsonResponse({
'success': False,
'message': str(e)
}, status=500)
@require_GET
@login_required
@observation_access_required
def observations_nearby_geojson(request):
"""
Return observations as GeoJSON within a bounding box.
Used for displaying existing observations on the observation form map.
Excludes archived observations.
"""
access_context = request.observation_access
# Get filter parameters
thematic_id = request.GET.get('thematic')
category_id = request.GET.get('category')
exclude_id = request.GET.get('exclude_id')
observation_type = request.GET.get('observation_type', '')
# Get bounding box parameters
try:
west = float(request.GET.get('west', 0))
south = float(request.GET.get('south', 0))
east = float(request.GET.get('east', 0))
north = float(request.GET.get('north', 0))
except (TypeError, ValueError):
return JsonResponse({
'type': 'FeatureCollection',
'features': []
})
# Start with base queryset
qs = Observation.objects.select_related('thematic', 'category')
# Exclude archived observations
qs = qs.exclude(status='archived')
# Filter by thematic if provided
if thematic_id:
qs = qs.filter(thematic_id=thematic_id)
# Filter by category if provided
if category_id:
qs = qs.filter(category_id=category_id)
# Exclude a specific observation (e.g. the current one on the detail page)
if exclude_id:
try:
qs = qs.exclude(id=int(exclude_id))
except (TypeError, ValueError):
pass
# Filter by observation type group:
# 'inventory' only matches 'inventory'; 'incident'/'remark' only match each other.
if observation_type == 'inventory':
qs = qs.filter(observation_type='inventory')
elif observation_type in ('incident', 'remark'):
qs = qs.filter(observation_type__in=('incident', 'remark'))
# Filter by bounding box
qs = qs.filter(
longitude__gte=west,
longitude__lte=east,
latitude__gte=south,
latitude__lte=north
)
# Apply access control
qs = access_context.filter_queryset(qs)
# Limit to recent observations (max 200)
qs = qs.order_by('-created_at')[:200]
# Build GeoJSON features
features = []
for obs in qs:
if obs.longitude is not None and obs.latitude is not None:
features.append({
'type': 'Feature',
'id': obs.id,
'geometry': {
'type': 'Point',
'coordinates': [float(obs.longitude), float(obs.latitude)]
},
'properties': {
'id': obs.id,
'status': obs.status,
'status_label': obs.get_status_display(),
'description': (obs.description or '')[:100], # Truncate for performance
'observation_type': obs.observation_type,
'created_at': obs.created_at.isoformat() if obs.created_at else ''
}
})
return JsonResponse({
'type': 'FeatureCollection',
'features': features
})
@login_required
@observation_access_required
def taxi_stands_geojson(request):
"""
Return SignParkingSpot of type 'taxi_stand' as GeoJSON within a bounding box.
Used for displaying taxi stands on the observation form map when category is SI_TAXI.
"""
try:
west = float(request.GET.get('west', 0))
south = float(request.GET.get('south', 0))
east = float(request.GET.get('east', 0))
north = float(request.GET.get('north', 0))
except (TypeError, ValueError):
return JsonResponse({'type': 'FeatureCollection', 'features': []})
from assets.models.sign import SignParkingSpot
qs = SignParkingSpot.objects.filter(
spot_type='taxi_stand',
status='active',
lon__gte=west,
lon__lte=east,
lat__gte=south,
lat__lte=north,
).values('id', 'code', 'lon', 'lat', 'capacity', 'entity')
features = []
for spot in qs:
if spot['lon'] is not None and spot['lat'] is not None:
features.append({
'type': 'Feature',
'id': spot['id'],
'geometry': {
'type': 'Point',
'coordinates': [float(spot['lon']), float(spot['lat'])]
},
'properties': {
'id': spot['id'],
'code': spot['code'] or '',
'capacity': spot['capacity'],
'entity': spot['entity'] or '',
}
})
return JsonResponse({'type': 'FeatureCollection', 'features': features})
# ──────────────────────────────────────────────────────────────────────────────
# Discussion / Messages sur observation
# ──────────────────────────────────────────────────────────────────────────────
@login_required
@observation_object_permission_required(select_related=('thematic', 'category', 'created_by'))
def observation_add_message(request, observation_id):
"""
Poste un message dans la discussion liée à cette observation.
Seuls le créateur de l'observation et les membres d'équipes gestionnaires
d'assets de la catégorie concernée peuvent poster.
"""
if request.method != 'POST':
return redirect('observations:observation_detail', observation_id=observation_id)
observation = request.observation_obj
from notifications.services import can_post_observation_message, get_or_create_observation_discussion, post_message
if not can_post_observation_message(request.user, observation):
messages.error(request, _("Vous n'avez pas la permission de poster un message sur cette observation."))
return redirect(
reverse('observations:observation_detail', args=[observation_id]) + '#discussion'
)
content = (request.POST.get('content') or '').strip()
if not content:
messages.warning(request, _("Le message ne peut pas être vide."))
return redirect(
reverse('observations:observation_detail', args=[observation_id]) + '#discussion'
)
discussion, _ = get_or_create_observation_discussion(observation)
files = request.FILES.getlist('attachments') or None
post_message(discussion=discussion, author=request.user, content=content, files=files)
# Si l'observation était en attente d'informations et que l'auteur répond,
# basculer automatiquement l'observation à 'to_process'.
if observation.status == 'needs_info' and request.user == observation.created_by:
observation.status = 'to_process'
observation.save(update_fields=['status'])
ObservationComment.objects.create(
observation=observation,
author=request.user,
comment=_("Réponse apportée dans la discussion : l'observation repasse à l'état 'À traiter'."),
)
messages.success(request, _("Votre réponse a été envoyée et l'observation a été renvoyée pour traitement."))
referer = request.META.get('HTTP_REFERER', '')
if '/mobile/' in referer:
return redirect(
reverse('mobile:observation_detail_mobile', args=[observation_id]) + '#discussion'
)
return redirect(
reverse('observations:observation_detail', args=[observation_id]) + '#discussion'
)
# ──────────────────────────────────────────────────────────────────────────────
# Lier une observation à une intervention existante
# ──────────────────────────────────────────────────────────────────────────────
@login_required
@require_GET
def api_search_interventions(request):
"""Retourne une liste JSON d'interventions correspondant à la requête (code ou titre)."""
q = (request.GET.get('q') or '').strip()
if len(q) < 2:
return JsonResponse({'results': []})
qs = Intervention.objects.filter(
Q(code__icontains=q) | Q(title__icontains=q)
).exclude(
pk__in=Observation.objects.filter(intervention__isnull=False).values('intervention_id')
).order_by('-id')[:20]
results = [{'id': i.id, 'text': f"{i.code} – {i.title or ''}".strip(' –')} for i in qs]
return JsonResponse({'results': results})
@login_required
@require_GET
def api_search_projects(request):
"""Retourne une liste JSON de projets correspondant à la requête (code ou nom)."""
from projects.models import Project
q = (request.GET.get('q') or '').strip()
if len(q) < 2:
return JsonResponse({'results': []})
qs = Project.objects.filter(
Q(code__icontains=q) | Q(name__icontains=q)
).order_by('-code')[:20]
results = [{'id': p.id, 'text': f"{p.code} – {p.name}"} for p in qs]
return JsonResponse({'results': results})
@login_required
@observation_object_permission_required(select_related=('thematic', 'category', 'created_by'))
def link_observation_to_intervention(request, observation_id):
"""Lie l'observation à une intervention existante (ou la délie si intervention_id vide)."""
if request.method != 'POST':
return HttpResponseForbidden("Méthode non autorisée.")
observation = request.observation_obj
access_context = request.observation_access
allowed_roles = ['admin', 'operator', 'manager', 'controller', 'top_manager']
if not any(access_context.user_config.has_role(r) for r in allowed_roles):
return HttpResponseForbidden(_("Vous n'avez pas la permission d'effectuer cette action."))
intervention_id = (request.POST.get('intervention_id') or '').strip()
if intervention_id:
try:
intervention = Intervention.objects.get(pk=int(intervention_id))
except (Intervention.DoesNotExist, ValueError):
messages.error(request, _("Intervention introuvable."))
return redirect('observations:observation_detail', observation_id=observation_id)
observation.intervention = intervention
messages.success(request, _("L'observation a été liée à l'intervention %(code)s.") % {'code': intervention.code})
else:
observation.intervention = None
messages.success(request, _("Le lien avec l'intervention a été supprimé."))
observation.save()
return redirect('observations:observation_detail', observation_id=observation_id)
@login_required
@observation_object_permission_required(select_related=('thematic', 'category', 'created_by'))
def link_observation_to_project(request, observation_id):
"""Lie l'observation à un projet existant (ou le délie si project_id vide)."""
if request.method != 'POST':
return HttpResponseForbidden("Méthode non autorisée.")
from projects.models import Project
observation = request.observation_obj
access_context = request.observation_access
allowed_roles = ['admin', 'operator', 'manager', 'controller', 'top_manager']
if not any(access_context.user_config.has_role(r) for r in allowed_roles):
return HttpResponseForbidden(_("Vous n'avez pas la permission d'effectuer cette action."))
project_id = (request.POST.get('project_id') or '').strip()
if project_id:
try:
project = Project.objects.get(pk=int(project_id))
except (Project.DoesNotExist, ValueError):
messages.error(request, _("Projet introuvable."))
return redirect('observations:observation_detail', observation_id=observation_id)
observation.project = project
messages.success(request, _("L'observation a été liée au projet %(code)s.") % {'code': project.code})
else:
observation.project = None
messages.success(request, _("Le lien avec le projet a été supprimé."))
observation.save()
return redirect('observations:observation_detail', observation_id=observation_id)
@login_required
@observation_access_required
@require_GET
def observations_nearby_interventions_geojson(request):
"""
Return interventions from the last 3 months filtered by asset_category as GeoJSON.
Used to display a contextual interventions layer on the observation detail/add map.
"""
from interventions.permissions import filter_viewable_interventions_for_user
from interventions.models import STATUS_COLORS
from django.utils import timezone
import datetime
category_id = request.GET.get('category')
if not category_id:
return JsonResponse({'type': 'FeatureCollection', 'features': []})
three_months_ago = timezone.now() - datetime.timedelta(days=90)
qs = filter_viewable_interventions_for_user(request.user)
qs = qs.filter(
asset_category_id=category_id,
creation_time__gte=three_months_ago,
).exclude(status='canceled').select_related('asset_category')
try:
west = float(request.GET.get('west', '-180'))
south = float(request.GET.get('south', '-90'))
east = float(request.GET.get('east', '180'))
north = float(request.GET.get('north', '90'))
qs = qs.filter(lon__gte=west, lon__lte=east, lat__gte=south, lat__lte=north)
except (TypeError, ValueError):
pass
features = []
for interv in qs[:300]:
if interv.lon is None or interv.lat is None:
continue
features.append({
'type': 'Feature',
'id': interv.id,
'geometry': {
'type': 'Point',
'coordinates': [float(interv.lon), float(interv.lat)],
},
'properties': {
'id': interv.id,
'code': interv.code or '',
'title': interv.title or '',
'status': interv.status,
'status_label': interv.get_status_display(),
'status_color': STATUS_COLORS.get(interv.status, '#6c757d'),
'creation_time': interv.creation_time.isoformat() if interv.creation_time else '',
},
})
return JsonResponse({'type': 'FeatureCollection', 'features': features})