2080 lines
84 KiB
Python
2080 lines
84 KiB
Python
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',
|
||
'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',
|
||
'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') and
|
||
can_edit
|
||
)
|
||
|
||
# Vérifier les permissions pour l'intervention liée
|
||
can_view_intervention_detail = False
|
||
can_view_intervention_summary_modal = False
|
||
if observation.intervention:
|
||
from interventions.permissions import can_view_intervention, can_view_intervention_summary
|
||
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,
|
||
'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', 'closed', 'archived']
|
||
if new_status not in allowed_statuses:
|
||
return HttpResponseForbidden("Statut non autorisé.")
|
||
# Règles simples de transition
|
||
if new_status == 'to_process' and observation.status != 'in_preparation':
|
||
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' et 'duplicate'
|
||
if new_status in ('not_relevant', 'to_redirect', 'duplicate'):
|
||
if 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']
|
||
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 "à 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,
|
||
)
|
||
# Archiver seulement depuis 'processed', 'not_relevant', 'to_redirect' ou 'duplicate'
|
||
if new_status == 'archived' and observation.status not in ('processed', 'not_relevant', 'to_redirect', 'duplicate'):
|
||
return HttpResponseForbidden("Seules les observations traitées, non relevantes, à rediriger 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
|
||
referer = request.META.get('HTTP_REFERER', '')
|
||
is_mobile = '/mobile/' in referer
|
||
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é
|
||
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
|
||
|
||
# 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)
|
||
|
||
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})
|