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() 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})