diff --git a/Mapapi/Send_mails.py b/Mapapi/Send_mails.py index 7bde2bb0..84f650a1 100644 --- a/Mapapi/Send_mails.py +++ b/Mapapi/Send_mails.py @@ -2,6 +2,7 @@ from django.core.mail import EmailMultiAlternatives from django.template.loader import render_to_string from django.utils.html import strip_tags +from django.conf import settings import logging logger = logging.getLogger(__name__) @@ -11,7 +12,8 @@ def send_email(subject, template_name, context, to_email): try: html_content = render_to_string(template_name, context) text_content = strip_tags(html_content) - msg = EmailMultiAlternatives(subject, text_content, 'Map Action ', [to_email]) + from_email = settings.DEFAULT_FROM_EMAIL or 'Map Action ' + msg = EmailMultiAlternatives(subject, text_content, from_email, [to_email]) msg.attach_alternative(html_content, "text/html") msg.send() logger.info(f"Email envoyé avec succès à {to_email}.") diff --git a/Mapapi/authentication.py b/Mapapi/authentication.py new file mode 100644 index 00000000..1bbbeba8 --- /dev/null +++ b/Mapapi/authentication.py @@ -0,0 +1,47 @@ +"""Authentification JWT par cookie httpOnly (avec repli Bearer). + +Le frontend stocke le JWT dans un cookie **httpOnly** (illisible par JavaScript → +protégé contre le vol par XSS) au lieu du sessionStorage/localStorage. L'API +reste utilisable par le mobile via l'en-tête ``Authorization: Bearer`` (essayé +en premier). Quand l'authentification vient du cookie, on applique la protection +CSRF sur les méthodes non sûres (le cookie étant envoyé automatiquement par le +navigateur, c'est la contre-mesure indispensable). +""" +from django.conf import settings +from rest_framework import exceptions +from rest_framework.authentication import CSRFCheck +from rest_framework_simplejwt.authentication import JWTAuthentication + +SAFE_METHODS = ('GET', 'HEAD', 'OPTIONS', 'TRACE') + + +def _enforce_csrf(request): + """Rejoue la vérification CSRF de Django pour une requête authentifiée par cookie.""" + def _dummy_get_response(_request): # pragma: no cover - jamais appelé + return None + + check = CSRFCheck(_dummy_get_response) + check.process_request(request) + reason = check.process_view(request, None, (), {}) + if reason: + raise exceptions.PermissionDenied(f'CSRF Failed: {reason}') + + +class CookieJWTAuthentication(JWTAuthentication): + """Bearer d'abord (mobile + transition), puis cookie httpOnly.""" + + def authenticate(self, request): + # 1. En-tête Authorization: Bearer — comportement historique inchangé. + header_result = super().authenticate(request) + if header_result is not None: + return header_result + + # 2. Cookie httpOnly. + raw_token = request.COOKIES.get(settings.AUTH_COOKIE_ACCESS) + if not raw_token: + return None + validated_token = self.get_validated_token(raw_token) + # CSRF requis quand on s'appuie sur le cookie pour une méthode non sûre. + if request.method not in SAFE_METHODS: + _enforce_csrf(request) + return self.get_user(validated_token), validated_token diff --git a/Mapapi/consumers.py b/Mapapi/consumers.py new file mode 100644 index 00000000..6a1dc0c7 --- /dev/null +++ b/Mapapi/consumers.py @@ -0,0 +1,92 @@ +"""Consumers WebSocket temps réel. + +- NotificationConsumer : /ws/notifications/ — flux des notifications de l'utilisateur + connecté (qui a fait quoi). Groupe ``notifications_``. +- DiscussionConsumer : /ws/incidents//discussion/ — messages de discussion + d'un incident en temps réel. Groupe ``discussion_``. +- TaskConsumer : /ws/incidents//tasks/ — créations/màj de tâches en temps réel. + Groupe ``tasks_``. + +Les serveurs (signals) poussent via channel_layer.group_send(group, {'type': 'broadcast', 'payload': {...}}). +""" +import json + +from channels.generic.websocket import AsyncJsonWebsocketConsumer +from django.core.serializers.json import DjangoJSONEncoder + + +class _GroupConsumer(AsyncJsonWebsocketConsumer): + """Base : rejoint un groupe si l'utilisateur est authentifié, relaie les + messages 'broadcast' tels quels au client.""" + group_name = None + + @classmethod + async def encode_json(cls, content): + # Les payloads poussés par les signaux contiennent des UUID et des datetime ; + # l'encodeur par défaut de channels (json.dumps brut) lèverait une exception + # « Object of type UUID is not JSON serializable » → fermeture WebSocket 1011. + # DjangoJSONEncoder sérialise UUID/datetime/Decimal proprement. + return json.dumps(content, cls=DjangoJSONEncoder) + + async def connect(self): + user = self.scope.get('user') + if user is None or not getattr(user, 'is_authenticated', False): + await self.close(code=4401) # non authentifié + return + self.group_name = await self.resolve_group() + if not self.group_name: + await self.close(code=4403) # non autorisé / cible invalide + return + await self.channel_layer.group_add(self.group_name, self.channel_name) + await self.accept() + + async def disconnect(self, code): + if self.group_name: + await self.channel_layer.group_discard(self.group_name, self.channel_name) + + async def resolve_group(self): + raise NotImplementedError + + async def broadcast(self, event): + # event = {'type': 'broadcast', 'payload': {...}} + await self.send_json(event['payload']) + + +class NotificationConsumer(_GroupConsumer): + async def resolve_group(self): + return f"notifications_{self.scope['user'].id}" + + +class DiscussionConsumer(_GroupConsumer): + async def resolve_group(self): + incident_id = self.scope['url_route']['kwargs'].get('incident_id') + return f"discussion_{incident_id}" if incident_id else None + + +class TaskConsumer(_GroupConsumer): + async def resolve_group(self): + incident_id = self.scope['url_route']['kwargs'].get('incident_id') + return f"tasks_{incident_id}" if incident_id else None + + +class CollaborationConsumer(_GroupConsumer): + """/ws/collaborations/ — collaborations de l'utilisateur connecté en temps réel + (onglet collaboration + demandes). Groupe ``collaborations_``.""" + async def resolve_group(self): + return f"collaborations_{self.scope['user'].id}" + + +class ActivityFeedConsumer(_GroupConsumer): + """/ws/activity-feed/ — flux d'activité de la plateforme en temps réel. + Groupe global ``activity_feed``. Comme le REST /activity-feed/, on n'affiche + PAS au viewer l'activité de sa PROPRE organisation : on filtre chaque broadcast + selon l'organisation du viewer (connue via ``scope['user']``).""" + async def resolve_group(self): + return "activity_feed" + + async def broadcast(self, event): + payload = event['payload'] + my_org = getattr(self.scope.get('user'), 'organisation_member_id', None) + if my_org is not None and str(payload.get('organisation_id')) == str(my_org): + return # activité de ma propre org -> ignorée (cohérent avec le REST) + await self.send_json(payload) diff --git a/Mapapi/ivr_views.py b/Mapapi/ivr_views.py index fdd28d0a..510a8eac 100644 --- a/Mapapi/ivr_views.py +++ b/Mapapi/ivr_views.py @@ -8,6 +8,9 @@ from rest_framework import status from .models import IVRCall, IVRInteraction, Incident, Category, Zone, User from django.conf import settings +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiResponse, inline_serializer +from drf_spectacular.types import OpenApiTypes +from rest_framework import serializers import logging logger = logging.getLogger(__name__) @@ -15,7 +18,28 @@ @method_decorator(csrf_exempt, name='dispatch') class TwilioIVRWebhook(View): - + + @extend_schema( + tags=['IVR (Téléphonie)'], + operation_id='ivr_webhook', + summary="Webhook d'entrée d'appel IVR", + description=( + "Webhook Twilio (public, csrf_exempt) appelé au début d'un appel entrant. " + "Crée ou met à jour l'IVRCall associé au CallSid et joue le menu principal " + "(1 pour signaler un incident, 2 pour un opérateur). " + "Reçoit des paramètres form-encoded de Twilio (`CallSid`, `From`, `CallStatus`) " + "et renvoie du TwiML (XML)." + ), + request=inline_serializer( + name='IVRWebhookRequest', + fields={ + 'CallSid': serializers.CharField(), + 'From': serializers.CharField(), + 'CallStatus': serializers.CharField(required=False), + }, + ), + responses={200: OpenApiResponse(description='TwiML (XML)')}, + ) def post(self, request): call_sid = request.POST.get('CallSid') from_number = request.POST.get('From') @@ -62,11 +86,30 @@ def post(self, request): @method_decorator(csrf_exempt, name='dispatch') class SelectZoneView(View): - + + @extend_schema( + tags=['IVR (Téléphonie)'], + operation_id='ivr_select_zone', + summary='Choix du menu principal IVR', + description=( + "Webhook Twilio (public, csrf_exempt) traitant le choix du menu principal. " + "Touche 1 : liste les zones et invite à en sélectionner une ; touche 2 : transfert " + "vers un opérateur ; sinon raccroche. Enregistre une IVRInteraction (`main_menu`). " + "Reçoit des paramètres form-encoded (`CallSid`, `Digits`) et renvoie du TwiML (XML)." + ), + request=inline_serializer( + name='IVRSelectZoneRequest', + fields={ + 'CallSid': serializers.CharField(), + 'Digits': serializers.CharField(), + }, + ), + responses={200: OpenApiResponse(description='TwiML (XML)')}, + ) def post(self, request): call_sid = request.POST.get('CallSid') digits = request.POST.get('Digits') - + try: ivr_call = IVRCall.objects.get(call_sid=call_sid) IVRInteraction.objects.create( @@ -123,14 +166,33 @@ def post(self, request): @method_decorator(csrf_exempt, name='dispatch') class SelectCategoryView(View): - + + @extend_schema( + tags=['IVR (Téléphonie)'], + operation_id='ivr_select_category', + summary="Choix de la zone, invite la catégorie", + description=( + "Webhook Twilio (public, csrf_exempt) enregistrant la zone choisie (`Digits` indexe " + "la liste des zones) sur l'IVRCall, journalise une IVRInteraction (`zone_selection`), " + "puis lit la liste des catégories à sélectionner. " + "Reçoit des paramètres form-encoded (`CallSid`, `Digits`) et renvoie du TwiML (XML)." + ), + request=inline_serializer( + name='IVRSelectCategoryRequest', + fields={ + 'CallSid': serializers.CharField(), + 'Digits': serializers.CharField(), + }, + ), + responses={200: OpenApiResponse(description='TwiML (XML)')}, + ) def post(self, request): call_sid = request.POST.get('CallSid') digits = request.POST.get('Digits') - + try: ivr_call = IVRCall.objects.get(call_sid=call_sid) - + zones = Zone.objects.all()[:9] zone_index = int(digits) - 1 @@ -178,14 +240,34 @@ def post(self, request): @method_decorator(csrf_exempt, name='dispatch') class RecordDescriptionView(View): - + + @extend_schema( + tags=['IVR (Téléphonie)'], + operation_id='ivr_record_description', + summary="Choix de la catégorie, lance l'enregistrement", + description=( + "Webhook Twilio (public, csrf_exempt) enregistrant la catégorie choisie (`Digits` " + "indexe la liste des catégories) sur l'IVRCall, journalise une IVRInteraction " + "(`category_selection`), puis invite l'appelant à décrire l'incident et démarre " + "l'enregistrement audio (jusqu'à 120 s, fin sur `#`). " + "Reçoit des paramètres form-encoded (`CallSid`, `Digits`) et renvoie du TwiML (XML)." + ), + request=inline_serializer( + name='IVRRecordDescriptionRequest', + fields={ + 'CallSid': serializers.CharField(), + 'Digits': serializers.CharField(), + }, + ), + responses={200: OpenApiResponse(description='TwiML (XML)')}, + ) def post(self, request): call_sid = request.POST.get('CallSid') digits = request.POST.get('Digits') - + try: ivr_call = IVRCall.objects.get(call_sid=call_sid) - + categories = Category.objects.all()[:9] category_index = int(digits) - 1 @@ -226,7 +308,29 @@ def post(self, request): @method_decorator(csrf_exempt, name='dispatch') class ProcessRecordingView(View): - + + @extend_schema( + tags=['IVR (Téléphonie)'], + operation_id='ivr_process_recording', + summary="Traitement de l'enregistrement et création d'incident", + description=( + "Webhook Twilio (public, csrf_exempt) appelé à la fin de l'enregistrement. " + "Stocke l'URL/durée audio sur l'IVRCall (statut `completed`), journalise une " + "IVRInteraction (`description_recording`), crée (ou récupère) un User citoyen à partir " + "du numéro et crée un Incident (état `declared`) lié à la zone, la catégorie et l'audio. " + "Reçoit des paramètres form-encoded (`CallSid`, `RecordingUrl`, `RecordingDuration`) " + "et renvoie du TwiML (XML)." + ), + request=inline_serializer( + name='IVRProcessRecordingRequest', + fields={ + 'CallSid': serializers.CharField(), + 'RecordingUrl': serializers.URLField(), + 'RecordingDuration': serializers.IntegerField(required=False), + }, + ), + responses={200: OpenApiResponse(description='TwiML (XML)')}, + ) def post(self, request): call_sid = request.POST.get('CallSid') recording_url = request.POST.get('RecordingUrl') @@ -288,7 +392,26 @@ def post(self, request): @method_decorator(csrf_exempt, name='dispatch') class RecordingStatusView(View): - + + @extend_schema( + tags=['IVR (Téléphonie)'], + operation_id='ivr_recording_status', + summary="Callback de statut d'enregistrement", + description=( + "Webhook Twilio (public, csrf_exempt) de suivi du statut d'enregistrement. " + "Journalise simplement le statut/URL pour le CallSid et renvoie une réponse vide. " + "Reçoit des paramètres form-encoded (`CallSid`, `RecordingUrl`, `RecordingStatus`)." + ), + request=inline_serializer( + name='IVRRecordingStatusRequest', + fields={ + 'CallSid': serializers.CharField(), + 'RecordingUrl': serializers.URLField(required=False), + 'RecordingStatus': serializers.CharField(required=False), + }, + ), + responses={200: OpenApiResponse(description='Réponse vide (HTTP 200).')}, + ) def post(self, request): call_sid = request.POST.get('CallSid') recording_url = request.POST.get('RecordingUrl') @@ -305,7 +428,36 @@ def post(self, request): class IVRCallListView(APIView): - + + @extend_schema( + tags=['IVR (Téléphonie)'], + operation_id='ivr_calls_list', + summary='Liste des appels IVR', + description=( + "Retourne tous les appels IVR (JSON), triés du plus récent au plus ancien. " + "Endpoint de lecture destiné au dashboard ; aucune permission n'est déclarée (public)." + ), + request=None, + responses={ + 200: inline_serializer( + name='IVRCallListItem', + many=True, + fields={ + 'id': serializers.UUIDField(), + 'call_sid': serializers.CharField(), + 'phone_number': serializers.CharField(), + 'status': serializers.CharField(), + 'zone_selected': serializers.CharField(allow_null=True), + 'category_selected': serializers.CharField(allow_null=True), + 'description_audio_url': serializers.URLField(allow_null=True), + 'description_audio_duration': serializers.IntegerField(allow_null=True), + 'incident_id': serializers.UUIDField(allow_null=True), + 'created_at': serializers.DateTimeField(), + 'updated_at': serializers.DateTimeField(), + }, + ) + }, + ) def get(self, request): ivr_calls = IVRCall.objects.all().order_by('-created_at') @@ -329,7 +481,51 @@ def get(self, request): class IVRCallDetailView(APIView): - + + @extend_schema( + tags=['IVR (Téléphonie)'], + operation_id='ivr_call_detail', + summary="Détail d'un appel IVR", + description=( + "Retourne le détail d'un appel IVR (JSON) avec la liste de ses interactions. " + "Endpoint de lecture destiné au dashboard ; aucune permission n'est déclarée (public). " + "Renvoie 404 si l'appel est introuvable." + ), + parameters=[ + OpenApiParameter('call_id', OpenApiTypes.UUID, OpenApiParameter.PATH), + ], + request=None, + responses={ + 200: inline_serializer( + name='IVRCallDetail', + fields={ + 'id': serializers.UUIDField(), + 'call_sid': serializers.CharField(), + 'phone_number': serializers.CharField(), + 'status': serializers.CharField(), + 'zone_selected': serializers.CharField(allow_null=True), + 'category_selected': serializers.CharField(allow_null=True), + 'description_audio_url': serializers.URLField(allow_null=True), + 'description_audio_duration': serializers.IntegerField(allow_null=True), + 'incident_id': serializers.UUIDField(allow_null=True), + 'created_at': serializers.DateTimeField(), + 'updated_at': serializers.DateTimeField(), + 'interactions': inline_serializer( + name='IVRInteractionItem', + many=True, + fields={ + 'step': serializers.CharField(), + 'user_input': serializers.CharField(allow_null=True), + 'recording_url': serializers.URLField(allow_null=True), + 'recording_duration': serializers.IntegerField(allow_null=True), + 'timestamp': serializers.DateTimeField(), + }, + ), + }, + ), + 404: OpenApiResponse(description="IVR Call introuvable ({'error': ...})."), + }, + ) def get(self, request, call_id): try: ivr_call = IVRCall.objects.get(id=call_id) diff --git a/Mapapi/middleware.py b/Mapapi/middleware.py index 73120ab1..8985c309 100644 --- a/Mapapi/middleware.py +++ b/Mapapi/middleware.py @@ -1,6 +1,39 @@ +from django.urls import resolve, Resolver404 from django.utils.deprecation import MiddlewareMixin from .models import Organisation + +class SlashInsensitiveMiddleware: + """Rend chaque route insensible au slash final. + + Les routes de /MapApi/ sont historiquement incohérentes : certaines finissent + par « / », d'autres non. Le frontend renvoyait des 404 uniquement à cause d'un + slash en trop ou en moins. Plutôt que d'éditer ~150 routes (et casser les + appels existants), on retente de façon transparente la variante avec/sans slash + AVANT le routage — même méthode HTTP, même corps de requête, sans redirection + (contrairement à APPEND_SLASH qui redirige en 301 et casse les POST). + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + path = request.path_info + if not self._resolves(path): + toggled = path[:-1] if (path.endswith('/') and len(path) > 1) else path + '/' + if self._resolves(toggled): + request.path = request.path[:-1] if request.path.endswith('/') and len(request.path) > 1 else request.path + '/' + request.path_info = toggled + return self.get_response(request) + + @staticmethod + def _resolves(path): + try: + resolve(path) + return True + except Resolver404: + return False + class OrganisationFromSubdomainMiddleware(MiddlewareMixin): """ Middleware pour extraire le sous-domaine de la requête et attacher l'organisation à request.organisation diff --git a/Mapapi/migrations/0001_initial.py b/Mapapi/migrations/0001_initial.py index ba4d3748..943887c0 100644 --- a/Mapapi/migrations/0001_initial.py +++ b/Mapapi/migrations/0001_initial.py @@ -1,9 +1,13 @@ -# Generated by Django 4.2.7 on 2024-10-24 12:44 +# Generated by Django 4.2.7 on 2026-06-29 01:36 import Mapapi.models +import backend.supabase_storage from django.conf import settings +import django.core.validators from django.db import migrations, models import django.db.models.deletion +import django.utils.timezone +import uuid class Migration(migrations.Migration): @@ -18,153 +22,221 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Category', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('name', models.CharField(max_length=250, unique=True)), + ('description', models.TextField(blank=True, max_length=500, null=True)), ('photo', models.ImageField(blank=True, null=True, upload_to='')), ('created_at', models.DateTimeField(auto_now_add=True)), ], - ), - migrations.CreateModel( - name='ChatHistory', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('session_id', models.CharField(db_index=True, max_length=255)), - ('question', models.TextField(db_index=True)), - ('answer', models.TextField(db_index=True)), - ], + options={ + 'abstract': False, + }, ), migrations.CreateModel( name='Collaboration', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True)), - ('end_date', models.DateField(blank=True)), + ('end_date', models.DateField(blank=True, null=True)), ('motivation', models.TextField(blank=True, null=True)), ('other_option', models.CharField(blank=True, max_length=255, null=True)), ('status', models.CharField(default='pending', max_length=20)), + ('role', models.CharField(choices=[('leader', 'leader'), ('contributor', 'contributor'), ('observer', 'observer')], default='contributor', help_text="Rôle de l'organisation sur l'incident : leader, contributor ou observer.", max_length=20)), ], ), migrations.CreateModel( name='Communaute', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('name', models.CharField(max_length=250)), ('created_at', models.DateTimeField(auto_now_add=True)), ], + options={ + 'abstract': False, + }, ), migrations.CreateModel( name='Contact', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('objet', models.CharField(max_length=250)), ('message', models.TextField(blank=True, max_length=500, null=True)), ('email', models.CharField(blank=True, max_length=250, null=True)), ('created_at', models.DateTimeField(auto_now_add=True)), ], + options={ + 'abstract': False, + }, ), migrations.CreateModel( name='Evenement', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('title', models.CharField(blank=True, max_length=255, null=True)), ('zone', models.CharField(max_length=255)), ('description', models.TextField(blank=True, max_length=500, null=True)), - ('photo', models.ImageField(blank=True, null=True, upload_to='')), + ('photo', models.ImageField(blank=True, null=True, storage=backend.supabase_storage.ImageStorage(), upload_to='events/')), ('date', models.DateTimeField(null=True)), ('lieu', models.CharField(max_length=250)), - ('video', models.FileField(blank=True, null=True, upload_to='')), - ('audio', models.FileField(blank=True, null=True, upload_to='')), + ('video', models.FileField(blank=True, null=True, storage=backend.supabase_storage.VideoStorage(), upload_to='events/')), + ('audio', models.FileField(blank=True, null=True, storage=backend.supabase_storage.VoiceStorage(), upload_to='events/')), ('latitude', models.CharField(blank=True, max_length=1000, null=True)), ('longitude', models.CharField(blank=True, max_length=1000, null=True)), ('created_at', models.DateTimeField(auto_now_add=True)), ], + options={ + 'abstract': False, + }, ), migrations.CreateModel( name='ImageBackground', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('photo', models.ImageField(blank=True, null=True, upload_to='')), ('created_at', models.DateTimeField(auto_now_add=True)), ], + options={ + 'abstract': False, + }, ), migrations.CreateModel( name='Incident', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('title', models.CharField(blank=True, max_length=250, null=True)), ('zone', models.CharField(max_length=250)), ('description', models.TextField(blank=True, max_length=500, null=True)), - ('photo', models.ImageField(blank=True, null=True, upload_to='uploads/')), - ('video', models.FileField(blank=True, null=True, upload_to='uploads/')), - ('audio', models.FileField(blank=True, null=True, upload_to='uploads/')), + ('photo', models.ImageField(blank=True, null=True, storage=backend.supabase_storage.ImageStorage(), upload_to='incidents/')), + ('video', models.FileField(blank=True, null=True, storage=backend.supabase_storage.VideoStorage(), upload_to='incidents/')), + ('audio', models.FileField(blank=True, null=True, storage=backend.supabase_storage.VoiceStorage(), upload_to='incidents/')), ('lattitude', models.CharField(blank=True, max_length=250, null=True)), ('longitude', models.CharField(blank=True, max_length=250, null=True)), - ('etat', models.CharField(choices=[('declared', 'declared'), ('resolved', 'resolved'), ('in_progress', 'in_progress'), ('taken_into_account', 'taken_into_account')], default='declared', max_length=255)), + ('etat', models.CharField(choices=[('declared', 'declared'), ('resolved', 'resolved'), ('in_progress', 'in_progress'), ('taken_into_account', 'taken_into_account'), ('resolution_prepared', 'Résolution préparée (attente Admin)'), ('in_validation', 'Résolu (en validation)'), ('resolved_definitive', 'Résolu (définitif)')], default='declared', max_length=255)), ('slug', models.CharField(blank=True, max_length=250, null=True)), ('created_at', models.DateTimeField(auto_now_add=True)), + ('take_in_charge_mode', models.CharField(blank=True, choices=[('internal', 'Interne (organisation seule)'), ('collaborative', 'Collaborative (ouvert aux autres organisations)')], help_text='Mode de prise en charge choisi par la première organisation.', max_length=20, null=True)), + ('resolution_start_date', models.DateField(blank=True, help_text='Date de début de la résolution. Obligatoire à la clôture.', null=True)), + ('resolution_end_date', models.DateField(blank=True, help_text='Date de fin de la résolution. Obligatoire à la clôture.', null=True)), + ('progress', models.PositiveSmallIntegerField(default=0, help_text='Progression auto-calculée (0-100) selon avancement des tâches.')), + ('is_public', models.BooleanField(default=True, help_text="Si False, l'incident n'est visible que par l'organisation de l'agent.")), + ('is_deleted', models.BooleanField(default=False, help_text="Si True, l'incident a été supprimé (corbeille).")), + ('deleted_at', models.DateTimeField(blank=True, help_text='Horodatage de la mise en corbeille (is_deleted=True). Sert à la purge des 30 j (spec D10).', null=True)), + ('taken_in_charge_at', models.DateTimeField(blank=True, help_text="Horodatage de la prise en compte (passage en 'taken_into_account'). Sert au calcul du délai anti-gel (spec T3).", null=True)), + ('severity', models.CharField(blank=True, choices=[('high', 'Élevée'), ('medium', 'Moyenne'), ('low', 'Faible')], default='medium', help_text='Sévérité catégorielle. Pilote le délai anti-gel (Élevée 30 j / Moyenne 60 j / Faible 90 j ; défaut/inconnue : 60 j).', max_length=10, null=True)), + ('antigel_warned_75', models.BooleanField(default=False, help_text='Avertissement anti-gel à 75 % du délai déjà envoyé au leader. Remis à False à chaque (re)prise en compte (spec T3).')), + ('antigel_warned_90', models.BooleanField(default=False, help_text='Avertissement anti-gel à 90 % du délai déjà envoyé au leader. Remis à False à chaque (re)prise en compte (spec T3).')), + ('resolution_submitted_at', models.DateTimeField(blank=True, help_text='Date de soumission du dossier de résolution préparée.', null=True)), + ('validation_deadline', models.DateTimeField(blank=True, help_text='Échéance de contrôle Super Admin (72h après déclaration de résolution).', null=True)), + ('rejection_reason', models.TextField(blank=True, help_text='Motif de refus de la résolution par le Super Admin.', null=True)), ('category_id', models.ForeignKey(db_column='categ_incid_id', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='user_category', to='Mapapi.category')), ('category_ids', models.ManyToManyField(blank=True, to='Mapapi.category')), ], + options={ + 'abstract': False, + }, ), migrations.CreateModel( name='Indicateur', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('name', models.CharField(max_length=250, unique=True)), ('created_at', models.DateTimeField(auto_now_add=True)), ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='IVRCall', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('call_sid', models.CharField(max_length=255, unique=True)), + ('phone_number', models.CharField(max_length=20)), + ('status', models.CharField(default='initiated', max_length=50)), + ('zone_selected', models.CharField(blank=True, max_length=250, null=True)), + ('description_audio_url', models.URLField(blank=True, null=True)), + ('description_audio_duration', models.IntegerField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('category_selected', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='Mapapi.category')), + ('incident_created', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='Mapapi.incident')), + ], + options={ + 'abstract': False, + }, ), migrations.CreateModel( name='Message', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('objet', models.CharField(max_length=250)), ('message', models.CharField(max_length=250)), ('created_at', models.DateTimeField(auto_now_add=True)), ('communaute', models.ForeignKey(db_column='mess_communaute_id', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='communaute_mess', to='Mapapi.communaute')), ], + options={ + 'abstract': False, + }, ), migrations.CreateModel( - name='PhoneOTP', + name='Organisation', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('phone_number', models.CharField(max_length=15)), - ('otp_code', models.CharField(max_length=6)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=255, unique=True)), + ('acronym', models.CharField(blank=True, max_length=50, null=True)), + ('is_premium', models.BooleanField(default=False)), + ('subdomain', models.CharField(max_length=255, unique=True)), + ('logo', models.ImageField(blank=True, help_text="Logo de l'organisation (upload).", null=True, storage=backend.supabase_storage.ImageStorage(), upload_to='organisations/logos/')), + ('activity_sector', models.CharField(blank=True, choices=[('humanitarian', 'Humanitaire'), ('humanitarian_coordination', 'Coordination humanitaire'), ('development', 'Développement'), ('child_protection', "Protection de l'enfance"), ('health', 'Santé'), ('nutrition_food_security', 'Nutrition et sécurité alimentaire'), ('development_humanitarian', 'Développement et humanitaire')], max_length=40, null=True)), + ('organisation_type', models.CharField(blank=True, choices=[('ngo', 'ONG'), ('international_organisation', 'Organisation internationale'), ('governmental', 'Gouvernementale'), ('civil_society', 'Société civile')], max_length=40, null=True)), + ('intervention_country', models.CharField(blank=True, choices=[('senegal', 'Sénégal'), ('mali', 'Mali'), ('guinea', 'Guinée'), ('burkina_faso', 'Burkina Faso'), ('niger', 'Niger'), ('cote_divoire', 'Côte d’Ivoire'), ('mauritania', 'Mauritanie')], max_length=30, null=True)), + ('description', models.TextField(blank=True, null=True)), + ('partner_status', models.CharField(choices=[('active', 'Actif'), ('inactive', 'Inactif')], default='active', max_length=20)), + ('phone', models.CharField(blank=True, max_length=20, null=True)), + ('website_url', models.URLField(blank=True, null=True)), + ('primary_color', models.CharField(default='#4CAF50', max_length=7)), + ('secondary_color', models.CharField(default='#8BC34A', max_length=7)), + ('background_color', models.CharField(default='#F0F0F0', max_length=7)), ('created_at', models.DateTimeField(auto_now_add=True)), ], + options={ + 'abstract': False, + }, ), migrations.CreateModel( - name='Prediction', + name='PhoneOTP', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('prediction_id', models.CharField(max_length=255)), - ('incident_id', models.CharField(max_length=255)), - ('incident_type', models.CharField(max_length=255)), - ('piste_solution', models.TextField()), - ('analysis', models.TextField()), - ('ndvi_heatmap', models.TextField(blank=True, null=True)), - ('ndvi_ndwi_plot', models.TextField(blank=True, null=True)), - ('landcover_plot', models.TextField(blank=True, null=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('phone_number', models.CharField(max_length=15)), + ('otp_code', models.CharField(max_length=6)), + ('created_at', models.DateTimeField(auto_now_add=True)), ], + options={ + 'abstract': False, + }, ), migrations.CreateModel( name='Zone', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('name', models.CharField(max_length=250, unique=True)), + ('description', models.TextField(blank=True, max_length=500, null=True)), ('lattitude', models.CharField(blank=True, max_length=250, null=True)), ('longitude', models.CharField(blank=True, max_length=250, null=True)), - ('photo', models.ImageField(blank=True, null=True, upload_to='')), + ('photo', models.ImageField(blank=True, null=True, storage=backend.supabase_storage.ImageStorage(), upload_to='zones/')), ('created_at', models.DateTimeField(auto_now_add=True)), ], + options={ + 'abstract': False, + }, ), migrations.CreateModel( name='User', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('password', models.CharField(max_length=128, verbose_name='password')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('email', models.EmailField(max_length=254, unique=True)), ('first_name', models.CharField(max_length=255, verbose_name='first name')), ('last_name', models.CharField(max_length=255, verbose_name='last name')), @@ -172,14 +244,24 @@ class Migration(migrations.Migration): ('date_joined', models.DateTimeField(auto_now_add=True, verbose_name='date joined')), ('is_active', models.BooleanField(default=True, verbose_name='active')), ('is_staff', models.BooleanField(default=False)), - ('avatar', models.ImageField(blank=True, default='avatars/default.png', null=True, upload_to='avatars/')), + ('avatar', models.ImageField(blank=True, default='avatars/default.png', null=True, storage=backend.supabase_storage.ImageStorage(), upload_to='avatars/')), ('password_reset_count', models.DecimalField(blank=True, decimal_places=0, default=0, max_digits=10, null=True)), ('address', models.CharField(blank=True, max_length=255, null=True, verbose_name='adress')), - ('user_type', models.CharField(choices=[('admin', 'admin'), ('visitor', 'visitor'), ('reporter', 'reporter'), ('citizen', 'citizen'), ('business', 'business'), ('elu', 'elu')], default='citizen', max_length=15)), + ('user_type', models.CharField(choices=[('admin', 'admin'), ('visitor', 'visitor'), ('reporter', 'reporter'), ('citizen', 'citizen'), ('business', 'business'), ('elu', 'elu'), ('field_agent', 'field_agent')], default='citizen', max_length=15)), ('provider', models.CharField(blank=True, max_length=255, null=True, verbose_name='provider')), - ('organisation', models.CharField(blank=True, max_length=255, null=True)), + ('organisation', models.CharField(blank=True, max_length=255, null=True, verbose_name='organisation')), + ('org_role', models.CharField(blank=True, choices=[('org_admin', 'Admin organisation'), ('bureau_agent', 'Agent de bureau'), ('field_agent', 'Agent de terrain')], help_text="Rôle interne dans l'organisation (org_admin, bureau_agent, field_agent).", max_length=20, null=True)), + ('agent_code', models.CharField(blank=True, help_text='Code auto-généré pour la connexion des agents de terrain.', max_length=10, null=True, unique=True)), + ('pin_code', models.CharField(blank=True, help_text='PIN hashé pour la connexion des agents de terrain (4 chiffres).', max_length=128, null=True)), + ('must_change_pin', models.BooleanField(default=False, help_text="Si True, l'agent doit changer son PIN à la prochaine connexion.")), ('points', models.IntegerField(blank=True, default=0, null=True)), + ('verification_token', models.UUIDField(blank=True, default=uuid.uuid4, editable=False, null=True)), + ('is_verified', models.BooleanField(default=False)), + ('otp', models.CharField(blank=True, max_length=6, null=True)), + ('otp_expiration', models.DateTimeField(blank=True, null=True)), + ('is_deleted', models.BooleanField(default=False, help_text="Si True, l'utilisateur a été supprimé (corbeille).")), ('community', models.ForeignKey(blank=True, db_column='user_communaute_id', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='user_communaute', to='Mapapi.communaute')), + ('organisation_member', models.ForeignKey(blank=True, help_text="Organisation à laquelle appartient l'utilisateur.", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='members', to='Mapapi.organisation')), ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='mapapi_user_user_permissions', to='auth.permission', verbose_name='user permissions')), ('zones', models.ManyToManyField(blank=True, to='Mapapi.zone')), ], @@ -194,69 +276,161 @@ class Migration(migrations.Migration): migrations.CreateModel( name='UserAction', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('action', models.CharField(max_length=255)), ('timeStamp', models.DateField(auto_now_add=True)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], + options={ + 'abstract': False, + }, ), migrations.CreateModel( name='ResponseMessage', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('response', models.CharField(max_length=250)), ('created_at', models.DateTimeField(auto_now_add=True)), ('elu', models.ForeignKey(db_column='user_mess_id', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='user_resp', to=settings.AUTH_USER_MODEL)), ('message', models.ForeignKey(db_column='mess_resp_id', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='resp_mess', to='Mapapi.message')), ], + options={ + 'abstract': False, + }, ), migrations.CreateModel( name='Rapport', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('details', models.CharField(max_length=500)), ('type', models.CharField(blank=True, max_length=500, null=True)), ('zone', models.CharField(max_length=250, null=True)), ('date_livraison', models.CharField(blank=True, max_length=100, null=True)), ('statut', models.CharField(choices=[('new', 'new'), ('in_progress', 'in_progress'), ('edit', 'edit'), ('canceled', 'canceled')], default='new', max_length=15)), ('disponible', models.BooleanField(default=False, verbose_name='active')), - ('file', models.FileField(blank=True, null=True, upload_to='')), + ('file', models.FileField(blank=True, null=True, storage=backend.supabase_storage.ImageStorage(), upload_to='reports/')), ('created_at', models.DateTimeField(auto_now_add=True)), ('incident', models.ForeignKey(db_column='incident_rapport_id', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='incident_rapport', to='Mapapi.incident')), ('incidents', models.ManyToManyField(blank=True, to='Mapapi.incident')), ('user_id', models.ForeignKey(db_column='user_rapport_id', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='user_rapport', to=settings.AUTH_USER_MODEL)), ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Prediction', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('prediction_id', models.IntegerField(blank=True, default=None, null=True, unique=True)), + ('legacy_incident_id', models.CharField(blank=True, max_length=255, null=True)), + ('incident_type', models.CharField(blank=True, max_length=255, null=True)), + ('piste_solution', models.TextField(blank=True, null=True)), + ('analysis', models.TextField(blank=True, null=True)), + ('ndvi_heatmap', models.TextField(blank=True, null=True)), + ('ndvi_ndwi_plot', models.TextField(blank=True, null=True)), + ('landcover_plot', models.TextField(blank=True, null=True)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('completed', 'Completed'), ('completed_with_warning', 'Completed with warning'), ('failed', 'Failed')], default='pending', max_length=32)), + ('macro_category', models.CharField(blank=True, default='', max_length=255)), + ('sub_category', models.CharField(blank=True, default='', max_length=255)), + ('description', models.TextField(blank=True, default='')), + ('source_size_meters', models.FloatField(blank=True, null=True)), + ('spread_vectors', models.JSONField(blank=True, default=list)), + ('impact_radius_meters', models.FloatField(blank=True, null=True)), + ('radius_explanation', models.TextField(blank=True, default='')), + ('global_impact_score', models.FloatField(blank=True, null=True)), + ('base_severity', models.IntegerField(blank=True, null=True)), + ('impact_tags', models.JSONField(blank=True, default=list)), + ('recommendation', models.TextField(blank=True, default='')), + ('latitude', models.FloatField(blank=True, null=True)), + ('longitude', models.FloatField(blank=True, null=True)), + ('city', models.CharField(blank=True, default='', max_length=255)), + ('region', models.CharField(blank=True, default='', max_length=255)), + ('country', models.CharField(blank=True, default='', max_length=255)), + ('display_name', models.TextField(blank=True, default='')), + ('social_vulnerability_score', models.FloatField(blank=True, null=True)), + ('is_social_probabilistic', models.BooleanField(default=False)), + ('total_population_exposed', models.IntegerField(default=0)), + ('adult_men_exposed', models.IntegerField(default=0)), + ('adult_women_exposed', models.IntegerField(default=0)), + ('children_exposed', models.IntegerField(default=0)), + ('maternities_count', models.IntegerField(default=0)), + ('nurseries_count', models.IntegerField(default=0)), + ('health_centers', models.IntegerField(default=0)), + ('maternities', models.IntegerField(default=0)), + ('schools', models.IntegerField(default=0)), + ('nurseries', models.IntegerField(default=0)), + ('markets', models.IntegerField(default=0)), + ('water_points', models.IntegerField(default=0)), + ('main_roads_bridges', models.IntegerField(default=0)), + ('residential_buildings', models.IntegerField(default=0)), + ('ai_analysis', models.JSONField(blank=True, default=dict)), + ('topography', models.JSONField(blank=True, default=dict)), + ('satellite', models.JSONField(blank=True, default=dict)), + ('social_data', models.JSONField(blank=True, default=dict)), + ('human_impact', models.JSONField(blank=True, default=dict)), + ('geocoding', models.JSONField(blank=True, default=dict)), + ('potential_risk', models.JSONField(blank=True, null=True)), + ('full_response', models.JSONField(blank=True, default=dict)), + ('error_message', models.TextField(blank=True, default='')), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), + ('updated_at', models.DateTimeField(auto_now=True, null=True)), + ('incident', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='prediction', to='Mapapi.incident')), + ], + options={ + 'abstract': False, + }, ), migrations.CreateModel( name='PasswordReset', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('code', models.CharField(max_length=7)), ('date_created', models.DateTimeField(auto_now_add=True)), ('used', models.BooleanField(default=False)), ('date_used', models.DateTimeField(null=True)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], + options={ + 'abstract': False, + }, ), migrations.CreateModel( name='Participate', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True)), ('evenement_id', models.ForeignKey(db_column='event_participate_id', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='event_participate', to='Mapapi.evenement')), ('user_id', models.ForeignKey(db_column='user_participate_id', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='user_participate', to=settings.AUTH_USER_MODEL)), ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='OrganisationTag', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('incident_type', models.CharField(max_length=255)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='incident_preferences', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, ), migrations.CreateModel( name='Notification', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('message', models.CharField(max_length=255)), ('created_at', models.DateTimeField(auto_now_add=True)), ('read', models.BooleanField(default=False)), - ('colaboration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='Mapapi.collaboration')), + ('colaboration', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='Mapapi.collaboration')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], + options={ + 'abstract': False, + }, ), migrations.AddField( model_name='message', @@ -268,11 +442,76 @@ class Migration(migrations.Migration): name='zone', field=models.ForeignKey(db_column='mess_zone_id', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='zone_mess', to='Mapapi.zone'), ), + migrations.CreateModel( + name='IVRInteraction', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('step', models.CharField(max_length=50)), + ('user_input', models.CharField(blank=True, max_length=255, null=True)), + ('recording_url', models.URLField(blank=True, null=True)), + ('recording_duration', models.IntegerField(blank=True, null=True)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('ivr_call', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interactions', to='Mapapi.ivrcall')), + ], + options={ + 'ordering': ['timestamp'], + }, + ), + migrations.AddField( + model_name='ivrcall', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.CreateModel( + name='IncidentTask', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('title', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, null=True)), + ('start_date', models.DateField()), + ('end_date', models.DateField()), + ('state', models.CharField(choices=[('pending', 'pending'), ('in_progress', 'in_progress'), ('done', 'done'), ('failed', 'failed')], default='pending', max_length=20)), + ('proof_image', models.ImageField(blank=True, help_text="Image de preuve quand la tâche est marquée 'done'.", null=True, storage=backend.supabase_storage.ImageStorage(), upload_to='tasks/proofs/')), + ('proof_video', models.FileField(blank=True, help_text="Vidéo de preuve quand la tâche est marquée 'done'.", null=True, storage=backend.supabase_storage.VideoStorage(), upload_to='tasks/proofs/')), + ('failure_reason', models.TextField(blank=True, help_text="Motif d'échec si la tâche est 'failed'.", null=True)), + ('is_confirmed', models.BooleanField(default=False, help_text='True si la tâche a été confirmée par le leader. Seules les tâches confirmées comptent dans la progression.')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_tasks', to=settings.AUTH_USER_MODEL)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_tasks', to=settings.AUTH_USER_MODEL)), + ('incident', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='Mapapi.incident')), + ], + options={ + 'ordering': ('start_date', 'id'), + }, + ), + migrations.CreateModel( + name='IncidentOrgAssignment', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('pending', 'En attente'), ('accepted', 'Acceptée'), ('declined', 'Déclinée')], default='pending', max_length=20)), + ('decline_reason', models.TextField(blank=True, null=True)), + ('deadline', models.DateTimeField(help_text="Échéance d'acceptation (now + 72 h).")), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('responded_at', models.DateTimeField(blank=True, null=True)), + ('assigned_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_org_assignments', to=settings.AUTH_USER_MODEL)), + ('incident', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='org_assignments', to='Mapapi.incident')), + ('organisation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='Mapapi.organisation')), + ], + options={ + 'ordering': ('-created_at',), + }, + ), migrations.AddField( model_name='incident', name='indicateur_id', field=models.ForeignKey(db_column='indic_incid_id', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='user_indicateur', to='Mapapi.indicateur'), ), + migrations.AddField( + model_name='incident', + name='resolution_submitted_by', + field=models.ForeignKey(blank=True, help_text='Membre (agent de bureau/admin) ayant monté le dossier de résolution.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + ), migrations.AddField( model_name='incident', name='taken_by', @@ -283,11 +522,46 @@ class Migration(migrations.Migration): name='user_id', field=models.ForeignKey(db_column='user_incid_id', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='user_incident', to=settings.AUTH_USER_MODEL), ), + migrations.CreateModel( + name='FieldReport', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('location_lat', models.CharField(blank=True, help_text="Latitude réelle de l'agent lors du déplacement.", max_length=250, null=True)), + ('location_lon', models.CharField(blank=True, help_text="Longitude réelle de l'agent lors du déplacement.", max_length=250, null=True)), + ('distance_meters', models.FloatField(blank=True, help_text="Distance estimée entre l'agent et le lieu de l'incident (m).", null=True)), + ('notes', models.TextField(blank=True, help_text="Observations de l'agent sur l'état des lieux.", max_length=1000, null=True)), + ('photo', models.ImageField(blank=True, null=True, storage=backend.supabase_storage.ImageStorage(), upload_to='field_reports/')), + ('visited_at', models.DateTimeField(default=django.utils.timezone.now, help_text='Date et heure du déplacement.')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('agent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='field_reports', to=settings.AUTH_USER_MODEL)), + ('incident', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='field_reports', to='Mapapi.incident')), + ], + options={ + 'ordering': ('-visited_at',), + }, + ), migrations.AddField( model_name='evenement', name='user_id', field=models.ForeignKey(db_column='user_event_id', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='user_event', to=settings.AUTH_USER_MODEL), ), + migrations.CreateModel( + name='DiscussionMessage', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('message', models.TextField(blank=True, null=True)), + ('audio', models.FileField(blank=True, null=True, storage=backend.supabase_storage.VoiceStorage(), upload_to='chat/audio/')), + ('attachment', models.FileField(blank=True, help_text='Pièce jointe : PDF, Word ou Excel uniquement.', null=True, storage=backend.supabase_storage.DocumentStorage(), upload_to='chat/attachments/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf', 'doc', 'docx', 'xls', 'xlsx'])])), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('collaboration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='Mapapi.collaboration')), + ('incident', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='Mapapi.incident')), + ('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to=settings.AUTH_USER_MODEL)), + ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), migrations.AddField( model_name='communaute', name='zone', @@ -306,7 +580,7 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Colaboration', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('end_date', models.DateField()), ('motivation', models.TextField(blank=True, null=True)), ('other_option', models.CharField(blank=True, max_length=255, null=True)), @@ -314,5 +588,64 @@ class Migration(migrations.Migration): ('incident', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='Mapapi.incident')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ChatHistory', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('session_id', models.CharField(blank=True, db_index=True, max_length=255, null=True)), + ('question', models.TextField(blank=True, db_index=True, null=True)), + ('answer', models.TextField(blank=True, db_index=True, null=True)), + ('role', models.CharField(choices=[('user', 'User'), ('assistant', 'Assistant'), ('system', 'System')], default='user', max_length=20)), + ('content', models.TextField(blank=True, default='')), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), + ('incident', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='chat_messages', to='Mapapi.incident')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='chat_messages', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('created_at', 'id'), + }, + ), + migrations.CreateModel( + name='PartnerSuggestion', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('suggested_role', models.CharField(choices=[('contributor', 'contributor'), ('observer', 'observer')], help_text='Rôle proposé (contributor ou observer uniquement).', max_length=20)), + ('justification', models.TextField(help_text='Court message justifiant la suggestion.')), + ('status', models.CharField(choices=[('pending', 'pending'), ('accepted', 'accepted'), ('rejected', 'rejected')], default='pending', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('incident', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='partner_suggestions', to='Mapapi.incident')), + ('suggested_by', models.ForeignKey(help_text="Contributeur à l'origine de la suggestion.", on_delete=django.db.models.deletion.CASCADE, related_name='suggestions_made', to=settings.AUTH_USER_MODEL)), + ('suggested_partner', models.ForeignKey(help_text='Organisation proposée.', on_delete=django.db.models.deletion.CASCADE, related_name='suggestions_received', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-created_at',), + 'unique_together': {('incident', 'suggested_partner')}, + }, + ), + migrations.CreateModel( + name='IncidentAssignment', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('deadline', models.DateTimeField()), + ('status', models.CharField(choices=[('pending', 'En attente'), ('in_progress', 'En cours'), ('reported', 'Rapport effectué'), ('cancelled', 'Annulé')], default='pending', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('agent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='incident_assignments', to=settings.AUTH_USER_MODEL)), + ('assigned_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_incident_assignments', to=settings.AUTH_USER_MODEL)), + ('incident', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to='Mapapi.incident')), + ], + options={ + 'ordering': ('deadline', '-created_at'), + 'unique_together': {('incident', 'agent')}, + }, + ), + migrations.AlterUniqueTogether( + name='collaboration', + unique_together={('incident', 'user')}, ), ] diff --git a/Mapapi/migrations/0002_alter_prediction_prediction_id.py b/Mapapi/migrations/0002_alter_prediction_prediction_id.py deleted file mode 100644 index 88672310..00000000 --- a/Mapapi/migrations/0002_alter_prediction_prediction_id.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.7 on 2024-10-24 15:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('Mapapi', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='prediction', - name='prediction_id', - field=models.CharField(blank=True, null=True, unique=True), - ), - ] diff --git a/Mapapi/migrations/0002_remove_chathistory_answer_and_more.py b/Mapapi/migrations/0002_remove_chathistory_answer_and_more.py new file mode 100644 index 00000000..21f069a4 --- /dev/null +++ b/Mapapi/migrations/0002_remove_chathistory_answer_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.7 on 2026-06-29 11:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('Mapapi', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='chathistory', + name='answer', + ), + migrations.RemoveField( + model_name='chathistory', + name='question', + ), + migrations.RemoveField( + model_name='chathistory', + name='session_id', + ), + ] diff --git a/Mapapi/migrations/0003_alter_prediction_prediction_id.py b/Mapapi/migrations/0003_alter_prediction_prediction_id.py deleted file mode 100644 index 7174249c..00000000 --- a/Mapapi/migrations/0003_alter_prediction_prediction_id.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.7 on 2024-10-24 15:31 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('Mapapi', '0002_alter_prediction_prediction_id'), - ] - - operations = [ - migrations.AlterField( - model_name='prediction', - name='prediction_id', - field=models.IntegerField(blank=True, default=None, null=True, unique=True), - ), - ] diff --git a/Mapapi/migrations/0003_incident_thumbnail.py b/Mapapi/migrations/0003_incident_thumbnail.py new file mode 100644 index 00000000..2cf05b85 --- /dev/null +++ b/Mapapi/migrations/0003_incident_thumbnail.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.7 on 2026-06-29 12:49 + +import backend.supabase_storage +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('Mapapi', '0002_remove_chathistory_answer_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='incident', + name='thumbnail', + field=models.ImageField(blank=True, null=True, storage=backend.supabase_storage.ImageStorage(), upload_to='incidents/thumbnails/'), + ), + ] diff --git a/Mapapi/migrations/0004_alter_organisation_activity_sector_and_more.py b/Mapapi/migrations/0004_alter_organisation_activity_sector_and_more.py new file mode 100644 index 00000000..04f251ce --- /dev/null +++ b/Mapapi/migrations/0004_alter_organisation_activity_sector_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.7 on 2026-06-29 13:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('Mapapi', '0003_incident_thumbnail'), + ] + + operations = [ + migrations.AlterField( + model_name='organisation', + name='activity_sector', + field=models.CharField(blank=True, choices=[('environment', 'Environnement'), ('sanitation', 'Assainissement'), ('water_wash', 'Eau / WASH'), ('health', 'Santé'), ('food_security', 'Sécurité alimentaire'), ('protection', 'Protection'), ('humanitarian', 'Humanitaire'), ('development', 'Développement'), ('governance', 'Gouvernance'), ('education', 'Éducation'), ('technology_data', 'Technologie / Données'), ('multisector', 'Multisectoriel'), ('other', 'Autre')], max_length=40, null=True), + ), + migrations.AlterField( + model_name='organisation', + name='organisation_type', + field=models.CharField(blank=True, choices=[('ngo', 'ONG'), ('international_organisation', 'Organisation internationale'), ('un_agency', 'Agence UN'), ('public_institution', 'Institution publique'), ('local_authority', 'Collectivité territoriale'), ('association_cso', 'Association / OSC'), ('private_sector', 'Secteur privé'), ('project_program', 'Projet / Programme'), ('community_structure', 'Structure communautaire'), ('other', 'Autre')], max_length=40, null=True), + ), + ] diff --git a/Mapapi/migrations/0004_category_description_zone_description.py b/Mapapi/migrations/0004_category_description_zone_description.py deleted file mode 100644 index a9d8b79e..00000000 --- a/Mapapi/migrations/0004_category_description_zone_description.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.2.7 on 2024-10-27 12:24 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('Mapapi', '0003_alter_prediction_prediction_id'), - ] - - operations = [ - migrations.AddField( - model_name='category', - name='description', - field=models.TextField(blank=True, max_length=500, null=True), - ), - migrations.AddField( - model_name='zone', - name='description', - field=models.TextField(blank=True, max_length=500, null=True), - ), - ] diff --git a/Mapapi/migrations/0005_notification_incident.py b/Mapapi/migrations/0005_notification_incident.py new file mode 100644 index 00000000..ac98f490 --- /dev/null +++ b/Mapapi/migrations/0005_notification_incident.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.7 on 2026-06-29 13:50 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('Mapapi', '0004_alter_organisation_activity_sector_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='notification', + name='incident', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='Mapapi.incident'), + ), + ] diff --git a/Mapapi/migrations/0005_user_is_verified_user_otp_user_otp_expiration_and_more.py b/Mapapi/migrations/0005_user_is_verified_user_otp_user_otp_expiration_and_more.py deleted file mode 100644 index f94f2eff..00000000 --- a/Mapapi/migrations/0005_user_is_verified_user_otp_user_otp_expiration_and_more.py +++ /dev/null @@ -1,36 +0,0 @@ - -# Generated by Django 4.2.7 on 2024-11-01 15:39 - - -import uuid -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('Mapapi', '0004_category_description_zone_description'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='is_verified', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='user', - name='otp', - field=models.CharField(blank=True, max_length=6, null=True), - ), - migrations.AddField( - model_name='user', - name='otp_expiration', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AddField( - model_name='user', - name='verification_token', - field=models.UUIDField(blank=True, default=uuid.uuid4, editable=False, null=True), - ), - ] diff --git a/Mapapi/migrations/0006_alter_useraction_options_useraction_created_at.py b/Mapapi/migrations/0006_alter_useraction_options_useraction_created_at.py new file mode 100644 index 00000000..28fd377f --- /dev/null +++ b/Mapapi/migrations/0006_alter_useraction_options_useraction_created_at.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.7 on 2026-06-29 15:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('Mapapi', '0005_notification_incident'), + ] + + operations = [ + migrations.AlterModelOptions( + name='useraction', + options={'ordering': ('-created_at', '-timeStamp')}, + ), + migrations.AddField( + model_name='useraction', + name='created_at', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + ] diff --git a/Mapapi/migrations/0006_discussionmessage.py b/Mapapi/migrations/0006_discussionmessage.py deleted file mode 100644 index 481b0afc..00000000 --- a/Mapapi/migrations/0006_discussionmessage.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 4.2.7 on 2025-02-27 17:27 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('Mapapi', '0005_user_is_verified_user_otp_user_otp_expiration_and_more'), - ] - - operations = [ - migrations.CreateModel( - name='DiscussionMessage', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('message', models.TextField()), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('collaboration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='Mapapi.collaboration')), - ('incident', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='Mapapi.incident')), - ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/Mapapi/migrations/0007_auto_20250303_0923.py b/Mapapi/migrations/0007_auto_20250303_0923.py deleted file mode 100644 index 032181f1..00000000 --- a/Mapapi/migrations/0007_auto_20250303_0923.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 4.2.7 on 2025-03-03 09:23 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('Mapapi', '0006_discussionmessage'), - ] - - operations = [ - migrations.AddField( - model_name='Collaboration', - name='motivation', - field=models.TextField(blank=True, null=True), - ), - migrations.AddField( - model_name='Collaboration', - name='other_option', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AddField( - model_name='Collaboration', - name='status', - field=models.CharField(max_length=20, default='pending'), - ), - ] diff --git a/Mapapi/migrations/0007_notification_notif_type.py b/Mapapi/migrations/0007_notification_notif_type.py new file mode 100644 index 00000000..64934d73 --- /dev/null +++ b/Mapapi/migrations/0007_notification_notif_type.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.7 on 2026-06-30 10:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('Mapapi', '0006_alter_useraction_options_useraction_created_at'), + ] + + operations = [ + migrations.AddField( + model_name='notification', + name='notif_type', + field=models.CharField(blank=True, default='', max_length=40), + ), + ] diff --git a/Mapapi/migrations/0008_discussionmessage_recipient.py b/Mapapi/migrations/0008_discussionmessage_recipient.py deleted file mode 100644 index 6428beec..00000000 --- a/Mapapi/migrations/0008_discussionmessage_recipient.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 4.2.7 on 2025-03-03 11:21 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('Mapapi', '0007_auto_20250303_0923'), - ] - - operations = [ - migrations.AddField( - model_name='discussionmessage', - name='recipient', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/Mapapi/migrations/0009_alter_collaboration_unique_together.py b/Mapapi/migrations/0009_alter_collaboration_unique_together.py deleted file mode 100644 index 2409d48b..00000000 --- a/Mapapi/migrations/0009_alter_collaboration_unique_together.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.2.7 on 2025-03-03 11:43 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('Mapapi', '0008_discussionmessage_recipient'), - ] - - operations = [ - migrations.AlterUniqueTogether( - name='collaboration', - unique_together={('incident', 'user')}, - ), - ] diff --git a/Mapapi/migrations/0010_organisationtag.py b/Mapapi/migrations/0010_organisationtag.py deleted file mode 100644 index d653969a..00000000 --- a/Mapapi/migrations/0010_organisationtag.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.2.7 on 2025-05-09 17:03 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('Mapapi', '0009_alter_collaboration_unique_together'), - ] - - operations = [ - migrations.CreateModel( - name='OrganisationTag', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('incident_type', models.CharField(max_length=255)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='incident_preferences', to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/Mapapi/migrations/0011_organisation_alter_evenement_audio_and_more.py b/Mapapi/migrations/0011_organisation_alter_evenement_audio_and_more.py deleted file mode 100644 index 75f32288..00000000 --- a/Mapapi/migrations/0011_organisation_alter_evenement_audio_and_more.py +++ /dev/null @@ -1,73 +0,0 @@ -# Generated by Django 4.2.7 on 2025-07-17 10:21 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('Mapapi', '0010_organisationtag'), - ] - - operations = [ - migrations.CreateModel( - name='Organisation', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255, unique=True)), - ('is_premium', models.BooleanField(default=False)), - ('subdomain', models.CharField(max_length=255, unique=True)), - ('logo_url', models.URLField(blank=True, null=True)), - ('primary_color', models.CharField(default='#4CAF50', max_length=7)), - ('secondary_color', models.CharField(default='#8BC34A', max_length=7)), - ('background_color', models.CharField(default='#F0F0F0', max_length=7)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ], - ), - migrations.AlterField( - model_name='evenement', - name='audio', - field=models.FileField(blank=True, null=True, upload_to='events/'), - ), - migrations.AlterField( - model_name='evenement', - name='photo', - field=models.ImageField(blank=True, null=True, upload_to='events/'), - ), - migrations.AlterField( - model_name='evenement', - name='video', - field=models.FileField(blank=True, null=True, upload_to='events/'), - ), - migrations.AlterField( - model_name='incident', - name='audio', - field=models.FileField(blank=True, null=True, upload_to='incidents/'), - ), - migrations.AlterField( - model_name='incident', - name='photo', - field=models.ImageField(blank=True, null=True, upload_to='incidents/'), - ), - migrations.AlterField( - model_name='incident', - name='video', - field=models.FileField(blank=True, null=True, upload_to='incidents/'), - ), - migrations.AlterField( - model_name='rapport', - name='file', - field=models.FileField(blank=True, null=True, upload_to='reports/'), - ), - migrations.AlterField( - model_name='zone', - name='photo', - field=models.ImageField(blank=True, null=True, upload_to='zones/'), - ), - migrations.AlterField( - model_name='user', - name='organisation', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='Mapapi.organisation'), - ), - ] diff --git a/Mapapi/migrations/0012_alter_evenement_audio_alter_evenement_photo_and_more.py b/Mapapi/migrations/0012_alter_evenement_audio_alter_evenement_photo_and_more.py deleted file mode 100644 index 2cb87dfd..00000000 --- a/Mapapi/migrations/0012_alter_evenement_audio_alter_evenement_photo_and_more.py +++ /dev/null @@ -1,64 +0,0 @@ -# Generated by Django 4.2.7 on 2025-08-13 15:42 - -import backend.supabase_storage -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('Mapapi', '0011_organisation_alter_evenement_audio_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='evenement', - name='audio', - field=models.FileField(blank=True, null=True, storage=backend.supabase_storage.VoiceStorage(), upload_to='events/'), - ), - migrations.AlterField( - model_name='evenement', - name='photo', - field=models.ImageField(blank=True, null=True, storage=backend.supabase_storage.ImageStorage(), upload_to='events/'), - ), - migrations.AlterField( - model_name='evenement', - name='video', - field=models.FileField(blank=True, null=True, storage=backend.supabase_storage.VideoStorage(), upload_to='events/'), - ), - migrations.AlterField( - model_name='incident', - name='audio', - field=models.FileField(blank=True, null=True, storage=backend.supabase_storage.VoiceStorage(), upload_to='incidents/'), - ), - migrations.AlterField( - model_name='incident', - name='photo', - field=models.ImageField(blank=True, null=True, storage=backend.supabase_storage.ImageStorage(), upload_to='incidents/'), - ), - migrations.AlterField( - model_name='incident', - name='video', - field=models.FileField(blank=True, null=True, storage=backend.supabase_storage.VideoStorage(), upload_to='incidents/'), - ), - migrations.AlterField( - model_name='rapport', - name='file', - field=models.FileField(blank=True, null=True, storage=backend.supabase_storage.ImageStorage(), upload_to='reports/'), - ), - migrations.AlterField( - model_name='user', - name='avatar', - field=models.ImageField(blank=True, default='avatars/default.png', null=True, storage=backend.supabase_storage.ImageStorage(), upload_to='avatars/'), - ), - migrations.AlterField( - model_name='user', - name='organisation', - field=models.CharField(blank=True, max_length=255, null=True, verbose_name='organisation'), - ), - migrations.AlterField( - model_name='zone', - name='photo', - field=models.ImageField(blank=True, null=True, storage=backend.supabase_storage.ImageStorage(), upload_to='zones/'), - ), - ] diff --git a/Mapapi/migrations/0013_add_ivr_models.py b/Mapapi/migrations/0013_add_ivr_models.py deleted file mode 100644 index 12021ed6..00000000 --- a/Mapapi/migrations/0013_add_ivr_models.py +++ /dev/null @@ -1,45 +0,0 @@ -# Generated manually for IVR models -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('Mapapi', '0012_alter_evenement_audio_alter_evenement_photo_and_more'), # Ajustez selon votre dernière migration - ] - - operations = [ - migrations.CreateModel( - name='IVRCall', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('call_sid', models.CharField(max_length=255, unique=True)), - ('phone_number', models.CharField(max_length=20)), - ('status', models.CharField(default='initiated', max_length=50)), - ('zone_selected', models.CharField(blank=True, max_length=250, null=True)), - ('description_audio_url', models.URLField(blank=True, null=True)), - ('description_audio_duration', models.IntegerField(blank=True, null=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('category_selected', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='Mapapi.category')), - ('incident_created', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='Mapapi.incident')), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='Mapapi.user')), - ], - ), - migrations.CreateModel( - name='IVRInteraction', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('step', models.CharField(max_length=50)), - ('user_input', models.CharField(blank=True, max_length=255, null=True)), - ('recording_url', models.URLField(blank=True, null=True)), - ('recording_duration', models.IntegerField(blank=True, null=True)), - ('timestamp', models.DateTimeField(auto_now_add=True)), - ('ivr_call', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interactions', to='Mapapi.ivrcall')), - ], - options={ - 'ordering': ['timestamp'], - }, - ), - ] diff --git a/Mapapi/migrations/0014_collaboration_role_discussionmessage_attachment_and_more.py b/Mapapi/migrations/0014_collaboration_role_discussionmessage_attachment_and_more.py deleted file mode 100644 index 764da3e7..00000000 --- a/Mapapi/migrations/0014_collaboration_role_discussionmessage_attachment_and_more.py +++ /dev/null @@ -1,97 +0,0 @@ -# Generated by Django 4.2.7 on 2026-05-05 12:33 - -import backend.supabase_storage -from django.conf import settings -import django.core.validators -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('Mapapi', '0013_add_ivr_models'), - ] - - operations = [ - migrations.AddField( - model_name='collaboration', - name='role', - field=models.CharField(choices=[('leader', 'leader'), ('contributor', 'contributor'), ('observer', 'observer')], default='contributor', help_text="Rôle de l'organisation sur l'incident : leader, contributor ou observer.", max_length=20), - ), - migrations.AddField( - model_name='discussionmessage', - name='attachment', - field=models.FileField(blank=True, help_text='Pièce jointe : PDF, Word ou Excel uniquement.', null=True, storage=backend.supabase_storage.DocumentStorage(), upload_to='chat/attachments/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf', 'doc', 'docx', 'xls', 'xlsx'])]), - ), - migrations.AddField( - model_name='discussionmessage', - name='audio', - field=models.FileField(blank=True, null=True, storage=backend.supabase_storage.VoiceStorage(), upload_to='chat/audio/'), - ), - migrations.AddField( - model_name='incident', - name='progress', - field=models.PositiveSmallIntegerField(default=0, help_text='Progression auto-calculée (0-100) selon avancement des tâches.'), - ), - migrations.AddField( - model_name='incident', - name='resolution_end_date', - field=models.DateField(blank=True, help_text='Date de fin de la résolution. Obligatoire à la clôture.', null=True), - ), - migrations.AddField( - model_name='incident', - name='resolution_start_date', - field=models.DateField(blank=True, help_text='Date de début de la résolution. Obligatoire à la clôture.', null=True), - ), - migrations.AlterField( - model_name='collaboration', - name='end_date', - field=models.DateField(blank=True, null=True), - ), - migrations.AlterField( - model_name='discussionmessage', - name='message', - field=models.TextField(blank=True, null=True), - ), - migrations.CreateModel( - name='IncidentTask', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=255)), - ('description', models.TextField(blank=True, null=True)), - ('start_date', models.DateField()), - ('end_date', models.DateField()), - ('state', models.CharField(choices=[('pending', 'pending'), ('in_progress', 'in_progress'), ('done', 'done'), ('failed', 'failed')], default='pending', max_length=20)), - ('proof_image', models.ImageField(blank=True, help_text="Image de preuve quand la tâche est marquée 'done'.", null=True, storage=backend.supabase_storage.ImageStorage(), upload_to='tasks/proofs/')), - ('proof_video', models.FileField(blank=True, help_text="Vidéo de preuve quand la tâche est marquée 'done'.", null=True, storage=backend.supabase_storage.VideoStorage(), upload_to='tasks/proofs/')), - ('failure_reason', models.TextField(blank=True, help_text="Motif d'échec si la tâche est 'failed'.", null=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_tasks', to=settings.AUTH_USER_MODEL)), - ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_tasks', to=settings.AUTH_USER_MODEL)), - ('incident', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='Mapapi.incident')), - ], - options={ - 'ordering': ('start_date', 'id'), - }, - ), - migrations.CreateModel( - name='PartnerSuggestion', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('suggested_role', models.CharField(choices=[('contributor', 'contributor'), ('observer', 'observer')], help_text='Rôle proposé (contributor ou observer uniquement).', max_length=20)), - ('justification', models.TextField(help_text='Court message justifiant la suggestion.')), - ('status', models.CharField(choices=[('pending', 'pending'), ('accepted', 'accepted'), ('rejected', 'rejected')], default='pending', max_length=20)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('incident', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='partner_suggestions', to='Mapapi.incident')), - ('suggested_by', models.ForeignKey(help_text="Contributeur à l'origine de la suggestion.", on_delete=django.db.models.deletion.CASCADE, related_name='suggestions_made', to=settings.AUTH_USER_MODEL)), - ('suggested_partner', models.ForeignKey(help_text='Organisation proposée.', on_delete=django.db.models.deletion.CASCADE, related_name='suggestions_received', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'ordering': ('-created_at',), - 'unique_together': {('incident', 'suggested_partner')}, - }, - ), - ] diff --git a/Mapapi/migrations/0015_user_organisation_member_org_role_agent_code.py b/Mapapi/migrations/0015_user_organisation_member_org_role_agent_code.py deleted file mode 100644 index a77ae485..00000000 --- a/Mapapi/migrations/0015_user_organisation_member_org_role_agent_code.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Lot 1 — Ajout organisation_member (FK), org_role et agent_code sur User. - -Includes a data migration to populate organisation_member from the existing -organisation CharField. -""" -from django.db import migrations, models -import django.db.models.deletion - - -def migrate_org_string_to_fk(apps, schema_editor): - """Copie les valeurs du CharField 'organisation' vers la FK 'organisation_member'. - - Pour chaque user ayant un champ 'organisation' non vide : - - Cherche ou crée l'Organisation correspondante ; - - Affecte la FK ; - - Si user_type == 'elu', met org_role = 'org_admin'. - """ - User = apps.get_model('Mapapi', 'User') - Organisation = apps.get_model('Mapapi', 'Organisation') - - for user in User.objects.exclude(organisation__isnull=True).exclude(organisation=''): - org_name = user.organisation.strip() - if not org_name: - continue - - org, _created = Organisation.objects.get_or_create( - name=org_name, - defaults={ - 'subdomain': org_name.lower().replace(' ', '-').replace("'", ''), - }, - ) - user.organisation_member = org - if user.user_type == 'elu': - user.org_role = 'org_admin' - user.save(update_fields=['organisation_member', 'org_role']) - - -def reverse_migration(apps, schema_editor): - """Reverse : copie organisation_member.name vers le CharField.""" - User = apps.get_model('Mapapi', 'User') - for user in User.objects.filter(organisation_member__isnull=False): - user.organisation = user.organisation_member.name - user.save(update_fields=['organisation']) - - -class Migration(migrations.Migration): - - dependencies = [ - ('Mapapi', '0014_collaboration_role_discussionmessage_attachment_and_more'), - ] - - operations = [ - # 1. Ajouter les 3 nouveaux champs - migrations.AddField( - model_name='user', - name='organisation_member', - field=models.ForeignKey( - blank=True, - help_text="Organisation à laquelle appartient l'utilisateur.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name='members', - to='Mapapi.organisation', - ), - ), - migrations.AddField( - model_name='user', - name='org_role', - field=models.CharField( - blank=True, - choices=[ - ('org_admin', 'Admin organisation'), - ('bureau_agent', 'Agent de bureau'), - ('field_agent', 'Agent de terrain'), - ], - help_text="Rôle interne dans l'organisation (org_admin, bureau_agent, field_agent).", - max_length=20, - null=True, - ), - ), - migrations.AddField( - model_name='user', - name='agent_code', - field=models.CharField( - blank=True, - help_text='Code auto-généré pour la connexion des agents de terrain.', - max_length=10, - null=True, - unique=True, - ), - ), - # 2. Data migration : copier organisation → organisation_member - migrations.RunPython(migrate_org_string_to_fk, reverse_migration), - ] diff --git a/Mapapi/migrations/0016_incident_is_public.py b/Mapapi/migrations/0016_incident_is_public.py deleted file mode 100644 index f145a122..00000000 --- a/Mapapi/migrations/0016_incident_is_public.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Lot 2 — Ajout du champ is_public sur Incident.""" -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('Mapapi', '0015_user_organisation_member_org_role_agent_code'), - ] - - operations = [ - migrations.AddField( - model_name='incident', - name='is_public', - field=models.BooleanField( - default=True, - help_text="Si False, l'incident n'est visible que par l'organisation de l'agent.", - ), - ), - ] diff --git a/Mapapi/migrations/0017_incident_is_deleted_user_is_deleted_fieldreport.py b/Mapapi/migrations/0017_incident_is_deleted_user_is_deleted_fieldreport.py deleted file mode 100644 index 9ba80cb7..00000000 --- a/Mapapi/migrations/0017_incident_is_deleted_user_is_deleted_fieldreport.py +++ /dev/null @@ -1,45 +0,0 @@ -# Generated by Django 4.2.7 on 2026-05-18 14:12 - -import backend.supabase_storage -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import django.utils.timezone - - -class Migration(migrations.Migration): - - dependencies = [ - ('Mapapi', '0016_incident_is_public'), - ] - - operations = [ - migrations.AddField( - model_name='incident', - name='is_deleted', - field=models.BooleanField(default=False, help_text="Si True, l'incident a été supprimé (corbeille)."), - ), - migrations.AddField( - model_name='user', - name='is_deleted', - field=models.BooleanField(default=False, help_text="Si True, l'utilisateur a été supprimé (corbeille)."), - ), - migrations.CreateModel( - name='FieldReport', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('location_lat', models.CharField(blank=True, help_text="Latitude réelle de l'agent lors du déplacement.", max_length=250, null=True)), - ('location_lon', models.CharField(blank=True, help_text="Longitude réelle de l'agent lors du déplacement.", max_length=250, null=True)), - ('distance_meters', models.FloatField(blank=True, help_text="Distance estimée entre l'agent et le lieu de l'incident (m).", null=True)), - ('notes', models.TextField(blank=True, help_text="Observations de l'agent sur l'état des lieux.", max_length=1000, null=True)), - ('photo', models.ImageField(blank=True, null=True, storage=backend.supabase_storage.ImageStorage(), upload_to='field_reports/')), - ('visited_at', models.DateTimeField(default=django.utils.timezone.now, help_text='Date et heure du déplacement.')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('agent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='field_reports', to=settings.AUTH_USER_MODEL)), - ('incident', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='field_reports', to='Mapapi.incident')), - ], - options={ - 'ordering': ('-visited_at',), - }, - ), - ] diff --git a/Mapapi/migrations/0018_organisation_extra_fields_incidentassignment.py b/Mapapi/migrations/0018_organisation_extra_fields_incidentassignment.py deleted file mode 100644 index 5601f57b..00000000 --- a/Mapapi/migrations/0018_organisation_extra_fields_incidentassignment.py +++ /dev/null @@ -1,70 +0,0 @@ -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('Mapapi', '0017_incident_is_deleted_user_is_deleted_fieldreport'), - ] - - operations = [ - migrations.AddField( - model_name='organisation', - name='acronym', - field=models.CharField(blank=True, max_length=50, null=True), - ), - migrations.AddField( - model_name='organisation', - name='activity_sector', - field=models.CharField(blank=True, choices=[('humanitarian', 'Humanitaire'), ('humanitarian_coordination', 'Coordination humanitaire'), ('development', 'Développement'), ('child_protection', "Protection de l'enfance"), ('health', 'Santé'), ('nutrition_food_security', 'Nutrition et sécurité alimentaire'), ('development_humanitarian', 'Développement et humanitaire')], max_length=40, null=True), - ), - migrations.AddField( - model_name='organisation', - name='organisation_type', - field=models.CharField(blank=True, choices=[('ngo', 'ONG'), ('international_organisation', 'Organisation internationale'), ('governmental', 'Gouvernementale'), ('civil_society', 'Société civile')], max_length=40, null=True), - ), - migrations.AddField( - model_name='organisation', - name='intervention_country', - field=models.CharField(blank=True, choices=[('senegal', 'Sénégal'), ('mali', 'Mali'), ('guinea', 'Guinée'), ('burkina_faso', 'Burkina Faso'), ('niger', 'Niger'), ('cote_divoire', 'Côte d’Ivoire'), ('mauritania', 'Mauritanie')], max_length=30, null=True), - ), - migrations.AddField( - model_name='organisation', - name='description', - field=models.TextField(blank=True, null=True), - ), - migrations.AddField( - model_name='organisation', - name='partner_status', - field=models.CharField(choices=[('active', 'Actif'), ('inactive', 'Inactif')], default='active', max_length=20), - ), - migrations.AddField( - model_name='organisation', - name='phone', - field=models.CharField(blank=True, max_length=20, null=True), - ), - migrations.AddField( - model_name='organisation', - name='website_url', - field=models.URLField(blank=True, null=True), - ), - migrations.CreateModel( - name='IncidentAssignment', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('deadline', models.DateTimeField()), - ('status', models.CharField(choices=[('pending', 'En attente'), ('in_progress', 'En cours'), ('reported', 'Rapport effectué'), ('cancelled', 'Annulé')], default='pending', max_length=20)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('agent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='incident_assignments', to=settings.AUTH_USER_MODEL)), - ('assigned_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_incident_assignments', to=settings.AUTH_USER_MODEL)), - ('incident', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to='Mapapi.incident')), - ], - options={ - 'ordering': ('deadline', '-created_at'), - 'unique_together': {('incident', 'agent')}, - }, - ), - ] diff --git a/Mapapi/migrations/0019_prediction_extended.py b/Mapapi/migrations/0019_prediction_extended.py deleted file mode 100644 index 40a4c20e..00000000 --- a/Mapapi/migrations/0019_prediction_extended.py +++ /dev/null @@ -1,174 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('Mapapi', '0018_organisation_extra_fields_incidentassignment'), - ] - - operations = [ - # --- Rename legacy CharField ``incident_id`` so it stops clashing - # with the FK ``incident`` we are about to add (which auto-creates - # an ``incident_id`` attribute/column). - migrations.RenameField( - model_name='prediction', - old_name='incident_id', - new_name='legacy_incident_id', - ), - # --- Make legacy fields nullable so the new flow can create rows - # without filling all the historical columns. - migrations.AlterField( - model_name='prediction', - name='legacy_incident_id', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AlterField( - model_name='prediction', - name='incident_type', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AlterField( - model_name='prediction', - name='piste_solution', - field=models.TextField(blank=True, null=True), - ), - migrations.AlterField( - model_name='prediction', - name='analysis', - field=models.TextField(blank=True, null=True), - ), - - # --- New FK to Incident - migrations.AddField( - model_name='prediction', - name='incident', - field=models.OneToOneField( - blank=True, null=True, - on_delete=models.deletion.CASCADE, - related_name='prediction', - to='Mapapi.incident', - ), - ), - - # --- Status - migrations.AddField( - model_name='prediction', - name='status', - field=models.CharField( - choices=[ - ('pending', 'Pending'), - ('processing', 'Processing'), - ('completed', 'Completed'), - ('completed_with_warning', 'Completed with warning'), - ('failed', 'Failed'), - ], - default='pending', - max_length=32, - ), - ), - - # --- AI analysis - migrations.AddField(model_name='prediction', name='macro_category', - field=models.CharField(blank=True, default='', max_length=255)), - migrations.AddField(model_name='prediction', name='sub_category', - field=models.CharField(blank=True, default='', max_length=255)), - migrations.AddField(model_name='prediction', name='description', - field=models.TextField(blank=True, default='')), - migrations.AddField(model_name='prediction', name='source_size_meters', - field=models.FloatField(blank=True, null=True)), - migrations.AddField(model_name='prediction', name='spread_vectors', - field=models.JSONField(blank=True, default=list)), - - # --- Impact - migrations.AddField(model_name='prediction', name='impact_radius_meters', - field=models.FloatField(blank=True, null=True)), - migrations.AddField(model_name='prediction', name='radius_explanation', - field=models.TextField(blank=True, default='')), - migrations.AddField(model_name='prediction', name='global_impact_score', - field=models.FloatField(blank=True, null=True)), - migrations.AddField(model_name='prediction', name='base_severity', - field=models.IntegerField(blank=True, null=True)), - migrations.AddField(model_name='prediction', name='impact_tags', - field=models.JSONField(blank=True, default=list)), - migrations.AddField(model_name='prediction', name='recommendation', - field=models.TextField(blank=True, default='')), - - # --- Geo - migrations.AddField(model_name='prediction', name='latitude', - field=models.FloatField(blank=True, null=True)), - migrations.AddField(model_name='prediction', name='longitude', - field=models.FloatField(blank=True, null=True)), - migrations.AddField(model_name='prediction', name='city', - field=models.CharField(blank=True, default='', max_length=255)), - migrations.AddField(model_name='prediction', name='region', - field=models.CharField(blank=True, default='', max_length=255)), - migrations.AddField(model_name='prediction', name='country', - field=models.CharField(blank=True, default='', max_length=255)), - migrations.AddField(model_name='prediction', name='display_name', - field=models.TextField(blank=True, default='')), - - # --- Social - migrations.AddField(model_name='prediction', name='social_vulnerability_score', - field=models.FloatField(blank=True, null=True)), - migrations.AddField(model_name='prediction', name='is_social_probabilistic', - field=models.BooleanField(default=False)), - - # --- Population - migrations.AddField(model_name='prediction', name='total_population_exposed', - field=models.IntegerField(default=0)), - migrations.AddField(model_name='prediction', name='adult_men_exposed', - field=models.IntegerField(default=0)), - migrations.AddField(model_name='prediction', name='adult_women_exposed', - field=models.IntegerField(default=0)), - migrations.AddField(model_name='prediction', name='children_exposed', - field=models.IntegerField(default=0)), - migrations.AddField(model_name='prediction', name='maternities_count', - field=models.IntegerField(default=0)), - migrations.AddField(model_name='prediction', name='nurseries_count', - field=models.IntegerField(default=0)), - - # --- Infrastructure - migrations.AddField(model_name='prediction', name='health_centers', - field=models.IntegerField(default=0)), - migrations.AddField(model_name='prediction', name='maternities', - field=models.IntegerField(default=0)), - migrations.AddField(model_name='prediction', name='schools', - field=models.IntegerField(default=0)), - migrations.AddField(model_name='prediction', name='nurseries', - field=models.IntegerField(default=0)), - migrations.AddField(model_name='prediction', name='markets', - field=models.IntegerField(default=0)), - migrations.AddField(model_name='prediction', name='water_points', - field=models.IntegerField(default=0)), - migrations.AddField(model_name='prediction', name='main_roads_bridges', - field=models.IntegerField(default=0)), - migrations.AddField(model_name='prediction', name='residential_buildings', - field=models.IntegerField(default=0)), - - # --- Raw blocks - migrations.AddField(model_name='prediction', name='ai_analysis', - field=models.JSONField(blank=True, default=dict)), - migrations.AddField(model_name='prediction', name='topography', - field=models.JSONField(blank=True, default=dict)), - migrations.AddField(model_name='prediction', name='satellite', - field=models.JSONField(blank=True, default=dict)), - migrations.AddField(model_name='prediction', name='social_data', - field=models.JSONField(blank=True, default=dict)), - migrations.AddField(model_name='prediction', name='human_impact', - field=models.JSONField(blank=True, default=dict)), - migrations.AddField(model_name='prediction', name='geocoding', - field=models.JSONField(blank=True, default=dict)), - migrations.AddField(model_name='prediction', name='potential_risk', - field=models.JSONField(blank=True, null=True)), - migrations.AddField(model_name='prediction', name='full_response', - field=models.JSONField(blank=True, default=dict)), - - # --- Errors / timestamps - migrations.AddField(model_name='prediction', name='error_message', - field=models.TextField(blank=True, default='')), - migrations.AddField(model_name='prediction', name='created_at', - field=models.DateTimeField(auto_now_add=True, null=True)), - migrations.AddField(model_name='prediction', name='updated_at', - field=models.DateTimeField(auto_now=True, null=True)), - ] diff --git a/Mapapi/migrations/0020_chathistory_incident_chat.py b/Mapapi/migrations/0020_chathistory_incident_chat.py deleted file mode 100644 index 73515373..00000000 --- a/Mapapi/migrations/0020_chathistory_incident_chat.py +++ /dev/null @@ -1,78 +0,0 @@ -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('Mapapi', '0019_prediction_extended'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - # Make legacy fields nullable so we can store role/content rows. - migrations.AlterField( - model_name='chathistory', - name='session_id', - field=models.CharField(blank=True, db_index=True, max_length=255, null=True), - ), - migrations.AlterField( - model_name='chathistory', - name='question', - field=models.TextField(blank=True, db_index=True, null=True), - ), - migrations.AlterField( - model_name='chathistory', - name='answer', - field=models.TextField(blank=True, db_index=True, null=True), - ), - - # New fields for per-message chat tied to an Incident. - migrations.AddField( - model_name='chathistory', - name='incident', - field=models.ForeignKey( - blank=True, null=True, - on_delete=models.deletion.CASCADE, - related_name='chat_messages', - to='Mapapi.incident', - ), - ), - migrations.AddField( - model_name='chathistory', - name='user', - field=models.ForeignKey( - blank=True, null=True, - on_delete=models.deletion.SET_NULL, - related_name='chat_messages', - to=settings.AUTH_USER_MODEL, - ), - ), - migrations.AddField( - model_name='chathistory', - name='role', - field=models.CharField( - choices=[ - ('user', 'User'), - ('assistant', 'Assistant'), - ('system', 'System'), - ], - default='user', - max_length=20, - ), - ), - migrations.AddField( - model_name='chathistory', - name='content', - field=models.TextField(blank=True, default=''), - ), - migrations.AddField( - model_name='chathistory', - name='created_at', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterModelOptions( - name='chathistory', - options={'ordering': ('created_at', 'id')}, - ), - ] diff --git a/Mapapi/migrations/0021_user_pin_fields.py b/Mapapi/migrations/0021_user_pin_fields.py deleted file mode 100644 index 6ca60fc5..00000000 --- a/Mapapi/migrations/0021_user_pin_fields.py +++ /dev/null @@ -1,29 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('Mapapi', '0020_chathistory_incident_chat'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='pin_code', - field=models.CharField( - blank=True, - help_text='PIN hashé pour la connexion des agents de terrain (4 chiffres).', - max_length=128, - null=True, - ), - ), - migrations.AddField( - model_name='user', - name='must_change_pin', - field=models.BooleanField( - default=False, - help_text="Si True, l'agent doit changer son PIN à la prochaine connexion.", - ), - ), - ] diff --git a/Mapapi/migrations/0022_user_type_field_agent.py b/Mapapi/migrations/0022_user_type_field_agent.py deleted file mode 100644 index 19a770aa..00000000 --- a/Mapapi/migrations/0022_user_type_field_agent.py +++ /dev/null @@ -1,28 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('Mapapi', '0021_user_pin_fields'), - ] - - operations = [ - migrations.AlterField( - model_name='user', - name='user_type', - field=models.CharField( - choices=[ - ('admin', 'admin'), - ('visitor', 'visitor'), - ('reporter', 'reporter'), - ('citizen', 'citizen'), - ('business', 'business'), - ('elu', 'elu'), - ('field_agent', 'field_agent'), - ], - default='citizen', - max_length=15, - ), - ), - ] diff --git a/Mapapi/migrations/0023_organisation_logo_upload.py b/Mapapi/migrations/0023_organisation_logo_upload.py deleted file mode 100644 index 5413d67e..00000000 --- a/Mapapi/migrations/0023_organisation_logo_upload.py +++ /dev/null @@ -1,28 +0,0 @@ -from django.db import migrations, models - -from backend.supabase_storage import ImageStorage - - -class Migration(migrations.Migration): - - dependencies = [ - ('Mapapi', '0022_user_type_field_agent'), - ] - - operations = [ - migrations.RemoveField( - model_name='organisation', - name='logo_url', - ), - migrations.AddField( - model_name='organisation', - name='logo', - field=models.ImageField( - blank=True, - null=True, - storage=ImageStorage(), - upload_to='organisations/logos/', - help_text="Logo de l'organisation (upload).", - ), - ), - ] diff --git a/Mapapi/migrations/0024_incidenttask_is_confirmed.py b/Mapapi/migrations/0024_incidenttask_is_confirmed.py deleted file mode 100644 index d8522c63..00000000 --- a/Mapapi/migrations/0024_incidenttask_is_confirmed.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.7 on 2026-06-03 11:52 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('Mapapi', '0023_organisation_logo_upload'), - ] - - operations = [ - migrations.AddField( - model_name='incidenttask', - name='is_confirmed', - field=models.BooleanField(default=False, help_text='True si la tâche a été confirmée par le leader. Seules les tâches confirmées comptent dans la progression.'), - ), - ] diff --git a/Mapapi/migrations/0025_incident_take_in_charge_mode.py b/Mapapi/migrations/0025_incident_take_in_charge_mode.py deleted file mode 100644 index 04f4fc54..00000000 --- a/Mapapi/migrations/0025_incident_take_in_charge_mode.py +++ /dev/null @@ -1,25 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('Mapapi', '0024_incidenttask_is_confirmed'), - ] - - operations = [ - migrations.AddField( - model_name='incident', - name='take_in_charge_mode', - field=models.CharField( - blank=True, - choices=[ - ('internal', 'Interne (organisation seule)'), - ('collaborative', 'Collaborative (ouvert aux autres organisations)'), - ], - help_text="Mode de prise en charge choisi par la première organisation.", - max_length=20, - null=True, - ), - ), - ] diff --git a/Mapapi/migrations/0026_alter_collaboration_role_alter_collaboration_status_and_more.py b/Mapapi/migrations/0026_alter_collaboration_role_alter_collaboration_status_and_more.py deleted file mode 100644 index 09962acd..00000000 --- a/Mapapi/migrations/0026_alter_collaboration_role_alter_collaboration_status_and_more.py +++ /dev/null @@ -1,68 +0,0 @@ -# Generated by Django 4.2.7 on 2026-06-30 17:37 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('Mapapi', '0025_incident_take_in_charge_mode'), - ] - - operations = [ - migrations.AlterField( - model_name='collaboration', - name='role', - field=models.CharField(choices=[('leader', 'leader'), ('contributor', 'contributor'), ('observer', 'observer')], db_index=True, default='contributor', help_text="Rôle de l'organisation sur l'incident : leader, contributor ou observer.", max_length=20), - ), - migrations.AlterField( - model_name='collaboration', - name='status', - field=models.CharField(db_index=True, default='pending', max_length=20), - ), - migrations.AlterField( - model_name='incident', - name='created_at', - field=models.DateTimeField(auto_now_add=True, db_index=True), - ), - migrations.AlterField( - model_name='incident', - name='etat', - field=models.CharField(choices=[('declared', 'declared'), ('resolved', 'resolved'), ('in_progress', 'in_progress'), ('taken_into_account', 'taken_into_account')], db_index=True, default='declared', max_length=255), - ), - migrations.AlterField( - model_name='incident', - name='is_deleted', - field=models.BooleanField(db_index=True, default=False, help_text="Si True, l'incident a été supprimé (corbeille)."), - ), - migrations.AlterField( - model_name='incident', - name='is_public', - field=models.BooleanField(db_index=True, default=True, help_text="Si False, l'incident n'est visible que par l'organisation de l'agent."), - ), - migrations.AlterField( - model_name='incident', - name='take_in_charge_mode', - field=models.CharField(blank=True, choices=[('internal', 'Interne (organisation seule)'), ('collaborative', 'Collaborative (ouvert aux autres organisations)')], db_index=True, help_text='Mode de prise en charge choisi par la première organisation.', max_length=20, null=True), - ), - migrations.AlterField( - model_name='incidenttask', - name='is_confirmed', - field=models.BooleanField(db_index=True, default=False, help_text='True si la tâche a été confirmée par le leader. Seules les tâches confirmées comptent dans la progression.'), - ), - migrations.AlterField( - model_name='incidenttask', - name='state', - field=models.CharField(choices=[('pending', 'pending'), ('in_progress', 'in_progress'), ('done', 'done'), ('failed', 'failed')], db_index=True, default='pending', max_length=20), - ), - migrations.AlterField( - model_name='organisation', - name='intervention_country', - field=models.CharField(blank=True, choices=[('mali', 'Mali'), ('senegal', 'Sénégal'), ('guinea', 'Guinée'), ('burkina_faso', 'Burkina Faso'), ('niger', 'Niger'), ('cote_divoire', 'Côte d’Ivoire'), ('mauritania', 'Mauritanie')], max_length=30, null=True), - ), - migrations.AlterField( - model_name='prediction', - name='status', - field=models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('completed', 'Completed'), ('completed_with_warning', 'Completed with warning'), ('failed', 'Failed')], db_index=True, default='pending', max_length=32), - ), - ] diff --git a/Mapapi/models.py b/Mapapi/models.py index fccbe2e5..79fe7610 100644 --- a/Mapapi/models.py +++ b/Mapapi/models.py @@ -27,6 +27,27 @@ RESOLVED = 'resolved' IN_PROGRESS = "in_progress" TAKEN = "taken_into_account" +# --- Phase 4 : nouveaux états du flux de résolution (additifs, non destructifs) --- +RESOLUTION_PREPARED = 'resolution_prepared' # Résolution préparée (attente Admin) +IN_VALIDATION = 'in_validation' # Résolu (en validation) +RESOLVED_DEFINITIVE = 'resolved_definitive' # Résolu (définitif) + +# --- Anti-gel (spec T3 / §5) : sévérité catégorielle de l'incident --- +# Pilote le délai d'échec de prise en compte : Élevée 30 j / Moyenne 60 j / Faible 90 j. +SEVERITY_HIGH = 'high' +SEVERITY_MEDIUM = 'medium' +SEVERITY_LOW = 'low' +SEVERITY_CHOICES = ( + (SEVERITY_HIGH, "Élevée"), + (SEVERITY_MEDIUM, "Moyenne"), + (SEVERITY_LOW, "Faible"), +) +# Mapping sévérité -> délai anti-gel en jours (None => fallback ANTI_GEL_DEFAULT_DAYS). +ANTI_GEL_DEADLINE_DAYS = { + SEVERITY_HIGH: 30, + SEVERITY_MEDIUM: 60, + SEVERITY_LOW: 90, +} USER_TYPES = ( (ADMIN, ADMIN), @@ -41,7 +62,11 @@ (DECLARED, DECLARED), (RESOLVED, RESOLVED), (IN_PROGRESS, IN_PROGRESS), - (TAKEN, TAKEN) + (TAKEN, TAKEN), + # --- Phase 4 : nouveaux états du flux de résolution --- + (RESOLUTION_PREPARED, "Résolution préparée (attente Admin)"), + (IN_VALIDATION, "Résolu (en validation)"), + (RESOLVED_DEFINITIVE, "Résolu (définitif)"), ) ETAT_RAPPORT = ( ("new", "new"), @@ -71,6 +96,24 @@ (TASK_FAILED, TASK_FAILED), ) +# --- Collaboration statuses (cf. spec §5 : En attente → Active → Terminée / Refusée) --- +# Valeurs historiques conservées (pending/accepted/declined). Ajout des états +# terminaux 'terminated' (Terminée) et 'refused' (Refusée). Choices uniquement : +# le champ status est un CharField sans `choices=` au niveau modèle, donc aucune +# migration n'est requise. +COLLAB_STATUS_PENDING = 'pending' +COLLAB_STATUS_ACCEPTED = 'accepted' +COLLAB_STATUS_DECLINED = 'declined' +COLLAB_STATUS_TERMINATED = 'terminated' +COLLAB_STATUS_REFUSED = 'refused' +COLLAB_STATUSES = ( + (COLLAB_STATUS_PENDING, 'En attente'), + (COLLAB_STATUS_ACCEPTED, 'Active'), + (COLLAB_STATUS_DECLINED, 'Refusée'), + (COLLAB_STATUS_TERMINATED, 'Terminée'), + (COLLAB_STATUS_REFUSED, 'Refusée'), +) + SUGGESTION_PENDING = 'pending' SUGGESTION_ACCEPTED = 'accepted' SUGGESTION_REJECTED = 'rejected' @@ -98,32 +141,58 @@ (ORG_ROLE_FIELD, 'Agent de terrain'), ) +# --- Secteurs d'activité d'une organisation (valeur stockée → libellé FR) --- +ORG_SECTOR_ENVIRONMENT = 'environment' +ORG_SECTOR_SANITATION = 'sanitation' +ORG_SECTOR_WATER_WASH = 'water_wash' +ORG_SECTOR_HEALTH = 'health' +ORG_SECTOR_FOOD_SECURITY = 'food_security' +ORG_SECTOR_PROTECTION = 'protection' ORG_SECTOR_HUMANITARIAN = 'humanitarian' -ORG_SECTOR_HUMANITARIAN_COORDINATION = 'humanitarian_coordination' ORG_SECTOR_DEVELOPMENT = 'development' -ORG_SECTOR_CHILD_PROTECTION = 'child_protection' -ORG_SECTOR_HEALTH = 'health' -ORG_SECTOR_NUTRITION_FOOD_SECURITY = 'nutrition_food_security' -ORG_SECTOR_DEVELOPMENT_HUMANITARIAN = 'development_humanitarian' +ORG_SECTOR_GOVERNANCE = 'governance' +ORG_SECTOR_EDUCATION = 'education' +ORG_SECTOR_TECHNOLOGY_DATA = 'technology_data' +ORG_SECTOR_MULTISECTOR = 'multisector' +ORG_SECTOR_OTHER = 'other' ORG_ACTIVITY_SECTORS = ( + (ORG_SECTOR_ENVIRONMENT, 'Environnement'), + (ORG_SECTOR_SANITATION, 'Assainissement'), + (ORG_SECTOR_WATER_WASH, 'Eau / WASH'), + (ORG_SECTOR_HEALTH, 'Santé'), + (ORG_SECTOR_FOOD_SECURITY, 'Sécurité alimentaire'), + (ORG_SECTOR_PROTECTION, 'Protection'), (ORG_SECTOR_HUMANITARIAN, 'Humanitaire'), - (ORG_SECTOR_HUMANITARIAN_COORDINATION, 'Coordination humanitaire'), (ORG_SECTOR_DEVELOPMENT, 'Développement'), - (ORG_SECTOR_CHILD_PROTECTION, "Protection de l'enfance"), - (ORG_SECTOR_HEALTH, 'Santé'), - (ORG_SECTOR_NUTRITION_FOOD_SECURITY, 'Nutrition et sécurité alimentaire'), - (ORG_SECTOR_DEVELOPMENT_HUMANITARIAN, 'Développement et humanitaire'), + (ORG_SECTOR_GOVERNANCE, 'Gouvernance'), + (ORG_SECTOR_EDUCATION, 'Éducation'), + (ORG_SECTOR_TECHNOLOGY_DATA, 'Technologie / Données'), + (ORG_SECTOR_MULTISECTOR, 'Multisectoriel'), + (ORG_SECTOR_OTHER, 'Autre'), ) +# --- Types d'organisation (valeur stockée → libellé FR) --- ORG_TYPE_NGO = 'ngo' ORG_TYPE_INTERNATIONAL = 'international_organisation' -ORG_TYPE_GOVERNMENTAL = 'governmental' -ORG_TYPE_CIVIL_SOCIETY = 'civil_society' +ORG_TYPE_UN_AGENCY = 'un_agency' +ORG_TYPE_PUBLIC_INSTITUTION = 'public_institution' +ORG_TYPE_LOCAL_AUTHORITY = 'local_authority' +ORG_TYPE_ASSOCIATION_CSO = 'association_cso' +ORG_TYPE_PRIVATE_SECTOR = 'private_sector' +ORG_TYPE_PROJECT_PROGRAM = 'project_program' +ORG_TYPE_COMMUNITY_STRUCTURE = 'community_structure' +ORG_TYPE_OTHER = 'other' ORG_TYPES = ( (ORG_TYPE_NGO, 'ONG'), (ORG_TYPE_INTERNATIONAL, 'Organisation internationale'), - (ORG_TYPE_GOVERNMENTAL, 'Gouvernementale'), - (ORG_TYPE_CIVIL_SOCIETY, 'Société civile'), + (ORG_TYPE_UN_AGENCY, 'Agence UN'), + (ORG_TYPE_PUBLIC_INSTITUTION, 'Institution publique'), + (ORG_TYPE_LOCAL_AUTHORITY, 'Collectivité territoriale'), + (ORG_TYPE_ASSOCIATION_CSO, 'Association / OSC'), + (ORG_TYPE_PRIVATE_SECTOR, 'Secteur privé'), + (ORG_TYPE_PROJECT_PROGRAM, 'Projet / Programme'), + (ORG_TYPE_COMMUNITY_STRUCTURE, 'Structure communautaire'), + (ORG_TYPE_OTHER, 'Autre'), ) COUNTRY_SENEGAL = 'senegal' @@ -134,8 +203,8 @@ COUNTRY_COTE_DIVOIRE = 'cote_divoire' COUNTRY_MAURITANIA = 'mauritania' INTERVENTION_COUNTRIES = ( - (COUNTRY_MALI, 'Mali'), (COUNTRY_SENEGAL, 'Sénégal'), + (COUNTRY_MALI, 'Mali'), (COUNTRY_GUINEA, 'Guinée'), (COUNTRY_BURKINA_FASO, 'Burkina Faso'), (COUNTRY_NIGER, 'Niger'), @@ -161,9 +230,30 @@ (ASSIGNMENT_CANCELLED, 'Annulé'), ) +# Phase 4 — assignation d'un incident à une ORGANISATION par le Super Admin +# (spec §2/§3, T5 : jamais directement à un agent). L'Admin de l'organisation +# cible accepte/décline sous 72 h, sinon acceptation tacite (D4). +ORG_ASSIGNMENT_PENDING = 'pending' +ORG_ASSIGNMENT_ACCEPTED = 'accepted' +ORG_ASSIGNMENT_DECLINED = 'declined' +ORG_ASSIGNMENT_STATUSES = ( + (ORG_ASSIGNMENT_PENDING, 'En attente'), + (ORG_ASSIGNMENT_ACCEPTED, 'Acceptée'), + (ORG_ASSIGNMENT_DECLINED, 'Déclinée'), +) + + +class UUIDModel(models.Model): + """Base abstraite : clé primaire UUID (au lieu d'un entier auto-incrémenté) + pour éviter l'énumération/devinette des identifiants (faille IDOR).""" + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + class Meta: + abstract = True + # Modèle d'organisation pour gérer les organisations liées aux utilisateurs -class Organisation(models.Model): +class Organisation(UUIDModel): name = models.CharField(max_length=255, unique=True) acronym = models.CharField(max_length=50, blank=True, null=True) is_premium = models.BooleanField(default=False) @@ -271,6 +361,8 @@ def create_superuser(self, email, password, **extra_fields): class User(AbstractBaseUser, PermissionsMixin): + # Clé primaire UUID (cf. UUIDModel ; User n'en hérite pas car AbstractBaseUser). + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) groups = models.ManyToManyField( Group, related_name="mapapi_user_groups", @@ -422,7 +514,7 @@ def check_pin(self, pin): return check_password(pin, self.pin_code) -class FieldReport(models.Model): +class FieldReport(UUIDModel): """Rapport de déplacement d'un agent de terrain sur le lieu d'un incident.""" agent = models.ForeignKey(User, on_delete=models.CASCADE, related_name='field_reports') incident = models.ForeignKey('Incident', on_delete=models.CASCADE, related_name='field_reports') @@ -448,7 +540,7 @@ class Meta: ordering = ('-visited_at',) -class IncidentAssignment(models.Model): +class IncidentAssignment(UUIDModel): incident = models.ForeignKey('Incident', on_delete=models.CASCADE, related_name='assignments') agent = models.ForeignKey(User, on_delete=models.CASCADE, related_name='incident_assignments') assigned_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='created_incident_assignments') @@ -464,17 +556,54 @@ class Meta: def __str__(self): return f"{self.incident} assigné à {self.agent} avant {self.deadline:%d/%m/%Y %H:%M}" -class Incident(models.Model): + +class IncidentOrgAssignment(UUIDModel): + """Assignation d'un incident à une ORGANISATION par le Super Admin (spec §2/§3, T5). + + Distinct du modèle AGENT-centrique `IncidentAssignment` : ici la cible est + une organisation, jamais un agent. L'Admin de l'organisation cible accepte + ou décline sous 72 h ; sans réponse, acceptation tacite (D4). À l'acceptation, + l'organisation engage l'incident (« Pris en compte »). + """ + incident = models.ForeignKey( + 'Incident', on_delete=models.CASCADE, related_name='org_assignments' + ) + organisation = models.ForeignKey(Organisation, on_delete=models.CASCADE) + assigned_by = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True, + related_name='created_org_assignments' + ) + status = models.CharField( + max_length=20, choices=ORG_ASSIGNMENT_STATUSES, default=ORG_ASSIGNMENT_PENDING + ) + decline_reason = models.TextField(null=True, blank=True) + deadline = models.DateTimeField(help_text="Échéance d'acceptation (now + 72 h).") + created_at = models.DateTimeField(auto_now_add=True) + responded_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ('-created_at',) + + def __str__(self): + return f"Incident {self.incident_id} → orga {self.organisation_id} ({self.status})" + + +class Incident(UUIDModel): title = models.CharField(max_length=250, blank=True, null=True) zone = models.CharField(max_length=250, blank=False, null=False) description = models.TextField(max_length=500, blank=True, null=True) - photo = models.ImageField(upload_to='incidents/', - storage=ImageStorage(), + photo = models.ImageField(upload_to='incidents/', + storage=ImageStorage(), null=True, blank=True) - video = models.FileField(upload_to='incidents/', - storage=VideoStorage(), + # Miniature générée automatiquement à partir de `photo` (cf. save()) pour + # alléger le chargement de l'onglet incidents. Lecture seule côté API. + thumbnail = models.ImageField(upload_to='incidents/thumbnails/', + storage=ImageStorage(), + null=True, blank=True) + video = models.FileField(upload_to='incidents/', + storage=VideoStorage(), blank=True, null=True) audio = models.FileField(upload_to='incidents/', storage=VoiceStorage(), @@ -486,7 +615,7 @@ class Incident(models.Model): longitude = models.CharField(max_length=250, blank=True, null=True) etat = models.CharField( - max_length=255, choices=ETAT_INCIDENT, blank=False, null=False, default=DECLARED, db_index=True) + max_length=255, choices=ETAT_INCIDENT, blank=False, null=False, default=DECLARED) category_id = models.ForeignKey('Category', db_column='categ_incid_id', related_name='user_category', on_delete=models.CASCADE, null=True) indicateur_id = models.ForeignKey('Indicateur', db_column='indic_incid_id', related_name='user_indicateur', @@ -494,7 +623,7 @@ class Incident(models.Model): slug = models.CharField(max_length=250, blank=True, null=True) category_ids = models.ManyToManyField('Category', blank=True) - created_at = models.DateTimeField(auto_now_add=True, db_index=True) + created_at = models.DateTimeField(auto_now_add=True) taken_by = models.ForeignKey(User, related_name='taken_incidents', null=True, blank=True, on_delete=models.SET_NULL) # Mode de prise en charge : 'internal' (org seule en interne) ou 'collaborative' (ouvert aux autres orgs) TAKE_IN_CHARGE_MODES = ( @@ -502,7 +631,7 @@ class Incident(models.Model): ('collaborative', 'Collaborative (ouvert aux autres organisations)'), ) take_in_charge_mode = models.CharField( - max_length=20, choices=TAKE_IN_CHARGE_MODES, null=True, blank=True, db_index=True, + max_length=20, choices=TAKE_IN_CHARGE_MODES, null=True, blank=True, help_text="Mode de prise en charge choisi par la première organisation." ) # --- Spec : suivi résolution incident --- @@ -512,14 +641,91 @@ class Incident(models.Model): help_text="Date de fin de la résolution. Obligatoire à la clôture.") progress = models.PositiveSmallIntegerField(default=0, help_text="Progression auto-calculée (0-100) selon avancement des tâches.") - is_public = models.BooleanField(default=True, db_index=True, + is_public = models.BooleanField(default=True, help_text="Si False, l'incident n'est visible que par l'organisation de l'agent.") - is_deleted = models.BooleanField(default=False, db_index=True, + is_deleted = models.BooleanField(default=False, help_text="Si True, l'incident a été supprimé (corbeille).") + deleted_at = models.DateTimeField( + null=True, blank=True, + help_text="Horodatage de la mise en corbeille (is_deleted=True). Sert à la purge des 30 j (spec D10)." + ) + taken_in_charge_at = models.DateTimeField( + null=True, blank=True, + help_text="Horodatage de la prise en compte (passage en 'taken_into_account'). " + "Sert au calcul du délai anti-gel (spec T3)." + ) + # --- Anti-gel (spec T3 / §5) : sévérité + drapeaux d'avertissement (75 % / 90 %) --- + severity = models.CharField( + max_length=10, choices=SEVERITY_CHOICES, null=True, blank=True, + default=SEVERITY_MEDIUM, + help_text="Sévérité catégorielle. Pilote le délai anti-gel " + "(Élevée 30 j / Moyenne 60 j / Faible 90 j ; défaut/inconnue : 60 j)." + ) + antigel_warned_75 = models.BooleanField( + default=False, + help_text="Avertissement anti-gel à 75 % du délai déjà envoyé au leader. " + "Remis à False à chaque (re)prise en compte (spec T3)." + ) + antigel_warned_90 = models.BooleanField( + default=False, + help_text="Avertissement anti-gel à 90 % du délai déjà envoyé au leader. " + "Remis à False à chaque (re)prise en compte (spec T3)." + ) + # --- Phase 4 : flux de résolution (résolution préparée → validation → définitif) --- + resolution_submitted_by = models.ForeignKey( + User, related_name='+', null=True, blank=True, on_delete=models.SET_NULL, + help_text="Membre (agent de bureau/admin) ayant monté le dossier de résolution." + ) + resolution_submitted_at = models.DateTimeField( + null=True, blank=True, + help_text="Date de soumission du dossier de résolution préparée." + ) + validation_deadline = models.DateTimeField( + null=True, blank=True, + help_text="Échéance de contrôle Super Admin (72h après déclaration de résolution)." + ) + rejection_reason = models.TextField( + null=True, blank=True, + help_text="Motif de refus de la résolution par le Super Admin." + ) def __str__(self): return self.zone + ' ' + def save(self, *args, **kwargs): + # Génère une miniature (≈320px) à partir de la photo, une seule fois, pour + # alléger le chargement de l'onglet incidents. N'échoue jamais la sauvegarde + # de l'incident si la génération de la miniature échoue. + if self.photo and not self.thumbnail: + self._generate_thumbnail() + # Un incident résolu/clôturé est à 100% de progression — même s'il n'a aucune + # tâche (sinon `progress` restait à 0 et le front affichait 0% pour un incident + # résolu). On ajoute 'progress' à update_fields si fourni, pour bien le persister. + if self.etat in (RESOLVED, RESOLVED_DEFINITIVE) and self.progress != 100: + self.progress = 100 + update_fields = kwargs.get('update_fields') + if update_fields is not None: + kwargs['update_fields'] = list(set(update_fields) | {'progress'}) + super().save(*args, **kwargs) + + def _generate_thumbnail(self, size=(320, 320)): + import logging + from io import BytesIO + from PIL import Image + from django.core.files.base import ContentFile + try: + self.photo.seek(0) + data = self.photo.read() + self.photo.seek(0) # réinitialise le flux : la photo doit se sauvegarder intacte + img = Image.open(BytesIO(data)).convert('RGB') + img.thumbnail(size) # conserve le ratio, borne à size + buf = BytesIO() + img.save(buf, format='JPEG', quality=80, optimize=True) + self.thumbnail.save(f"thumb_{self.pk}.jpg", ContentFile(buf.getvalue()), save=False) + except Exception as exc: # noqa: BLE001 + logging.getLogger(__name__).warning( + "Génération miniature échouée pour l'incident %s: %s", self.pk, exc) + def update_progress(self, save=True): """Recalcule la progression de l'incident en fonction de ses tâches confirmées. @@ -528,13 +734,17 @@ def update_progress(self, save=True): Une tâche 'failed' est considérée comme close (poids 1) mais ne contribue pas à 100%. Progression = round(done / total * 100). """ - tasks = self.tasks.filter(is_confirmed=True) - total = tasks.count() - if total == 0: - self.progress = 0 + # Un incident résolu est à 100% quoi qu'il arrive (clôture = terminé). + if self.etat in (RESOLVED, RESOLVED_DEFINITIVE): + self.progress = 100 else: - done = tasks.filter(state=TASK_DONE).count() - self.progress = round(done * 100 / total) + tasks = self.tasks.filter(is_confirmed=True) + total = tasks.count() + if total == 0: + self.progress = 0 + else: + done = tasks.filter(state=TASK_DONE).count() + self.progress = round(done * 100 / total) if save: self.save(update_fields=['progress']) return self.progress @@ -559,7 +769,7 @@ def reported_by_agent(self): return False -class Evenement(models.Model): +class Evenement(UUIDModel): title = models.CharField(max_length=255, blank=True, null=True) zone = models.CharField(max_length=255, blank=False, @@ -587,7 +797,7 @@ def __str__(self): return self.zone + ' ' -class Contact(models.Model): +class Contact(UUIDModel): objet = models.CharField(max_length=250, blank=False, null=False) message = models.TextField(max_length=500, blank=True, null=True) @@ -599,7 +809,7 @@ def __str__(self): return self.objet + ' ' -class Communaute(models.Model): +class Communaute(UUIDModel): name = models.CharField(max_length=250, blank=False, null=False) zone = models.ForeignKey('Zone', db_column='zone_communaute_id', related_name='Zone_communaute', @@ -610,7 +820,7 @@ def __str__(self): return self.name + ' ' -class Rapport(models.Model): +class Rapport(UUIDModel): details = models.CharField(max_length=500, blank=False, null=False) type = models.CharField(max_length=500, blank=True, @@ -635,7 +845,7 @@ def __str__(self): return self.details + ' ' -class Participate(models.Model): +class Participate(UUIDModel): evenement_id = models.ForeignKey('Evenement', db_column='event_participate_id', related_name='event_participate', on_delete=models.CASCADE, null=True) user_id = models.ForeignKey('User', db_column='user_participate_id', related_name='user_participate', @@ -643,7 +853,7 @@ class Participate(models.Model): created_at = models.DateTimeField(auto_now_add=True) -class Zone(models.Model): +class Zone(UUIDModel): name = models.CharField(max_length=250, blank=False, null=False, unique=True) description = models.TextField(max_length=500, blank=True, null=True) # Added description field @@ -660,7 +870,7 @@ def __str__(self): return self.name + ' ' -class Message(models.Model): +class Message(UUIDModel): objet = models.CharField(max_length=250, blank=False, null=False) message = models.CharField(max_length=250, blank=False, null=False) @@ -677,7 +887,7 @@ def __str__(self): return self.objet + ' ' -class ResponseMessage(models.Model): +class ResponseMessage(UUIDModel): response = models.CharField(max_length=250, blank=False, null=False) message = models.ForeignKey('Message', db_column='mess_resp_id', related_name='resp_mess', on_delete=models.CASCADE, @@ -690,7 +900,7 @@ def __str__(self): return self.response + ' ' -class Category(models.Model): +class Category(UUIDModel): name = models.CharField(max_length=250, blank=False, null=False, unique=True) description = models.TextField(max_length=500, blank=True, null=True) # Added description field @@ -702,7 +912,7 @@ def __str__(self): return self.name + ' ' -class Indicateur(models.Model): +class Indicateur(UUIDModel): name = models.CharField(max_length=250, blank=False, null=False, unique=True) created_at = models.DateTimeField(auto_now_add=True) @@ -711,7 +921,7 @@ def __str__(self): return self.name + ' ' -class PasswordReset(models.Model): +class PasswordReset(UUIDModel): code = models.CharField(max_length=7, blank=False, null=False) user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=False, null=False, on_delete=models.CASCADE) date_created = models.DateTimeField(auto_now_add=True) @@ -719,27 +929,27 @@ class PasswordReset(models.Model): date_used = models.DateTimeField(null=True) -class ImageBackground(models.Model): +class ImageBackground(UUIDModel): photo = models.ImageField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) # verification code otp -class PhoneOTP(models.Model): +class PhoneOTP(UUIDModel): phone_number = models.CharField(max_length=15) otp_code = models.CharField(max_length=6) created_at = models.DateTimeField(auto_now_add=True) # Collaboration table -class Collaboration(models.Model): +class Collaboration(UUIDModel): incident = models.ForeignKey('Incident', blank=False, null=False, on_delete=models.CASCADE) user = models.ForeignKey(User, blank=False, null=False, on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) end_date = models.DateField(blank=True, null=True) motivation = models.TextField(blank=True, null=True) other_option = models.CharField(max_length=255, blank=True, null=True) - status = models.CharField(max_length=20, default='pending', db_index=True) - role = models.CharField(max_length=20, choices=COLLAB_ROLES, default=COLLAB_ROLE_CONTRIBUTOR, db_index=True, + status = models.CharField(max_length=20, default='pending') + role = models.CharField(max_length=20, choices=COLLAB_ROLES, default=COLLAB_ROLE_CONTRIBUTOR, help_text="Rôle de l'organisation sur l'incident : leader, contributor ou observer.") class Meta: @@ -749,7 +959,7 @@ def __str__(self): return f"Collaboration on {self.incident} by {self.user} ({self.role})" # Collaboration table -class Colaboration(models.Model): +class Colaboration(UUIDModel): incident = models.ForeignKey('Incident', blank=False, null=False, on_delete=models.CASCADE) user = models.ForeignKey(User, blank=False, null=False, on_delete=models.CASCADE) end_date = models.DateField() @@ -769,7 +979,7 @@ class PredictionStatus(models.TextChoices): FAILED = "failed", "Failed" -class Prediction(models.Model): +class Prediction(UUIDModel): # --- Legacy fields (kept nullable for backward compatibility) --- # NOTE: ``incident_id`` was the legacy CharField. It has been renamed to # ``legacy_incident_id`` because the new ``incident`` ForeignKey below @@ -790,7 +1000,7 @@ class Prediction(models.Model): ) status = models.CharField( max_length=32, choices=PredictionStatus.choices, - default=PredictionStatus.PENDING, db_index=True + default=PredictionStatus.PENDING ) macro_category = models.CharField(max_length=255, blank=True, default='') @@ -872,16 +1082,65 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) -class Notification(models.Model): +NOTIF_TYPE_TITLES = { + 'collaboration_request': 'Demande de collaboration', + 'collaboration_accepted': 'Collaboration acceptée', + 'collaboration_declined': 'Collaboration refusée', + 'deadline_warning': 'Alerte délai', + 'incident_report': 'Nouvel incident signalé', + 'incident_assignment': 'Incident assigné', +} + + +class Notification(UUIDModel): user = models.ForeignKey(User, on_delete=models.CASCADE) message = models.CharField(max_length=255) + # Catégorie de la notification (pour titre/icône côté front, plus de titre en + # dur). cf. NOTIF_TYPE_TITLES pour le libellé FR par défaut. + notif_type = models.CharField(max_length=40, blank=True, default='') created_at = models.DateTimeField(auto_now_add=True) read = models.BooleanField(default=False) - colaboration = models.ForeignKey(Collaboration, on_delete=models.CASCADE) + # Rendu nullable (Phase 4 — Feature 3 « Signaler à mon Admin ») : une + # notification peut désormais exister sans collaboration rattachée. + colaboration = models.ForeignKey(Collaboration, on_delete=models.CASCADE, null=True, blank=True) + # Incident concerné (pour la redirection au clic sur la notification). + incident = models.ForeignKey('Incident', on_delete=models.CASCADE, related_name='notifications', null=True, blank=True) def __str__(self): return self.message - + + @property + def title(self): + """Titre FR par défaut selon le type (le front peut le surcharger).""" + return NOTIF_TYPE_TITLES.get(self.notif_type, 'Notification') + + def redirect_link(self): + """Cible de redirection au clic sur la notification (ou None). + + - ``collaboration_request`` (demande REÇUE par le leader) → page collaboration, + onglet « Demandes » (là où on accepte/refuse), PAS le détail. + - autre notif de collaboration (acceptée/refusée…) → détail de la collaboration, + via le **collaboration_id** (et non l'incident_id — c'est l'id attendu par la + route ``/collaboration-detail/``). + - sinon, si un incident est lié → page de l'incident. + """ + incident_id = self.incident_id or getattr(getattr(self, 'colaboration', None), 'incident_id', None) + if self.colaboration_id: + collab_id = str(self.colaboration_id) + inc = str(incident_id) if incident_id else None + if self.notif_type == 'collaboration_request': + return {'type': 'collaboration_request', 'tab': 'requests', + 'collaboration_id': collab_id, 'incident_id': inc, + 'url': '/collaboration?tab=requests'} + return {'type': 'collaboration', 'tab': 'mes-collaborations', + 'collaboration_id': collab_id, 'incident_id': inc, + 'url': f'/collaboration-detail/{collab_id}'} + if incident_id: + return {'type': 'incident', 'incident_id': str(incident_id), + 'url': f'/incidents/{incident_id}'} + return None + + CHAT_ROLE_USER = 'user' CHAT_ROLE_ASSISTANT = 'assistant' CHAT_ROLE_SYSTEM = 'system' @@ -892,13 +1151,8 @@ def __str__(self): ) -class ChatHistory(models.Model): - # --- Legacy fields (kept nullable for backward compatibility) --- - session_id = models.CharField(max_length=255, db_index=True, blank=True, null=True) - question = models.TextField(db_index=True, blank=True, null=True) - answer = models.TextField(db_index=True, blank=True, null=True) - - # --- New per-message fields tied to an Incident --- +class ChatHistory(UUIDModel): + # Un message du chat IA d'un incident, propre à un utilisateur (incident + user). incident = models.ForeignKey( 'Incident', on_delete=models.CASCADE, related_name='chat_messages', null=True, blank=True, @@ -917,15 +1171,21 @@ class Meta: def __str__(self): return f"[{self.role}] {self.content[:60]}" -class UserAction(models.Model): +class UserAction(UUIDModel): user = models.ForeignKey(User, on_delete=models.CASCADE, blank=False, null=False) action = models.CharField(max_length=255) timeStamp = models.DateField(auto_now_add=True) + # Horodatage précis (le `timeStamp` historique est une date seule) — utilisé par + # le flux d'activité pour afficher "il y a X min". Nullable pour les lignes existantes. + created_at = models.DateTimeField(auto_now_add=True, null=True, blank=True) + + class Meta: + ordering = ('-created_at', '-timeStamp') def __str__(self): return self.action -class DiscussionMessage(models.Model): +class DiscussionMessage(UUIDModel): incident = models.ForeignKey('Incident', on_delete=models.CASCADE) collaboration = models.ForeignKey(Collaboration, on_delete=models.CASCADE) sender = models.ForeignKey(User, on_delete=models.CASCADE) @@ -953,13 +1213,13 @@ def clean(self): # --- Tâches d'incident (gérées par le leader) --- -class IncidentTask(models.Model): +class IncidentTask(UUIDModel): incident = models.ForeignKey('Incident', related_name='tasks', on_delete=models.CASCADE) title = models.CharField(max_length=255) description = models.TextField(blank=True, null=True) start_date = models.DateField() end_date = models.DateField() - state = models.CharField(max_length=20, choices=TASK_STATES, default=TASK_PENDING, db_index=True) + state = models.CharField(max_length=20, choices=TASK_STATES, default=TASK_PENDING) proof_image = models.ImageField(upload_to='tasks/proofs/', storage=ImageStorage(), null=True, blank=True, help_text="Image de preuve quand la tâche est marquée 'done'.") @@ -972,7 +1232,7 @@ class IncidentTask(models.Model): on_delete=models.SET_NULL) created_by = models.ForeignKey(User, related_name='created_tasks', on_delete=models.CASCADE) is_confirmed = models.BooleanField( - default=False, db_index=True, + default=False, help_text="True si la tâche a été confirmée par le leader. " "Seules les tâches confirmées comptent dans la progression." ) @@ -1030,7 +1290,7 @@ def delete(self, *args, **kwargs): # --- Suggestions de partenaires (par contributors, validées par le leader) --- -class PartnerSuggestion(models.Model): +class PartnerSuggestion(UUIDModel): incident = models.ForeignKey('Incident', related_name='partner_suggestions', on_delete=models.CASCADE) suggested_by = models.ForeignKey(User, related_name='suggestions_made', on_delete=models.CASCADE, help_text="Contributeur à l'origine de la suggestion.") @@ -1050,12 +1310,12 @@ class Meta: def __str__(self): return f"Suggestion {self.suggested_partner} ({self.suggested_role}) on {self.incident} - {self.status}" -class OrganisationTag(models.Model): +class OrganisationTag(UUIDModel): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='incident_preferences') incident_type = models.CharField(max_length=255) -class IVRCall(models.Model): +class IVRCall(UUIDModel): call_sid = models.CharField(max_length=255, unique=True) phone_number = models.CharField(max_length=20) status = models.CharField(max_length=50, default='initiated') @@ -1072,7 +1332,7 @@ def __str__(self): return f"IVR Call {self.call_sid} - {self.phone_number}" -class IVRInteraction(models.Model): +class IVRInteraction(UUIDModel): ivr_call = models.ForeignKey('IVRCall', on_delete=models.CASCADE, related_name='interactions') step = models.CharField(max_length=50) user_input = models.CharField(max_length=255, blank=True, null=True) diff --git a/Mapapi/permissions.py b/Mapapi/permissions.py index 3b645865..de4c6225 100644 --- a/Mapapi/permissions.py +++ b/Mapapi/permissions.py @@ -16,11 +16,11 @@ COLLAB_ROLE_CONTRIBUTOR, COLLAB_ROLE_OBSERVER, ) - - -def _is_super(request): - """True si l'utilisateur est un super admin authentifié (accès total, sans exception).""" - return bool(request.user and request.user.is_authenticated and request.user.is_superuser) +from .roles import ( + is_super_admin, + is_org_admin, + is_bureau_agent, +) def _get_incident_from_view(view, request): @@ -40,6 +40,27 @@ def _get_incident_from_view(view, request): return None +def _is_internal_org_member(incident, user): + """En prise en charge INTERNE, tout membre de l'organisation propriétaire + (celle de ``incident.taken_by``) est considéré comme collaborateur de + l'incident, au même titre que le leader. + + Aligne les permissions sur le comportement déjà implémenté côté discussion + (``DiscussionMessageView``), où les membres de l'org propriétaire participent + à l'incident interne de leur organisation. Sans cela, un agent de bureau (ou + l'admin) ne pouvait pas voir les tâches / sous-ressources d'un incident pris + en charge en interne par SON org (403/404), alors qu'il y a accès au chat. + """ + if not user or not getattr(user, 'is_authenticated', False): + return False + if not incident or getattr(incident, 'take_in_charge_mode', None) != 'internal': + return False + owner = getattr(incident, 'taken_by', None) + owner_org = getattr(owner, 'organisation_member_id', None) + user_org = getattr(user, 'organisation_member_id', None) + return bool(owner_org and user_org and owner_org == user_org) + + class IsIncidentLeader(BasePermission): """Autorise uniquement le leader de l'incident. Le leader est déterminé par une Collaboration acceptée de rôle 'leader', @@ -51,8 +72,6 @@ class IsIncidentLeader(BasePermission): def has_permission(self, request, view): if not request.user or not request.user.is_authenticated: return False - if _is_super(request): - return True incident = _get_incident_from_view(view, request) if incident is None: # si pas d'incident dans l'URL, on délègue à has_object_permission @@ -67,8 +86,6 @@ def has_permission(self, request, view): return incident.taken_by_id == request.user.id def has_object_permission(self, request, view, obj): - if _is_super(request): - return True incident = getattr(obj, 'incident', obj if isinstance(obj, Incident) else None) if incident is None: return False @@ -90,25 +107,25 @@ class IsIncidentCollaborator(BasePermission): def has_permission(self, request, view): if not request.user or not request.user.is_authenticated: return False - if _is_super(request): - return True incident = _get_incident_from_view(view, request) if incident is None: return True if incident.taken_by_id == request.user.id: return True + if _is_internal_org_member(incident, request.user): + return True return Collaboration.objects.filter( incident=incident, user=request.user, status='accepted' ).exists() def has_object_permission(self, request, view, obj): - if _is_super(request): - return True incident = getattr(obj, 'incident', obj if isinstance(obj, Incident) else None) if incident is None: return False if incident.taken_by_id == request.user.id: return True + if _is_internal_org_member(incident, request.user): + return True return Collaboration.objects.filter( incident=incident, user=request.user, status='accepted' ).exists() @@ -126,14 +143,14 @@ class IsIncidentContributor(BasePermission): def has_permission(self, request, view): if not request.user or not request.user.is_authenticated: return False - if _is_super(request): - return True # Les méthodes de lecture restent autorisées pour tous les collaborateurs if request.method in SAFE_METHODS: return IsIncidentCollaborator().has_permission(request, view) incident = _get_incident_from_view(view, request) if incident is None: return True + if _is_internal_org_member(incident, request.user): + return True return Collaboration.objects.filter( incident=incident, user=request.user, @@ -154,8 +171,6 @@ class IsIncidentLeaderOrContributor(BasePermission): def has_permission(self, request, view): if not request.user or not request.user.is_authenticated: return False - if _is_super(request): - return True if request.method in SAFE_METHODS: return IsIncidentCollaborator().has_permission(request, view) incident = _get_incident_from_view(view, request) @@ -163,6 +178,8 @@ def has_permission(self, request, view): return True if incident.taken_by_id == request.user.id: return True + if _is_internal_org_member(incident, request.user): + return True return Collaboration.objects.filter( incident=incident, user=request.user, @@ -197,29 +214,66 @@ def has_object_permission(self, request, view, obj): return request.user and request.user.is_authenticated and request.user.is_superuser -class IsSuperAdminOrOrgOwnIncident(BasePermission): - """Super Admin peut supprimer tous les incidents. Organisation ne peut supprimer que ses incidents.""" +class IsSuperAdminRole(BasePermission): + """Autorise uniquement le rôle web super_admin (cf. roles.py).""" - message = "Vous n'avez pas la permission de supprimer cet incident." + message = "Seul un super administrateur peut effectuer cette action." def has_permission(self, request, view): - return request.user and request.user.is_authenticated + return is_super_admin(request.user) def has_object_permission(self, request, view, obj): - # Super admin peut tout supprimer - if request.user.is_superuser: - return True + return is_super_admin(request.user) - # Organisation ne peut supprimer que ses propres incidents - if isinstance(obj, Incident): - user_org = request.user.organisation_member - if user_org: - # L'incident appartient à l'organisation si: - # - Il a été reporté par un agent de l'organisation - # - Ou il a été pris en charge par l'organisation (taken_by) - if obj.user_id and obj.user_id.organisation_member == user_org: - return True - if obj.taken_by and obj.taken_by.organisation_member == user_org: - return True - return False +class IsOrgAdmin(BasePermission): + """Autorise uniquement le rôle web org_admin (Admin d'organisation).""" + + message = "Seul un administrateur d'organisation peut effectuer cette action." + + def has_permission(self, request, view): + return is_org_admin(request.user) + + def has_object_permission(self, request, view, obj): + return is_org_admin(request.user) + + +class IsAgentBureau(BasePermission): + """Autorise uniquement le rôle web bureau_agent (Agent de bureau).""" + + message = "Seul un agent de bureau peut effectuer cette action." + + def has_permission(self, request, view): + return is_bureau_agent(request.user) + + def has_object_permission(self, request, view, obj): + return is_bureau_agent(request.user) + + +class IsOrgOperative(BasePermission): + """Autorise un membre opérationnel d'une organisation (org_admin OU bureau_agent).""" + + message = "Cette action est réservée aux membres d'une organisation." + + def has_permission(self, request, view): + return is_org_admin(request.user) or is_bureau_agent(request.user) + + def has_object_permission(self, request, view, obj): + return is_org_admin(request.user) or is_bureau_agent(request.user) + + +class IsSuperAdminOrOrgOwnIncident(BasePermission): + """Suppression d'incident réservée au Super Admin (spec §6 : Corbeille = Super Admin only). + + Conservée pour compat (delete path), mais ne laisse plus les organisations + supprimer leurs propres incidents. + """ + + message = "Seul un super administrateur peut supprimer un incident." + + def has_permission(self, request, view): + return request.user and request.user.is_authenticated + + def has_object_permission(self, request, view, obj): + # Suppression d'incident : Super Admin uniquement. + return is_super_admin(request.user) diff --git a/Mapapi/roles.py b/Mapapi/roles.py new file mode 100644 index 00000000..c3db046a --- /dev/null +++ b/Mapapi/roles.py @@ -0,0 +1,46 @@ +"""Canonical web-role logic for the dashboard's 3 roles. + +Single source of truth shared by the API serializers (what the frontend reads) +and the DRF permission classes (what the backend enforces). See +Dashboardv2/Map_Action_Logique_des_roles.md. + +Web roles (dashboard): super_admin, org_admin, bureau_agent. +field_agent is mobile-only and has no dashboard access. +""" + +SUPER_ADMIN = "super_admin" +ORG_ADMIN = "org_admin" +BUREAU_AGENT = "bureau_agent" +FIELD_AGENT = "field_agent" + +# Roles allowed to use the web dashboard at all. +WEB_ROLES = (SUPER_ADMIN, ORG_ADMIN, BUREAU_AGENT) + + +def get_web_role(user): + """Return the canonical web role for a user, or None. + + is_superuser wins (super admin is a platform role, not an org role — D3). + Otherwise the user's org_role drives it. + """ + if user is None or not getattr(user, "is_authenticated", False): + return None + if getattr(user, "is_superuser", False): + return SUPER_ADMIN + return getattr(user, "org_role", None) or None + + +def is_super_admin(user): + return get_web_role(user) == SUPER_ADMIN + + +def is_org_admin(user): + return get_web_role(user) == ORG_ADMIN + + +def is_bureau_agent(user): + return get_web_role(user) == BUREAU_AGENT + + +def has_web_access(user): + return get_web_role(user) in WEB_ROLES diff --git a/Mapapi/routing.py b/Mapapi/routing.py new file mode 100644 index 00000000..c4834e45 --- /dev/null +++ b/Mapapi/routing.py @@ -0,0 +1,12 @@ +"""Routes WebSocket (temps réel).""" +from django.urls import path + +from . import consumers + +websocket_urlpatterns = [ + path('ws/notifications/', consumers.NotificationConsumer.as_asgi()), + path('ws/incidents//discussion/', consumers.DiscussionConsumer.as_asgi()), + path('ws/incidents//tasks/', consumers.TaskConsumer.as_asgi()), + path('ws/collaborations/', consumers.CollaborationConsumer.as_asgi()), + path('ws/activity-feed/', consumers.ActivityFeedConsumer.as_asgi()), +] diff --git a/Mapapi/schema.py b/Mapapi/schema.py new file mode 100644 index 00000000..36b0214c --- /dev/null +++ b/Mapapi/schema.py @@ -0,0 +1,43 @@ +"""drf-spectacular extensions & hooks for the Map Action OpenAPI schema. + +Imported at startup (from ``Mapapi/urls.py``) so the extensions below register +themselves with drf-spectacular. +""" +from drf_spectacular.extensions import OpenApiAuthenticationExtension + + +class CookieJWTAuthenticationScheme(OpenApiAuthenticationExtension): + """Expose le JWT Bearer (et le cookie httpOnly) dans le schéma OpenAPI. + + Sans cette extension drf-spectacular ne reconnaît pas la classe custom + ``CookieJWTAuthentication`` (sous-classe de ``JWTAuthentication``) et n'ajoute + donc aucun schéma de sécurité → pas de bouton « Authorize » dans Swagger. + On déclare le schéma Bearer (principal, utilisé par le dashboard et le mobile) + plus le cookie d'accès httpOnly en second. + """ + target_class = 'Mapapi.authentication.CookieJWTAuthentication' + name = ['jwtAuth', 'cookieAuth'] # deux schémas : Bearer + cookie + + def get_security_definition(self, auto_schema): + return [ + { + 'type': 'http', + 'scheme': 'bearer', + 'bearerFormat': 'JWT', + 'description': ( + "Token JWT d'accès. Obtenez-le via `POST /MapApi/login/` " + "(corps `{email, password}` → `{access, refresh}`), puis " + "cliquez sur **Authorize** et collez le token (sans le préfixe " + "`Bearer`). Durée de vie : 90 jours." + ), + }, + { + 'type': 'apiKey', + 'in': 'cookie', + 'name': 'access_token', + 'description': ( + "Cookie httpOnly posé par `POST /MapApi/login/` " + "(utilisé en same-site / proxy ; le dashboard utilise le Bearer)." + ), + }, + ] diff --git a/Mapapi/serializer.py b/Mapapi/serializer.py index 0c469a8d..5c94d2a9 100644 --- a/Mapapi/serializer.py +++ b/Mapapi/serializer.py @@ -8,37 +8,96 @@ from rest_framework.serializers import ModelSerializer from django.contrib.auth.hashers import make_password from django.utils import timezone +from drf_spectacular.utils import extend_schema_field +from drf_spectacular.types import OpenApiTypes +import base64 +import uuid as _uuid +from django.core.files.base import ContentFile + + +class AvatarField(serializers.ImageField): + """Champ avatar tolérant : accepte un fichier multipart OU une data-URL base64 + (le front lit le fichier en base64 via FileReader). Toute autre valeur (l'URL + existante renvoyée par le front quand l'avatar n'a pas changé, chaîne, vide) est + ignorée → l'avatar courant est conservé, plus de 400 « not a file ».""" + + def to_internal_value(self, data): + if hasattr(data, 'read'): # fichier multipart classique + return super().to_internal_value(data) + if isinstance(data, str) and data.startswith('data:image'): + try: + header, b64 = data.split(';base64,', 1) + ext = (header.split('/')[-1] or 'png')[:5] + decoded = base64.b64decode(b64) + f = ContentFile(decoded, name=f"avatar_{_uuid.uuid4().hex[:10]}.{ext}") + return super().to_internal_value(f) + except Exception: + self.fail('invalid_image') + raise serializers.SkipField() # rien de nouveau à enregistrer class OrganisationSerializer(serializers.ModelSerializer): members_count = serializers.SerializerMethodField(read_only=True) + incidents_taken_count = serializers.SerializerMethodField(read_only=True) class Meta: model = Organisation fields = '__all__' - def get_members_count(self, obj): + def get_members_count(self, obj) -> int: return obj.members.count() + def get_incidents_taken_count(self, obj) -> int: + # Incidents « pris en compte » par l'org = incidents dont le leader (taken_by) + # est un membre de l'organisation. Exclut les incidents supprimés. + return Incident.objects.filter( + taken_by__organisation_member=obj, is_deleted=False + ).count() + class OrganisationMemberSerializer(serializers.ModelSerializer): """Serializer pour la gestion des membres d'une organisation.""" organisation_name = serializers.CharField(source='organisation_member.name', read_only=True) + # Rôle web canonique (cf. roles.py) — même source que partout ailleurs, pour + # qu'un objet user expose TOUJOURS le rôle de la même façon (pas de confusion + # org_role vs user_type côté frontend). + web_role = serializers.SerializerMethodField(read_only=True) class Meta: model = User fields = [ - 'id', 'email', 'first_name', 'last_name', 'phone', - 'organisation_member', 'organisation_name', 'org_role', + 'id', 'email', 'first_name', 'last_name', 'phone', 'avatar', + 'organisation_member', 'organisation_name', 'org_role', 'web_role', 'agent_code', 'is_active', 'date_joined', ] - read_only_fields = ('id', 'email', 'date_joined', 'agent_code') + read_only_fields = ('id', 'email', 'date_joined', 'agent_code', 'avatar') + + @extend_schema_field(serializers.ChoiceField( + choices=['super_admin', 'org_admin', 'bureau_agent', 'field_agent'], + allow_null=True, + help_text="Rôle canonique du dashboard : super_admin si is_superuser, " + "sinon l'org_role (org_admin/bureau_agent/field_agent), sinon null. " + "Distinct de user_type (catégorie de compte).", + )) + def get_web_role(self, obj) -> str | None: + from .roles import get_web_role + return get_web_role(obj) + +# Secrets d'authentification jamais exposés en lecture par l'API. +# Ceux-ci sont EXCLUS de la sortie (ni lus ni écrits via ces serializers). +SENSITIVE_USER_FIELDS = ( + 'otp', 'otp_expiration', 'verification_token', 'pin_code', +) +# password : accepté en entrée (création/MAJ via set_password) mais jamais renvoyé. +PASSWORD_WRITE_ONLY = {'password': {'write_only': True}} + class UserRegisterSerializer(serializers.ModelSerializer): class Meta: model = User - fields = '__all__' + exclude = SENSITIVE_USER_FIELDS depth = 1 + extra_kwargs = PASSWORD_WRITE_ONLY def create(self, validated_data): user = User( @@ -55,6 +114,7 @@ def create(self, validated_data): class UserSerializer(ModelSerializer): + avatar = AvatarField(required=False) incident_preferences = serializers.ListField( child=serializers.CharField(), write_only=True, @@ -63,10 +123,26 @@ class UserSerializer(ModelSerializer): organisation_name = serializers.CharField( source='organisation_member.name', read_only=True ) + web_role = serializers.SerializerMethodField(read_only=True) class Meta: model = User - exclude = ('user_permissions', 'is_superuser', 'is_active', 'is_staff') + exclude = ('user_permissions', 'is_superuser', 'is_active', 'is_staff') \ + + SENSITIVE_USER_FIELDS + # password reste accepté en entrée (create() appelle set_password) mais + # n'est jamais renvoyé. + extra_kwargs = PASSWORD_WRITE_ONLY + + @extend_schema_field(serializers.ChoiceField( + choices=['super_admin', 'org_admin', 'bureau_agent', 'field_agent'], + allow_null=True, + help_text="Rôle canonique du dashboard : super_admin si is_superuser, " + "sinon l'org_role (org_admin/bureau_agent/field_agent), sinon null. " + "Distinct de user_type (catégorie de compte).", + )) + def get_web_role(self, obj) -> str | None: + from .roles import get_web_role + return get_web_role(obj) def create(self, validated_data): zones = validated_data.pop('zones', None) @@ -102,9 +178,12 @@ def create(self, validated_data, **extra_fields): class UserPutSerializer(serializers.ModelSerializer): + avatar = AvatarField(required=False) + class Meta: model = User - fields = '__all__' + exclude = SENSITIVE_USER_FIELDS + extra_kwargs = PASSWORD_WRITE_ONLY class RegisterSerializer(serializers.ModelSerializer): @@ -135,7 +214,90 @@ class Meta: fields = '__all__' -class IncidentSerializer(ModelSerializer): +class IncidentActingOrgsMixin(serializers.Serializer): + """Champs explicites partagés liste + détail (cf. services.incident_orgs) : + qui a pris l'incident en charge, et toutes les organisations actives dessus. + Permet à une autre organisation de voir, AVANT d'ouvrir l'incident, quelle(s) + organisation(s) agissent déjà.""" + taken_by_organisation = serializers.SerializerMethodField(read_only=True) + taken_by_name = serializers.SerializerMethodField(read_only=True) + acting_organisations = serializers.SerializerMethodField(read_only=True) + my_collaboration = serializers.SerializerMethodField(read_only=True) + + @extend_schema_field(OpenApiTypes.OBJECT) + def get_taken_by_organisation(self, obj): + from .services.incident_orgs import taken_by_organisation + return taken_by_organisation(obj) + + @extend_schema_field(OpenApiTypes.OBJECT) + def get_my_collaboration(self, obj): + """Pour le viewer connecté : SA demande de collaboration sur cet incident + (ou celle de SON organisation), QUEL QUE SOIT le statut — notamment + ``pending``. Permet, dans la liste, de voir « j'ai demandé à collaborer, + en attente » sur un incident déjà pris en charge par une autre org. + Renvoie ``None`` si aucune demande (ou pas de viewer authentifié). + Calculé sur ``collaboration_set`` préchargé → pas de N+1 en liste.""" + request = self.context.get('request') + user = getattr(request, 'user', None) + if not user or not getattr(user, 'is_authenticated', False): + return None + org_id = getattr(user, 'organisation_member_id', None) + mine = [] + for c in obj.collaboration_set.all(): + cu = getattr(c, 'user', None) + if not cu: + continue + if org_id is not None: + if getattr(cu, 'organisation_member_id', None) == org_id: + mine.append(c) + elif cu.id == user.id: + mine.append(c) + if not mine: + return None + # On garde la plus pertinente : accepted > pending > declined, puis récente. + rank = {'accepted': 0, 'pending': 1, 'declined': 2} + mine.sort(key=lambda c: ( + rank.get(c.status, 9), + -(c.created_at.timestamp() if getattr(c, 'created_at', None) else 0), + )) + best = mine[0] + org = getattr(getattr(best, 'user', None), 'organisation_member', None) + return { + 'id': best.id, + 'status': best.status, + 'role': best.role, + 'created_at': best.created_at.isoformat() if getattr(best, 'created_at', None) else None, + 'organisation_id': org.id if org else None, + 'organisation_name': org.name if org else None, + } + + def get_taken_by_name(self, obj) -> str | None: + tb = getattr(obj, 'taken_by', None) + if not tb: + return None + return f"{tb.first_name or ''} {tb.last_name or ''}".strip() or tb.email + + @extend_schema_field(serializers.ListField(child=serializers.DictField())) + def get_acting_organisations(self, obj): + from .services.incident_orgs import acting_organisations + return acting_organisations(obj) + + +class IncidentOrgAssignmentNestedSerializer(serializers.ModelSerializer): + """Lecture seule : assignations org exposées sur le détail d'un incident, + pour que le front affiche les actions accepter/refuser (spec §2/§3).""" + organisation_id = serializers.IntegerField(source='organisation.id', read_only=True) + organisation_name = serializers.CharField(source='organisation.name', read_only=True) + + class Meta: + model = IncidentOrgAssignment + fields = ('id', 'organisation_id', 'organisation_name', 'status', 'deadline') + read_only_fields = fields + + +class IncidentSerializer(IncidentActingOrgsMixin, ModelSerializer): + org_assignments = IncidentOrgAssignmentNestedSerializer(many=True, read_only=True) + class Meta: model = Incident fields = '__all__' @@ -173,9 +335,10 @@ def validate(self, data): return data -class IncidentGetSerializer(ModelSerializer): +class IncidentGetSerializer(IncidentActingOrgsMixin, ModelSerializer): user_id = UserSerializer() category_id = CategorySerializer() + org_assignments = IncidentOrgAssignmentNestedSerializer(many=True, read_only=True) class Meta: model = Incident @@ -183,18 +346,22 @@ class Meta: class IncidentMapSerializer(ModelSerializer): - """Serializer léger pour les marqueurs de la carte. + """Sérialiseur ultra-léger pour la carte du dashboard. + + N'expose que les champs scalaires dont les marqueurs ont besoin. Il évite + volontairement le M2M `category_ids`, le nested `org_assignments` et les URLs + de fichiers (photo/video/audio) qui, via `IncidentSerializer(__all__)`, + déclenchaient un N+1 (~126 requêtes pour 59 incidents → ~12 s sur le pooler + Supabase distant). `taken_by` reste un PK (optimisation PK-only de DRF, pas de + requête supplémentaire), donc l'endpoint ne fait plus qu'UNE requête. Les + détails (photo, description, participants…) sont chargés à la demande via + `GET /incident/` quand un marqueur est cliqué.""" - Ne charge aucune relation imbriquée (user, catégorie, assignments) afin - d'éviter les requêtes N+1. Uniquement les champs nécessaires à l'affichage - d'un marqueur et à son ouverture. - """ class Meta: model = Incident fields = ( - 'id', 'title', 'zone', 'lattitude', 'longitude', - 'etat', 'category_id', 'created_at', 'progress', - 'take_in_charge_mode', + 'id', 'title', 'lattitude', 'longitude', 'etat', 'taken_by', + 'is_deleted', 'severity', 'created_at', ) @@ -230,6 +397,23 @@ class Meta: fields = '__all__' +class IncidentReportSerializer(ModelSerializer): + """Rapport d'un agent vu depuis un incident (page Mes interventions / détail + collaboration). Expose explicitement l'auteur + son organisation.""" + author = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = Rapport + fields = ( + 'id', 'details', 'type', 'statut', 'date_livraison', 'disponible', + 'file', 'created_at', 'incident', 'author', + ) + + @extend_schema_field(OpenApiTypes.OBJECT) + def get_author(self, obj): + return _person_brief(obj.user_id) + + class ParticipateSerializer(ModelSerializer): class Meta: model = Participate @@ -360,61 +544,72 @@ class Meta: 'error_message', 'created_at', 'updated_at', ) -class OtherCollaboratorSerializer(serializers.ModelSerializer): - organisation_name = serializers.CharField( - source='user.organisation_member.name', read_only=True, default=None - ) - organisation_id = serializers.IntegerField( - source='user.organisation_member_id', read_only=True, default=None - ) - user_full_name = serializers.SerializerMethodField() - user_email = serializers.EmailField(source='user.email', read_only=True) +def _person_brief(user): + """Représentation explicite et minimale d'une personne + son organisation.""" + if not user: + return None + org = getattr(user, 'organisation_member', None) + return { + 'id': user.id, + 'name': f"{user.first_name or ''} {user.last_name or ''}".strip() or user.email, + 'email': user.email, + 'organisation_id': org.id if org else None, + 'organisation_name': org.name if org else (getattr(user, 'organisation', None) or None), + } + + +class CollaborationPartiesMixin(serializers.Serializer): + """Expose EXPLICITEMENT l'émetteur et le récepteur d'une demande de + collaboration, pour lever la confusion côté frontend (#8) : + - sender (émetteur) = l'organisation qui DEMANDE à rejoindre (collaboration.user) + - receiver (récepteur) = le leader qui REÇOIT la demande (incident.taken_by) + """ + sender = serializers.SerializerMethodField(read_only=True) + receiver = serializers.SerializerMethodField(read_only=True) - class Meta: - model = Collaboration - fields = ( - 'id', 'user', 'user_full_name', 'user_email', - 'organisation_id', 'organisation_name', 'role', 'status', 'end_date' - ) + @extend_schema_field(OpenApiTypes.OBJECT) + def get_sender(self, obj): + return _person_brief(getattr(obj, 'user', None)) + + @extend_schema_field(OpenApiTypes.OBJECT) + def get_receiver(self, obj): + incident = getattr(obj, 'incident', None) + return _person_brief(getattr(incident, 'taken_by', None)) if incident else None - def get_user_full_name(self, obj): - if obj.user: - return f"{obj.user.first_name or ''} {obj.user.last_name or ''}".strip() or obj.user.email - return None -class CollaborationSerializer(ModelSerializer): +class CollaborationSerializer(CollaborationPartiesMixin, ModelSerializer): # Nom de l'organisation du collaborateur (lecture seule) organisation_name = serializers.CharField( source='user.organisation_member.name', read_only=True, default=None ) - organisation_id = serializers.IntegerField( + organisation_id = serializers.UUIDField( source='user.organisation_member_id', read_only=True, default=None ) user_full_name = serializers.SerializerMethodField() user_email = serializers.EmailField(source='user.email', read_only=True) incident_title = serializers.CharField(source='incident.title', read_only=True) + # Raccourcis photo/miniature (en plus de incident_details) pour les cartes. + incident_photo = serializers.ImageField(source='incident.photo', read_only=True) + incident_thumbnail = serializers.ImageField(source='incident.thumbnail', read_only=True) incident_details = IncidentSerializer(source='incident', read_only=True) prediction_details = PredictionSerializer(source='incident.prediction', read_only=True) - other_collaborators = serializers.SerializerMethodField() class Meta: model = Collaboration fields = '__all__' - # 'status' et 'role' ne sont PAS settables librement par le demandeur : + # 'status', 'user' et 'role'(leader) ne sont PAS settables librement par le demandeur : + # - user = l'émetteur, TOUJOURS forcé à request.user dans la vue (le client + # n'envoie rien ; évite le 400 "Invalid pk" quand le FE postait un mauvais id) # - status est géré par le leader via les endpoints accept/decline # - role = 'leader' est auto-attribué quand une organisation prend l'incident ; # une demande manuelle ne peut proposer que contributor/observer - read_only_fields = ('status',) + read_only_fields = ('status', 'user') - def get_user_full_name(self, obj): + def get_user_full_name(self, obj) -> str | None: if obj.user: return f"{obj.user.first_name or ''} {obj.user.last_name or ''}".strip() or obj.user.email return None - def get_other_collaborators(self, obj): - qs = Collaboration.objects.filter(incident=obj.incident).exclude(id=obj.id).select_related('user', 'user__organisation_member') - return OtherCollaboratorSerializer(qs, many=True).data - def validate_role(self, value): """Un utilisateur ne peut pas se déclarer leader lui-même. @@ -442,7 +637,7 @@ def validate(self, data): return data -class CollaborationEnrichedSerializer(ModelSerializer): +class CollaborationEnrichedSerializer(CollaborationPartiesMixin, ModelSerializer): """Serializer enrichi pour la vue collaboration dashboard.""" organisation_name = serializers.SerializerMethodField() user_role = serializers.CharField(source='role', read_only=True) @@ -451,6 +646,9 @@ class CollaborationEnrichedSerializer(ModelSerializer): incident_zone = serializers.CharField(source='incident.zone', read_only=True) incident_etat = serializers.CharField(source='incident.etat', read_only=True) incident_progress = serializers.IntegerField(source='incident.progress', read_only=True) + # Photo (+ miniature) de l'incident pour les cartes « Mes collaborations ». + incident_photo = serializers.ImageField(source='incident.photo', read_only=True) + incident_thumbnail = serializers.ImageField(source='incident.thumbnail', read_only=True) start_date = serializers.DateTimeField(source='created_at', read_only=True) participants_count = serializers.SerializerMethodField() @@ -458,19 +656,20 @@ class Meta: model = Collaboration fields = [ 'id', 'incident', 'user', 'status', 'role', - 'organisation_name', 'user_role', + 'organisation_name', 'user_role', 'sender', 'receiver', 'incident_title', 'incident_description', 'incident_zone', 'incident_etat', 'incident_progress', + 'incident_photo', 'incident_thumbnail', 'start_date', 'end_date', 'participants_count', 'motivation', ] - def get_organisation_name(self, obj): + def get_organisation_name(self, obj) -> str | None: if obj.user and obj.user.organisation_member: return obj.user.organisation_member.name return obj.user.organisation if obj.user else None - def get_participants_count(self, obj): + def get_participants_count(self, obj) -> int: return Collaboration.objects.filter( incident=obj.incident, status='accepted' ).count() @@ -482,10 +681,23 @@ class Meta: class NotificationSerializer(serializers.ModelSerializer): + link = serializers.SerializerMethodField(read_only=True) + # `type` = catégorie (collaboration_request, incident_assignment, …) pour le + # front (icône/logique) ; `title` = libellé FR prêt à afficher (plus de titre en + # dur côté front). cf. incident_title pour le titre de l'incident concerné. + type = serializers.CharField(source='notif_type', read_only=True) + title = serializers.CharField(read_only=True) + incident_title = serializers.CharField(source='incident.title', read_only=True, default=None) + class Meta: model = Notification fields = '__all__' + @extend_schema_field(OpenApiTypes.OBJECT) + def get_link(self, obj): + # Cible de redirection au clic ({type, incident_id, [collaboration_id], url}) ou null. + return obj.redirect_link() + class ChatHistorySerializer(serializers.ModelSerializer): class Meta: @@ -499,6 +711,33 @@ class Meta: fields = '__all__' +class ActivityFeedSerializer(serializers.ModelSerializer): + """Élément du flux d'activité : action + acteur + organisation + horodatage précis.""" + user_name = serializers.SerializerMethodField(read_only=True) + organisation_name = serializers.SerializerMethodField(read_only=True) + # `actor` = ce qu'on AFFICHE en tête de l'élément : le nom de l'ORGANISATION + # (l'activité est au niveau org), avec repli sur le nom de la personne si elle + # n'a pas d'organisation (ex. super admin). À utiliser à la place de `user_name`. + actor = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = UserAction + fields = ['id', 'action', 'timeStamp', 'created_at', 'user', 'user_name', 'organisation_name', 'actor'] + + def get_user_name(self, obj) -> str | None: + u = obj.user + if not u: + return None + return (f"{u.first_name or ''} {u.last_name or ''}".strip()) or u.email + + def get_organisation_name(self, obj) -> str | None: + u = obj.user + return getattr(getattr(u, 'organisation_member', None), 'name', None) if u else None + + def get_actor(self, obj) -> str | None: + return self.get_organisation_name(obj) or self.get_user_name(obj) + + class IncidentAssignmentSerializer(serializers.ModelSerializer): agent_name = serializers.CharField(source='agent.get_full_name', read_only=True) agent_email = serializers.EmailField(source='agent.email', read_only=True) @@ -514,6 +753,7 @@ class Meta: fields = '__all__' read_only_fields = ('assigned_by', 'created_at', 'updated_at') + @extend_schema_field(IncidentGetSerializer) def get_incident_detail(self, obj): if not obj.incident: return None @@ -543,6 +783,22 @@ def validate(self, data): return data +class IncidentOrgAssignmentSerializer(serializers.ModelSerializer): + """Assignation d'un incident à une organisation par le Super Admin (spec §2/§3).""" + organisation_name = serializers.CharField(source='organisation.name', read_only=True) + incident_title = serializers.CharField(source='incident.title', read_only=True) + assigned_by_name = serializers.CharField(source='assigned_by.get_full_name', read_only=True) + assigned_by_email = serializers.EmailField(source='assigned_by.email', read_only=True) + + class Meta: + model = IncidentOrgAssignment + fields = '__all__' + read_only_fields = ( + 'status', 'decline_reason', 'deadline', 'assigned_by', + 'created_at', 'responded_at', + ) + + class FieldReportSerializer(ModelSerializer): agent_name = serializers.CharField(source='agent.get_full_name', read_only=True) incident_title = serializers.CharField(source='incident.title', read_only=True) @@ -619,19 +875,14 @@ def validate(self, data): class IncidentTaskSerializer(serializers.ModelSerializer): - created_by_name = serializers.CharField(source='created_by.get_full_name', read_only=True) - created_by_organisation = serializers.CharField( - source='created_by.organisation_member.name', read_only=True, default=None - ) - created_by_organisation_id = serializers.IntegerField( - source='created_by.organisation_member.id', read_only=True, default=None - ) - assigned_to_name = serializers.CharField(source='assigned_to.get_full_name', read_only=True, default=None) - class Meta: model = IncidentTask fields = '__all__' - read_only_fields = ('created_by', 'created_at', 'updated_at', 'is_confirmed') + # `incident` est TOUJOURS injecté depuis l'URL par la vue + # (perform_create → serializer.save(incident=...)). Il ne doit donc pas être + # exigé/envoyé dans le corps (sinon is_valid() échoue « This field may not be + # null »). `created_by` est de même posé par la vue. + read_only_fields = ('incident', 'created_by', 'created_at', 'updated_at', 'is_confirmed') def validate(self, data): # Refus d'ajouter/modifier une tâche sur un incident clôturé @@ -692,18 +943,26 @@ class Meta: 'suggested_partner': {'required': False}, } - def get_suggested_by_name(self, obj): + def get_suggested_by_name(self, obj) -> str | None: u = obj.suggested_by if not u: return None return f"{u.first_name or ''} {u.last_name or ''}".strip() or u.email - def get_suggested_partner_name(self, obj): + def get_suggested_partner_name(self, obj) -> str | None: u = obj.suggested_partner if not u: return None return f"{u.first_name or ''} {u.last_name or ''}".strip() or u.email + def get_unique_together_validators(self): + # Le modèle a unique_together (incident, suggested_partner). DRF en déduit + # un UniqueTogetherValidator qui FORCE suggested_partner requis dans l'input + # (enforce_required_fields), ce qui casse le chemin suggested_organisation + # (où le partenaire n'est résolu qu'au moment du validate()). On désactive + # ce validateur auto et on contrôle l'unicité manuellement dans validate(). + return [] + def validate(self, data): incident = data.get('incident') or getattr(self.instance, 'incident', None) if incident and incident.is_resolved: @@ -750,107 +1009,17 @@ def validate(self, data): raise serializers.ValidationError( "Cette organisation collabore déjà sur l'incident." ) + # unicité (incident, suggested_partner) gérée manuellement puisque le + # validateur auto a été désactivé ci-dessus (get_unique_together_validators). + dup_qs = PartnerSuggestion.objects.filter( + incident=incident, suggested_partner=suggested_partner + ) + if self.instance is not None: + dup_qs = dup_qs.exclude(pk=self.instance.pk) + if dup_qs.exists(): + raise serializers.ValidationError( + "Cette organisation a déjà été invitée ou suggérée pour cet incident." + ) return data -class IncidentTaskResolvedSerializer(serializers.ModelSerializer): - """Tâche enrichie avec les infos de l'organisation qui l'a créée.""" - organisation_name = serializers.CharField( - source='created_by.organisation_member.name', read_only=True, default=None - ) - organisation_id = serializers.IntegerField( - source='created_by.organisation_member.id', read_only=True, default=None - ) - assigned_to_name = serializers.CharField( - source='assigned_to.get_full_name', read_only=True, default=None - ) - created_by_name = serializers.CharField( - source='created_by.get_full_name', read_only=True - ) - - class Meta: - model = IncidentTask - fields = [ - 'id', 'title', 'description', - 'start_date', 'end_date', 'state', - 'is_confirmed', 'proof_image', 'proof_video', 'failure_reason', - 'organisation_id', 'organisation_name', - 'assigned_to', 'assigned_to_name', - 'created_by', 'created_by_name', - 'created_at', 'updated_at', - ] - - -class IncidentResolvedSerializer(serializers.ModelSerializer): - """ - Sérialiseur complet pour les incidents résolus. - - prediction : données d'analyse complètes - - organisations : liste des organisations impliquées (via Collaboration acceptée) - - tasks_by_organisation : tâches regroupées par organisation - """ - prediction = PredictionSerializer(read_only=True) - category_name = serializers.CharField(source='category_id.name', read_only=True, default=None) - reporter_name = serializers.CharField(source='user_id.get_full_name', read_only=True, default=None) - reporter_email = serializers.EmailField(source='user_id.email', read_only=True, default=None) - taken_by_name = serializers.CharField(source='taken_by.get_full_name', read_only=True, default=None) - taken_by_organisation = serializers.CharField( - source='taken_by.organisation_member.name', read_only=True, default=None - ) - organisations = serializers.SerializerMethodField() - tasks_by_organisation = serializers.SerializerMethodField() - - class Meta: - model = Incident - fields = [ - 'id', 'title', 'description', 'zone', 'lattitude', 'longitude', - 'etat', 'take_in_charge_mode', 'progress', - 'created_at', 'resolution_end_date', - 'category_id', 'category_name', - 'reporter_name', 'reporter_email', - 'taken_by', 'taken_by_name', 'taken_by_organisation', - 'prediction', - 'organisations', - 'tasks_by_organisation', - ] - - def get_organisations(self, obj): - """Retourne la liste de toutes les organisations impliquées (Collaborations acceptées).""" - collabs = obj.collaboration_set.filter(status='accepted').select_related( - 'user__organisation_member' - ) - result = [] - seen_org_ids = set() - for c in collabs: - org = getattr(c.user, 'organisation_member', None) - if org and org.id not in seen_org_ids: - seen_org_ids.add(org.id) - result.append({ - 'id': org.id, - 'name': org.name, - 'role': c.role, - }) - return result - - def get_tasks_by_organisation(self, obj): - """Retourne les tâches de l'incident regroupées par organisation.""" - tasks = obj.tasks.select_related( - 'created_by__organisation_member', 'assigned_to' - ).all() - - grouped = {} - for task in tasks: - org = getattr(task.created_by, 'organisation_member', None) - org_key = org.id if org else 'sans_organisation' - org_name = org.name if org else 'Sans organisation' - - if org_key not in grouped: - grouped[org_key] = { - 'organisation_id': org.id if org else None, - 'organisation_name': org_name, - 'tasks': [], - } - grouped[org_key]['tasks'].append( - IncidentTaskResolvedSerializer(task).data - ) - - return list(grouped.values()) diff --git a/Mapapi/services/impact_service.py b/Mapapi/services/impact_service.py deleted file mode 100644 index af899940..00000000 --- a/Mapapi/services/impact_service.py +++ /dev/null @@ -1,239 +0,0 @@ -import math -from datetime import timedelta -from django.db.models import Q, Sum, F, Count, Avg, IntegerField -from django.db.models.functions import Cast -from django.db.models.fields.json import KeyTextTransform -from django.utils import timezone -from ..models import ( - Incident, Prediction, Collaboration, - DECLARED, TAKEN, RESOLVED, TASK_DONE -) -from django.contrib.auth import get_user_model -User = get_user_model() - -class ImpactCalculatorService: - def __init__(self, user, query_params): - self.user = user - self.query_params = query_params - - def _get_date_filter(self): - period = self.query_params.get('period') - if period == '30d': - return timezone.now() - timedelta(days=30) - elif period == '90d': - return timezone.now() - timedelta(days=90) - elif period == 'year': - return timezone.now() - timedelta(days=365) - return None - - def get_base_queryset(self): - qs = Incident.objects.all() - - # 1. Filtre par rôle (Périmètre) - if not self.user.is_superuser: - # Admin organisation / Agent bureau : incidents de son organisation - if hasattr(self.user, 'organisation_member') and self.user.organisation_member: - org = self.user.organisation_member - org_members = org.members.all() - # Les incidents liés à l'organisation : créés par, pris en charge par, ou collaboration - qs = qs.filter( - Q(user_id__in=org_members) | - Q(taken_by__in=org_members) | - Q(collaboration__user__in=org_members, collaboration__status='accepted') - ).distinct() - else: - return Incident.objects.none() - - # 2. Filtres par paramètres - # Périmètre (résolus / en cours) - scope = self.query_params.get('scope') - if scope == 'resolved': - qs = qs.filter(etat=RESOLVED) - elif scope == 'ongoing': - qs = qs.filter(etat=TAKEN, tasks__state=TASK_DONE).distinct() - - # Période - date_from = self._get_date_filter() - if date_from: - qs = qs.filter(created_at__gte=date_from) - - # Catégorie - category = self.query_params.get('category') - if category: - qs = qs.filter(Q(category_id__name__icontains=category) | Q(prediction__macro_category__icontains=category)) - - # Recherche texte - search = self.query_params.get('search') - if search: - qs = qs.filter( - Q(title__icontains=search) | - Q(description__icontains=search) | - Q(zone__icontains=search) - ) - - return qs - - def compute(self): - base_qs = self.get_base_queryset() - - if not base_qs.exists(): - return self._empty_result() - - # Séparation des incidents - # Incidents avec prédiction (analyse exploitable) - incidents_with_analysis = base_qs.filter(prediction__isnull=False).distinct() - - # Le volume total (pour affichage de "n incidents sans analyse exploitable" et taux de résolution) - total_incidents_count = base_qs.count() - incidents_without_analysis = total_incidents_count - incidents_with_analysis.count() - - # Filtre selon les règles d'impact: - # Inclure : résolus, ou pris en compte avec tâche terminée - # Exclure : incidents sans action (déclarés, ou pris en compte sans tâche) - impact_incidents = incidents_with_analysis.filter( - Q(etat=RESOLVED) | - Q(etat=TAKEN, tasks__state=TASK_DONE) - ).distinct() - - # Obtenir les prédictions correspondantes - predictions = Prediction.objects.filter(incident__in=impact_incidents) - - # 4.1 Bénéficiaires directs + ventilation hommes / femmes / enfants - directs_agg = predictions.aggregate( - total=Sum('total_population_exposed'), - hommes=Sum('adult_men_exposed'), - femmes=Sum('adult_women_exposed'), - enfants=Sum('children_exposed'), - ) - beneficiaires_directs = { - "total": directs_agg['total'] or 0, - "hommes": directs_agg['hommes'] or 0, - "femmes": directs_agg['femmes'] or 0, - "enfants": directs_agg['enfants'] or 0, - } - - # 4.2 Bénéficiaires indirects (via json field potential_risk -> stats -> total_pop) - # Extraction du champ JSON imbriqué et somme côté SQL. - indirects_agg = predictions.annotate( - indirect_pop=Cast( - KeyTextTransform('total_pop', KeyTextTransform('stats', 'potential_risk')), - IntegerField(), - ) - ).aggregate(total=Sum('indirect_pop')) - beneficiaires_indirects = { - "total": indirects_agg['total'] or 0, - } - - # 4.3 Infrastructures : total + détail par type - infra_fields = ['schools', 'health_centers', 'maternities', 'nurseries', 'markets', 'water_points', 'main_roads_bridges', 'residential_buildings'] - infra_agg = predictions.aggregate(**{f: Sum(f) for f in infra_fields}) - infrastructures_detail = {f: (infra_agg[f] or 0) for f in infra_fields} - infrastructures_total = sum(infrastructures_detail.values()) - - # 4.4 Superficie impact : somme(π·r²) calculée en SQL (somme des r²) puis × π. - radius_agg = predictions.filter(impact_radius_meters__gt=0).aggregate( - total_r2=Sum(F('impact_radius_meters') * F('impact_radius_meters')) - ) - superficie_m2 = math.pi * (radius_agg['total_r2'] or 0) - superficie_ha = round(superficie_m2 / 10000, 2) - - # 4.5 Temps moyen de résolution & 4.6 Taux de résolution - resolved_incidents = base_qs.filter(etat=RESOLVED) - nb_resolus = resolved_incidents.count() - taux_resolution = round((nb_resolus / total_incidents_count) * 100, 2) if total_incidents_count > 0 else 0 - - durations = [] - for inc in resolved_incidents: - if inc.resolution_end_date and inc.created_at: - duration = (inc.resolution_end_date - inc.created_at.date()).days - if duration >= 0: - durations.append(duration) - temps_moyen_resolution = sum(durations) / len(durations) if durations else 0 - - # Indicateurs Super Admin - mobilisation = None - contribution_citoyenne = None - - if self.user.is_superuser: - # Optimisation des requêtes - impact_incidents_opt = impact_incidents.select_related('taken_by__organisation_member', 'user_id').prefetch_related( - 'collaboration_set__user__organisation_member', - 'tasks__assigned_to' - ) - - # 4.7 Mobilisation - org_names = set() - field_agents = set() - - for inc in impact_incidents_opt: - if inc.taken_by and getattr(inc.taken_by, 'organisation_member', None): - org_names.add(inc.taken_by.organisation_member.name) - - collabs = inc.collaboration_set.filter(status='accepted') - for c in collabs: - if getattr(c.user, 'organisation_member', None): - org_names.add(c.user.organisation_member.name) - - for t in inc.tasks.all(): - if t.assigned_to and getattr(t.assigned_to, 'org_role', None) == 'field_agent': - field_agents.add(t.assigned_to.id) - - if inc.user_id and getattr(inc.user_id, 'org_role', None) == 'field_agent': - field_agents.add(inc.user_id.id) - - nb_collaborations = Collaboration.objects.filter(incident__in=impact_incidents).count() - - mobilisation = { - "organisations_distinctes": len(org_names), - "agents_terrain": len(field_agents), - "collaborations": nb_collaborations - } - - # 4.8 Contribution citoyenne - # Créateur n'est pas un agent (ie. None ou autre rôle, mais dans ce contexte on exclut les field_agents) - signalements_citoyens = base_qs.exclude(user_id__org_role='field_agent') - nb_recus = signalements_citoyens.count() - nb_verifies = signalements_citoyens.filter(prediction__isnull=False).count() - nb_actions = signalements_citoyens.filter(prediction__isnull=False).exclude(etat=DECLARED).count() - - contribution_citoyenne = { - "signalements_recus": nb_recus, - "signalements_verifies": nb_verifies, - "transformes_actions": nb_actions - } - - return { - "beneficiaires_directs": beneficiaires_directs, - "beneficiaires_indirects": beneficiaires_indirects, - "infrastructures_total": infrastructures_total, - "infrastructures_detail": infrastructures_detail, - "superficie_ha": superficie_ha, - "temps_moyen_resolution": temps_moyen_resolution, - "taux_resolution": taux_resolution, - "incidents_sans_analyse": incidents_without_analysis, - "mobilisation": mobilisation, - "contribution_citoyenne": contribution_citoyenne - } - - def _empty_result(self): - infra_fields = ['schools', 'health_centers', 'maternities', 'nurseries', 'markets', 'water_points', 'main_roads_bridges', 'residential_buildings'] - return { - "beneficiaires_directs": {"total": 0, "hommes": 0, "femmes": 0, "enfants": 0}, - "beneficiaires_indirects": {"total": 0}, - "infrastructures_total": 0, - "infrastructures_detail": {f: 0 for f in infra_fields}, - "superficie_ha": 0, - "temps_moyen_resolution": 0, - "taux_resolution": 0, - "incidents_sans_analyse": 0, - "mobilisation": { - "organisations_distinctes": 0, - "agents_terrain": 0, - "collaborations": 0 - } if self.user.is_superuser else None, - "contribution_citoyenne": { - "signalements_recus": 0, - "signalements_verifies": 0, - "transformes_actions": 0 - } if self.user.is_superuser else None - } diff --git a/Mapapi/services/incident_orgs.py b/Mapapi/services/incident_orgs.py new file mode 100644 index 00000000..2e34c491 --- /dev/null +++ b/Mapapi/services/incident_orgs.py @@ -0,0 +1,74 @@ +"""Organisations actives sur un incident. + +Couche service partagée par les serializers incident (liste + détail) pour +exposer, de façon explicite et cohérente : l'organisation qui a pris l'incident +en charge, et la liste de toutes les organisations qui agissent dessus. Conçu +pour s'appuyer sur des relations préchargées (select_related / prefetch_related) +afin d'éviter le N+1 en liste. +""" +from django.db.models import Q + +from ..models import ORG_ASSIGNMENT_ACCEPTED, COLLAB_STATUS_ACCEPTED + + +def org_acting_q(user): + """Filtre Q : incidents sur lesquels l'utilisateur (ou son organisation) agit + réellement — a pris en charge (perso/org), org assignée acceptée, collaboration + acceptée de son org, ou agent assigné. Source unique partagée par + /my-interventions/ et le scope ``mine`` de /incident-filter/.""" + q = Q(taken_by=user) | Q(assignments__agent=user) + org = getattr(user, 'organisation_member', None) + if org: + q |= Q(taken_by__organisation_member=org) + q |= Q(org_assignments__organisation=org, + org_assignments__status=ORG_ASSIGNMENT_ACCEPTED) + q |= Q(collaboration__user__organisation_member=org, + collaboration__status=COLLAB_STATUS_ACCEPTED) + return q + +RELATION_LEADER = 'leader' # a pris l'incident en charge (taken_by) +RELATION_ASSIGNED = 'assigned' # assignée par le Super Admin et a accepté +RELATION_COLLABORATOR = 'collaborator' # collabore (Collaboration acceptée) + +_RANK = {RELATION_LEADER: 0, RELATION_ASSIGNED: 1, RELATION_COLLABORATOR: 2} + + +def taken_by_organisation(incident): + """{'id', 'name'} de l'organisation qui a pris l'incident en charge, ou None.""" + taken_by = getattr(incident, 'taken_by', None) + org = getattr(taken_by, 'organisation_member', None) if taken_by else None + return {'id': org.id, 'name': org.name} if org else None + + +def acting_organisations(incident): + """Liste dédupliquée des organisations actives sur l'incident. + + Chaque entrée : {'id', 'name', 'relation'} où relation ∈ + {leader, assigned, collaborator}. Si une organisation cumule plusieurs + relations on garde la plus forte (leader > assigned > collaborator). + """ + found = {} + + def add(org, relation): + if not org: + return + existing = found.get(org.id) + if existing is None or _RANK[relation] < _RANK[existing['relation']]: + found[org.id] = {'id': org.id, 'name': org.name, 'relation': relation} + + # 1. l'organisation qui a pris en charge (leader) + taken_by = getattr(incident, 'taken_by', None) + if taken_by: + add(getattr(taken_by, 'organisation_member', None), RELATION_LEADER) + + # 2. assignations org acceptées + for assignment in incident.org_assignments.all(): + if assignment.status == ORG_ASSIGNMENT_ACCEPTED: + add(assignment.organisation, RELATION_ASSIGNED) + + # 3. collaborations acceptées (org du collaborateur) + for collab in incident.collaboration_set.all(): + if collab.status == COLLAB_STATUS_ACCEPTED: + add(getattr(collab.user, 'organisation_member', None), RELATION_COLLABORATOR) + + return list(found.values()) diff --git a/Mapapi/services/model_chat_client.py b/Mapapi/services/model_chat_client.py index b6c67012..a0fe2945 100644 --- a/Mapapi/services/model_chat_client.py +++ b/Mapapi/services/model_chat_client.py @@ -16,7 +16,7 @@ def ask_model_chat(messages, context): chat_url = getattr( settings, "MODEL_DEPLOY_CHAT_URL", - os.getenv("MODEL_DEPLOY_CHAT_URL", "http://localhost:8001/api1/chat"), + os.getenv("MODEL_DEPLOY_CHAT_URL", "http://localhost:8001/chat"), ) timeout = int(getattr( settings, diff --git a/Mapapi/signals.py b/Mapapi/signals.py index 4f6b5abf..2b329de0 100644 --- a/Mapapi/signals.py +++ b/Mapapi/signals.py @@ -1,25 +1,214 @@ -from django.db.models.signals import post_save +import json + +from django.db.models.signals import post_save, pre_save, post_delete from django.dispatch import receiver -from .models import Collaboration, Notification, User +from django.core.serializers.json import DjangoJSONEncoder +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +from .models import (Collaboration, Notification, User, DiscussionMessage, IncidentTask, + UserAction, COLLAB_ROLE_LEADER) + + +def _actor_label(user): + org = getattr(getattr(user, 'organisation_member', None), 'name', None) + return org or (user.get_full_name() or user.email if user else 'Quelqu\'un') from .Send_mails import send_email import logging logger = logging.getLogger(__name__) + +def _ws_broadcast(group, payload): + """Pousse un message vers un groupe WebSocket (depuis un contexte sync). + + Le payload est d'abord normalisé en primitives JSON (UUID -> str, datetime -> + ISO, etc.) AVANT group_send : la couche Channels sérialise en msgpack, qui ne + sait pas empaqueter un UUID/datetime. Sans cela, group_send levait + « can not serialize 'UUID' object », l'exception était avalée ci-dessous, et le + broadcast était silencieusement perdu (WebSocket ouvert mais aucun message reçu). + """ + try: + safe_payload = json.loads(json.dumps(payload, cls=DjangoJSONEncoder)) + layer = get_channel_layer() + if layer is not None: + async_to_sync(layer.group_send)(group, {'type': 'broadcast', 'payload': safe_payload}) + except Exception as exc: # ne jamais casser une écriture DB à cause du temps réel + logger.warning("WS broadcast échoué (%s): %s", group, exc) + + +@receiver(post_save, sender=Notification) +def ws_push_notification(sender, instance, created, **kwargs): + """Temps réel : pousse chaque notification à son destinataire (qui a fait quoi).""" + if kwargs.get('raw') or not created: + return + _ws_broadcast(f"notifications_{instance.user_id}", { + 'event': 'notification', + 'id': instance.id, + 'type': instance.notif_type, # catégorie (collaboration_request, …) + 'title': instance.title, # libellé FR prêt à afficher + 'message': instance.message, + 'incident_title': getattr(getattr(instance, 'incident', None), 'title', None), + 'read': instance.read, + 'colaboration': instance.colaboration_id, + 'incident': instance.incident_id, + 'link': instance.redirect_link(), # cible de redirection au clic + 'created_at': instance.created_at.isoformat() if instance.created_at else None, + }) + + +@receiver(post_save, sender=DiscussionMessage) +def ws_push_discussion(sender, instance, created, **kwargs): + """Temps réel : pousse chaque message de discussion aux membres de l'incident.""" + if kwargs.get('raw') or not created: + return + _ws_broadcast(f"discussion_{instance.incident_id}", { + 'event': 'discussion_message', + 'id': instance.id, + 'incident': instance.incident_id, + 'collaboration': instance.collaboration_id, + 'sender': instance.sender_id, + 'message': instance.message, + 'created_at': instance.created_at.isoformat() if instance.created_at else None, + }) + + +@receiver(post_save, sender=IncidentTask) +def ws_push_task(sender, instance, created, **kwargs): + """Temps réel : pousse les créations/màj de tâches aux membres de l'incident.""" + if kwargs.get('raw'): + return + _ws_broadcast(f"tasks_{instance.incident_id}", { + 'event': 'task_created' if created else 'task_updated', + 'id': instance.id, + 'incident': instance.incident_id, + 'title': instance.title, + 'state': instance.state, + 'assigned_to': instance.assigned_to_id, + 'updated_at': instance.updated_at.isoformat() if getattr(instance, 'updated_at', None) else None, + }) + + +@receiver(post_delete, sender=IncidentTask) +def ws_push_task_deleted(sender, instance, **kwargs): + """Temps réel : pousse la SUPPRESSION d'une tâche aux membres de l'incident, + pour que la carte disparaisse instantanément (et pas seulement au refetch).""" + if kwargs.get('raw'): + return + _ws_broadcast(f"tasks_{instance.incident_id}", { + 'event': 'task_deleted', + 'id': instance.id, + 'incident': instance.incident_id, + }) + + +@receiver(post_save, sender=UserAction) +def ws_push_activity(sender, instance, created, **kwargs): + """Temps réel : pousse chaque nouvelle action au flux d'activité global + (groupe ``activity_feed``). L'ActivityFeedConsumer filtre côté serveur + l'activité de l'organisation du viewer — comme le REST /activity-feed/ qui + exclut sa propre organisation.""" + if kwargs.get('raw') or not created: + return + u = instance.user + org = getattr(u, 'organisation_member', None) if u else None + user_name = (f"{u.first_name or ''} {u.last_name or ''}".strip() or u.email) if u else None + org_name = org.name if org else None + _ws_broadcast("activity_feed", { + 'event': 'activity', + 'id': instance.id, + 'action': instance.action, + 'user': u.id if u else None, + 'user_name': user_name, + 'organisation_id': org.id if org else None, + 'organisation_name': org_name, + 'actor': org_name or user_name, # à afficher en tête (org, repli sur la personne) + 'created_at': instance.created_at.isoformat() if getattr(instance, 'created_at', None) else None, + 'timeStamp': instance.timeStamp.isoformat() if getattr(instance, 'timeStamp', None) else None, + }) + + +@receiver(pre_save, sender=Collaboration) +def _capture_collab_old_status(sender, instance, **kwargs): + """Capture l'ancien statut pour détecter accept/decline dans le post_save.""" + if instance.pk: + instance._old_status = Collaboration.objects.filter(pk=instance.pk).values_list('status', flat=True).first() + else: + instance._old_status = None + + +@receiver(post_save, sender=Collaboration) +def ws_push_collaboration(sender, instance, created, **kwargs): + """Temps réel : pousse les créations/màj de collaboration à l'émetteur ET au + leader de l'incident → onglet collaboration + demandes instantanés. + Groupe ``collaborations_`` (cf. CollaborationConsumer).""" + if kwargs.get('raw'): + return + inc = getattr(instance, 'incident', None) + sender = getattr(instance, 'user', None) + sender_org = getattr(sender, 'organisation_member', None) if sender else None + payload = { + 'event': 'collaboration_created' if created else 'collaboration_updated', + 'id': instance.id, + 'incident': instance.incident_id, + 'incident_title': getattr(inc, 'title', None), # pour afficher sans refetch + 'status': instance.status, + 'role': instance.role, + 'sender': instance.user_id, + 'sender_name': (f"{sender.first_name or ''} {sender.last_name or ''}".strip() or sender.email) if sender else None, + 'sender_organisation': getattr(sender_org, 'name', None), + 'created_at': instance.created_at.isoformat() if getattr(instance, 'created_at', None) else None, + } + # Cibles : l'émetteur (sa liste "envoyées") + le leader/récepteur (ses "demandes reçues"). + targets = {instance.user_id} + leader_id = getattr(getattr(instance, 'incident', None), 'taken_by_id', None) + if leader_id: + targets.add(leader_id) + for uid in targets: + if uid: + _ws_broadcast(f"collaborations_{uid}", payload) + # Journalise l'activité (flux d'activité + journal d'actions). + try: + # L'acteur (nom + organisation) est exposé séparément par ActivityFeedSerializer + # (user_name / organisation_name) ; le texte de l'action ne le répète donc pas. + incident_title = getattr(getattr(instance, 'incident', None), 'title', None) or "un incident" + if created: + UserAction.objects.create( + user=instance.user, + action=f"a demandé une collaboration sur l'incident «{incident_title}»."[:255], + ) + elif getattr(instance, '_old_status', None) != instance.status and instance.status in ('accepted', 'declined'): + actor = getattr(getattr(instance, 'incident', None), 'taken_by', None) or instance.user + verbe = "a accepté" if instance.status == 'accepted' else "a refusé" + UserAction.objects.create( + user=actor, + action=f"{verbe} une demande de collaboration sur l'incident «{incident_title}»."[:255], + ) + except Exception as exc: # ne jamais casser l'écriture DB + logger.warning("log activité collaboration échoué: %s", exc) + + @receiver(post_save, sender=Collaboration) def notify_organisation_on_collaboration(sender, instance, created, **kwargs): + if kwargs.get('raw'): + return # chargement de fixtures (loaddata) : ne pas déclencher la logique métier if created: incident = instance.incident user = incident.taken_by - requesting_user = instance.user - requesting_organisation = getattr(requesting_user, 'organisation', '') - if hasattr(requesting_user, 'organisation_member') and requesting_user.organisation_member: - requesting_organisation = requesting_user.organisation_member.name - - target_organisation = getattr(user, 'organisation', '') - if hasattr(user, 'organisation_member') and user.organisation_member: - target_organisation = user.organisation_member.name - + requesting_user = instance.user + # La collaboration AUTO-CRÉÉE du LEADER lui-même (lors de la prise en charge, + # rôle 'leader') n'est PAS une demande d'une autre organisation : on ne notifie + # pas / on n'envoie pas d'email au leader à propos de sa propre prise en charge + # (sinon « Bonjour USAD, l'organisation USAD souhaite collaborer… »). + if user and requesting_user and ( + requesting_user.id == user.id or instance.role == COLLAB_ROLE_LEADER): + return + # Nom réel de l'organisation du demandeur (organisation_member) — pas le champ + # legacy `organisation` souvent vide, qui affichait « L'organisation None ». + requesting_organisation = ( + getattr(getattr(requesting_user, 'organisation_member', None), 'name', None) + or requesting_user.organisation or "Une organisation" + ) + if user and user.email: try: context = { @@ -27,8 +216,8 @@ def notify_organisation_on_collaboration(sender, instance, created, **kwargs): 'incident_title': incident.title, 'incident_zone': incident.zone, 'incident_creation_date': incident.created_at, - 'organisation': target_organisation, - 'requesting_organisation': requesting_organisation + 'organisation': getattr(getattr(user, 'organisation_member', None), 'name', None) or user.organisation, + 'requesting_organisation': requesting_organisation } # Envoi de l'email à l'organisation @@ -43,8 +232,10 @@ def notify_organisation_on_collaboration(sender, instance, created, **kwargs): # Création de la notification pour l'organisation Notification.objects.create( user=user, + notif_type='collaboration_request', message=f"L'organisation {requesting_organisation} souhaite collaborer sur l'incident {incident.title} (Zone: {incident.zone}, Date: {incident.created_at.strftime('%d-%m-%Y')})", - colaboration=instance + colaboration=instance, + incident=incident, ) logger.info(f"Notification créée pour l'utilisateur {user.email}.") diff --git a/Mapapi/tasks.py b/Mapapi/tasks.py index 615ac077..b837deb3 100644 --- a/Mapapi/tasks.py +++ b/Mapapi/tasks.py @@ -7,22 +7,44 @@ import os import logging import mimetypes +from datetime import timedelta import requests from celery import shared_task from django.conf import settings - -from Mapapi.models import Prediction, PredictionStatus +from django.db import transaction +from django.utils import timezone + +from Mapapi.models import ( + Prediction, PredictionStatus, Incident, Collaboration, Notification, + IN_VALIDATION, RESOLVED_DEFINITIVE, TAKEN, DECLARED, + COLLAB_STATUS_ACCEPTED, COLLAB_STATUS_TERMINATED, + IncidentOrgAssignment, ORG_ASSIGNMENT_PENDING, ORG_ASSIGNMENT_ACCEPTED, + ORG_ROLE_ADMIN, ANTI_GEL_DEADLINE_DAYS, +) from Mapapi.services.prediction_mapper import fill_prediction_from_model_response logger = logging.getLogger(__name__) +# Anti-gel (spec T3) : délai de repli quand la sévérité de l'Incident est nulle/inconnue. +# La spec prévoit Élevée 30 j / Moyenne 60 j / Faible 90 j (cf. Incident.severity et +# ANTI_GEL_DEADLINE_DAYS dans models.py). Faute de sévérité exploitable, on applique 60 j. +ANTI_GEL_DEFAULT_DAYS = 60 + + +def _antigel_deadline_days(incident): + """Délai anti-gel (en jours) pour `incident` selon sa sévérité. + + Repli sur ANTI_GEL_DEFAULT_DAYS (60 j) si la sévérité est nulle/inconnue. + """ + return ANTI_GEL_DEADLINE_DAYS.get(incident.severity, ANTI_GEL_DEFAULT_DAYS) + def _get_analyze_url(): return getattr( settings, "MODEL_DEPLOY_ANALYZE_URL", - os.getenv("MODEL_DEPLOY_ANALYZE_URL", "http://localhost:8001/api1/analyze/"), + os.getenv("MODEL_DEPLOY_ANALYZE_URL", "http://localhost:8001/analyze/upload"), ) @@ -127,6 +149,213 @@ def analyze_incident_with_model_task(self, prediction_id): raise +# ============================================================================ +# Phase 4 — mécanismes temporels du cycle de vie de l'incident (Celery Beat) +# Tâches idempotentes : sûres à rejouer ; n'agissent que sur les lignes éligibles. +# ============================================================================ + +@shared_task +def auto_validate_overdue_resolutions(): + """Validation tacite à 72 h (spec D1). + + Tout incident en 'in_validation' dont validation_deadline est dépassée passe + automatiquement en 'resolved_definitive' (le Super Admin n'a pas tranché à temps). + Idempotent : ne sélectionne que les lignes encore 'in_validation' avec une + échéance passée ; une fois basculées, elles ne ressortent plus. + """ + now = timezone.now() + qs = Incident.objects.filter( + etat=IN_VALIDATION, + validation_deadline__isnull=False, + validation_deadline__lt=now, + ) + count = 0 + for incident in qs: + incident.etat = RESOLVED_DEFINITIVE + incident.save(update_fields=['etat']) + # Spec §5 : à la résolution définitive, les collaborations encore actives + # passent en « Terminée ». Idempotent (ne touche que les 'accepted'). + Collaboration.objects.filter( + incident=incident, + status=COLLAB_STATUS_ACCEPTED, + ).update(status=COLLAB_STATUS_TERMINATED) + count += 1 + logger.info( + "auto_validate_overdue_resolutions: incident=%s validé tacitement " + "(deadline=%s) -> resolved_definitive", + incident.pk, incident.validation_deadline, + ) + return {"validated": count} + + +@shared_task +def revert_stale_taken_incidents(): + """Anti-gel / délai d'échec de prise en compte (spec T3 / §5). + + Pour chaque incident 'taken_into_account' avec taken_in_charge_at non nul, on + calcule le délai (deadline) selon sa sévérité — Élevée 30 j / Moyenne 60 j / + Faible 90 j ; repli ANTI_GEL_DEFAULT_DAYS (60 j) si nulle/inconnue — et l'écoulé + (elapsed = now - taken_in_charge_at) : + + - elapsed >= deadline -> retour en 'declared' (champs de prise en charge + remis à zéro), drapeaux d'avertissement réarmés. + - elapsed >= 0.90*deadline -> avertissement au leader (taken_by) à 90 %, une + seule fois (antigel_warned_90, + 75 par sûreté). + - elapsed >= 0.75*deadline -> avertissement au leader (taken_by) à 75 %, une + seule fois (antigel_warned_75). + + Avertissements : même schéma que ReportToAdminView (Notification au leader, + colaboration=None, message tronqué à 255 car.). Si taken_by est nul, on saute + la Notification mais on positionne quand même le drapeau (pas de re-déclenchement). + + On exige taken_in_charge_at non nul : les incidents pris en compte AVANT l'ajout + de ce champ (timestamp nul) ne sont jamais traités (pas de date fiable). + Idempotent : un incident repassé 'declared' ne ressort plus ; un avertissement + déjà émis (drapeau posé) ne se redéclenche pas. + """ + now = timezone.now() + qs = Incident.objects.filter( + etat=TAKEN, + taken_in_charge_at__isnull=False, + ) + reverted = 0 + warned_75 = 0 + warned_90 = 0 + for incident in qs: + deadline_days = _antigel_deadline_days(incident) + elapsed = now - incident.taken_in_charge_at + deadline = timedelta(days=deadline_days) + + if elapsed >= deadline: + incident.etat = DECLARED + incident.taken_by = None + incident.take_in_charge_mode = None + incident.taken_in_charge_at = None + incident.antigel_warned_75 = False + incident.antigel_warned_90 = False + incident.save(update_fields=[ + 'etat', 'taken_by', 'take_in_charge_mode', 'taken_in_charge_at', + 'antigel_warned_75', 'antigel_warned_90', + ]) + reverted += 1 + logger.info( + "revert_stale_taken_incidents: incident=%s gelé > %s j -> declared " + "(anti-gel, spec T3)", + incident.pk, deadline_days, + ) + + elif elapsed >= 0.90 * deadline and not incident.antigel_warned_90: + _notify_antigel_leader(incident, 90, deadline_days) + # Poser aussi 75 pour ne pas déclencher l'avertissement à 75 % en retard. + incident.antigel_warned_90 = True + incident.antigel_warned_75 = True + incident.save(update_fields=['antigel_warned_90', 'antigel_warned_75']) + warned_90 += 1 + + elif elapsed >= 0.75 * deadline and not incident.antigel_warned_75: + _notify_antigel_leader(incident, 75, deadline_days) + incident.antigel_warned_75 = True + incident.save(update_fields=['antigel_warned_75']) + warned_75 += 1 + + return {"reverted": reverted, "warned_75": warned_75, "warned_90": warned_90} + + +def _notify_antigel_leader(incident, pct, deadline_days): + """Notifie le leader (incident.taken_by) qu'un seuil anti-gel est atteint. + + Même schéma que ReportToAdminView : Notification(user=leader, colaboration=None, + message tronqué à 255 car.). Sans leader (taken_by nul), on ne crée rien (le + drapeau est posé par l'appelant pour éviter tout re-déclenchement). + """ + leader = incident.taken_by + if leader is None: + return + titre = incident.title or incident.zone + message = ( + f"Anti-gel : l'incident « {titre} » a atteint {pct} % " + f"du délai de prise en compte ({deadline_days} j). Agissez pour éviter " + f"son retour automatique en « Déclaré »." + )[:255] + Notification.objects.create(user=leader, notif_type='deadline_warning', + message=message, colaboration=None, incident=incident) + + +@shared_task +def purge_expired_trash(): + """Purge de la Corbeille à 30 j (spec D10). + + Suppression DÉFINITIVE (.delete()) des incidents en corbeille (is_deleted=True) + dont la mise en corbeille (deleted_at) date de plus de 30 jours. Les + suppressions antérieures à l'ajout de deleted_at (timestamp nul) ne sont PAS + purgées — on ne supprime que ce qu'on peut dater de façon fiable. + Idempotent : les lignes purgées disparaissent ; relancer ne refait rien. + """ + cutoff = timezone.now() - timedelta(days=30) + qs = Incident.objects.filter( + is_deleted=True, + deleted_at__isnull=False, + deleted_at__lt=cutoff, + ) + purged_ids = list(qs.values_list('pk', flat=True)) + count = 0 + with transaction.atomic(): + for incident in qs: + incident.delete() + count += 1 + logger.info( + "purge_expired_trash: incident=%s purgé définitivement " + "(deleted_at antérieur à %s)", + incident.pk, cutoff, + ) + return {"purged": count, "ids": purged_ids} + + +@shared_task +def auto_accept_overdue_assignments(): + """Acceptation tacite des assignations d'organisation à 72 h (spec D4). + + Toute IncidentOrgAssignment 'pending' dont la deadline est dépassée passe + automatiquement en 'accepted' (l'Admin de l'organisation cible n'a pas + répondu à temps), avec responded_at = maintenant, et l'incident est engagé + comme dans l'endpoint accept ('declared' → 'taken_into_account', taken_by + + taken_in_charge_at). taken_by est fixé à un Admin de l'organisation s'il en + existe un (l'org engage via l'un de ses Admins). Idempotent : ne sélectionne + que les lignes encore 'pending' avec une échéance passée. + """ + now = timezone.now() + qs = IncidentOrgAssignment.objects.select_related( + 'incident', 'organisation' + ).filter( + status=ORG_ASSIGNMENT_PENDING, + deadline__lt=now, + ) + count = 0 + for assignment in qs: + assignment.status = ORG_ASSIGNMENT_ACCEPTED + assignment.responded_at = now + assignment.save(update_fields=['status', 'responded_at']) + + # Engager l'incident : taken_by = un Admin de l'org cible si disponible. + org_admin = assignment.organisation.members.filter( + org_role=ORG_ROLE_ADMIN + ).first() + incident = assignment.incident + if incident.etat == DECLARED: + incident.etat = TAKEN + incident.taken_by = org_admin + incident.taken_in_charge_at = now + incident.save(update_fields=['etat', 'taken_by', 'taken_in_charge_at']) + + count += 1 + logger.info( + "auto_accept_overdue_assignments: assignation=%s acceptée tacitement " + "(deadline=%s) -> incident %s engagé", + assignment.pk, assignment.deadline, incident.pk, + ) + return {"accepted": count} + + # --- Legacy code, kept for reference ----------------------------------------- # from celery import shared_task # from django_http_exceptions import HTTPExceptions diff --git a/Mapapi/urls.py b/Mapapi/urls.py index faf844e8..51bf3bd2 100644 --- a/Mapapi/urls.py +++ b/Mapapi/urls.py @@ -3,6 +3,7 @@ from .views.task import ( IncidentTaskListCreateView, IncidentTaskDetailView, IncidentTaskCompleteView, IncidentTaskFailView, IncidentTaskConfirmView, + IncidentTaskRelaunchView, ) from .views.partner_suggestion import ( PartnerSuggestionListCreateView, PartnerSuggestionDetailView, @@ -11,7 +12,6 @@ ) from .views.incident import ( TakeInChargeView, CloseIncidentView, MyIncidentsView, - MyIncidentNotificationsView, OrgIncidentsView, AgentCodeLoginView, ToggleIncidentPublicView, TrashIncidentsView, RestoreIncidentView, IncidentAssignmentListCreateView, IncidentAssignmentDetailView, @@ -20,7 +20,11 @@ BulkForceDeleteIncidentsView, IncidentPredictionView, RetryIncidentPredictionView, IncidentChatView, AgentPinLoginView, AgentChangePinView, - GlobalImpactAPIView, + PrepareResolutionView, ReturnForCompletionView, DeclareResolvedView, + DisengageIncidentView, + ValidateResolutionView, RejectResolutionView, ReportToAdminView, + AssignIncidentToOrganisationView, AcceptOrgAssignmentView, + DeclineOrgAssignmentView, ) from .views.collaboration import ( BulkCollaborationRequestView, @@ -28,7 +32,7 @@ ) from .views.organisation import ( OrganisationMemberListView, OrganisationMemberCreateView, - OrganisationMemberDetailView, OrganisationDetailView, + OrganisationMemberDetailView, OrganisationDetailView, OrganisationStatsView, FieldAgentCreateView, StaffAccountCreateView, ) from .ivr_views import ( @@ -47,20 +51,24 @@ TokenVerifyView, ) from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView +from . import schema # noqa: F401 — enregistre les extensions drf-spectacular (sécurité Bearer/cookie) from .views import PasswordResetView +from .views.auth_cookie import ( + CookieTokenObtainPairView, CookieTokenRefreshView, CookieLogoutView, +) urlpatterns = [ path('tenant-config/', TenantConfigView.as_view(), name='tenant_config'), - path('impact/global/', GlobalImpactAPIView.as_view(), name='global-impact'), path('organisations/', OrganisationViewSet.as_view(), name='organisation-list-create'), - path('organisations//', OrganisationViewSet.as_view(), name='organisation-detail'), - path('organisations//detail/', OrganisationDetailView.as_view(), name='organisation-detail-enriched'), + path('organisations/stats/', OrganisationStatsView.as_view(), name='organisation-stats'), + path('organisations//', OrganisationViewSet.as_view(), name='organisation-detail'), + path('organisations//detail/', OrganisationDetailView.as_view(), name='organisation-detail-enriched'), # --- Gestion des membres d'une organisation --- - path('organisations//members/', OrganisationMemberListView.as_view(), name='organisation-members-list'), - path('organisations//members/add/', OrganisationMemberCreateView.as_view(), name='organisation-members-add'), - path('organisations//members//', OrganisationMemberDetailView.as_view(), name='organisation-members-detail'), - path('organisations//agents/create/', FieldAgentCreateView.as_view(), name='organisation-field-agent-create'), - path('organisations//staff/create/', StaffAccountCreateView.as_view(), name='organisation-staff-create'), + path('organisations//members/', OrganisationMemberListView.as_view(), name='organisation-members-list'), + path('organisations//members/add/', OrganisationMemberCreateView.as_view(), name='organisation-members-add'), + path('organisations//members//', OrganisationMemberDetailView.as_view(), name='organisation-members-detail'), + path('organisations//agents/create/', FieldAgentCreateView.as_view(), name='organisation-field-agent-create'), + path('organisations//staff/create/', StaffAccountCreateView.as_view(), name='organisation-staff-create'), # URL PATTERNS for the documentation path('api/schema/', SpectacularAPIView.as_view(), name='schema'), # Optional UI: @@ -68,20 +76,21 @@ path('schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), path('accounts/', include('allauth.urls')), # for token - path('login/', TokenObtainPairView.as_view(), name='login'), - path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('login/', CookieTokenObtainPairView.as_view(), name='login'), + path('api/token/', CookieTokenObtainPairView.as_view(), name='token_obtain_pair'), path('verify-token/', TokenVerifyView.as_view(), name='token_verify'), - path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('token/refresh/', CookieTokenRefreshView.as_view(), name='token_refresh'), + path('logout/', CookieLogoutView.as_view(), name='logout'), path('get_csrf_token/', get_csrf_token, name="get_csrf_token"), path("gettoken_bymail/", GetTokenByMailView.as_view(), name="get_token_by_mail"), # path('login/', login), path('register/', UserRegisterView, name='register'), - path('user//', user_api_view, name='user'), + path('user//', user_api_view, name='user'), path('user/', UserAPIListView.as_view(), name='user_list'), path('user_retrieve/', UserRetrieveView.as_view(), name='user_retrieve'), # URL for views incidents path('incidentByZone//', IncidentByZoneAPIView.as_view(), name='incidentZone'), - path('incident/', IncidentAPIView.as_view(), name='incident_rud'), + path('incident/', IncidentAPIView.as_view(), name='incident_rud'), path('incident/', IncidentAPIListView.as_view(), name='incident'), path('incidentResolved/', IncidentResolvedAPIListView.as_view(), name='incidentResolved'), path('incidentNotResolved/', IncidentNotResolvedAPIListView.as_view(), name='incidentNotResolved'), @@ -90,62 +99,65 @@ path('IncidentOnWeek/', IncidentOnWeekAPIListView.as_view(), name='IncidentOnWeek'), path('IncidentOnWeek_zone/', IncidentByWeekByZoneAPIView.as_view(), name='IncidentOnWeek_zone'), path('incident-filter/', IncidentFilterView.as_view(), name='incident_filter'), + path('incidents/dashboard-stats/', IncidentDashboardStatsView.as_view(), name='incident-dashboard-stats'), + path('impact/', ImpactView.as_view(), name='impact'), + path('impact/incidents/', ImpactIncidentsView.as_view(), name='impact-incidents'), path('my-incidents/', MyIncidentsView.as_view(), name='my-incidents'), - path('my-incidents/notifications/', MyIncidentNotificationsView.as_view(), name='my-incident-notifications'), + path('my-interventions/', MyInterventionsView.as_view(), name='my-interventions'), path('org-incidents/', OrgIncidentsView.as_view(), name='org-incidents'), - path('incidents//prediction/', IncidentPredictionView.as_view(), name='incident-prediction'), - path('incidents//prediction/retry/', RetryIncidentPredictionView.as_view(), name='incident-prediction-retry'), - path('incidents//chat/', IncidentChatView.as_view(), name='incident-chat'), + path('incidents//prediction/', IncidentPredictionView.as_view(), name='incident-prediction'), + path('incidents//prediction/retry/', RetryIncidentPredictionView.as_view(), name='incident-prediction-retry'), + path('incidents//chat/', IncidentChatView.as_view(), name='incident-chat'), path('agent/assigned-incidents/', AgentAssignedIncidentsView.as_view(), name='agent-assigned-incidents'), path('field-reports/', FieldReportListCreateView.as_view(), name='field-reports'), path('agent-login/', AgentCodeLoginView.as_view(), name='agent-login'), path('agent-pin-login/', AgentPinLoginView.as_view(), name='agent-pin-login'), path('agent/change-pin/', AgentChangePinView.as_view(), name='agent-change-pin'), # URL for views Events - path('Event/', EvenementAPIView.as_view(), name='event'), + path('Event/', EvenementAPIView.as_view(), name='event'), path('Event/', EvenementAPIListView.as_view(), name='event'), # URL for views contact - path('contact/', ContactAPIView.as_view(), name='contact'), + path('contact/', ContactAPIView.as_view(), name='contact'), path('contact/', ContactAPIListView.as_view(), name='contact'), # URL for views community - path('community/', CommunauteAPIView.as_view(), name='community'), + path('community/', CommunauteAPIView.as_view(), name='community'), path('community/', CommunauteAPIListView.as_view(), name='community'), # URL for views rapport - path('rapport/', RapportAPIView.as_view(), name='rapport'), + path('rapport/', RapportAPIView.as_view(), name='rapport'), path('rapport/', RapportAPIListView.as_view(), name='rapport_list'), - path('rapport_user/', RapportByUserAPIView.as_view(), name='rapport_user'), + path('rapport_user/', RapportByUserAPIView.as_view(), name='rapport_user'), path('rapport_zone/', RapportOnZoneAPIView.as_view(), name='rapport_zone'), # URL for views participate - path('participate/', ParticipateAPIView.as_view(), name='participate_rud'), + path('participate/', ParticipateAPIView.as_view(), name='participate_rud'), path('participate/', ParticipateAPIListView.as_view(), name='participate'), # URL for views Elu - path('elu/', EluAPIListView.as_view(), name='elu_rud'), + path('elu/', EluAPIListView.as_view(), name='elu_rud'), path('elu/', EluToZoneAPIListView.as_view(), name='elu_zone'), # URL for views citizen path('citizen/', CitizenAPIListView.as_view(), name='citizen'), # URL for views zone - path('zone/', ZoneAPIView.as_view(), name='zone'), + path('zone/', ZoneAPIView.as_view(), name='zone'), path('zone/', ZoneAPIListView.as_view(), name='zone_list'), # URL for views message - path('message/', MessageAPIView.as_view(), name='message'), + path('message/', MessageAPIView.as_view(), name='message'), path('message/', MessageAPIListView.as_view(), name='message_list'), path('message/', MessageByComAPIView.as_view(), name='message_com'), - path('message_user//', MessageByUserAPIView.as_view(), name='message_user'), + path('message_user//', MessageByUserAPIView.as_view(), name='message_user'), path('message/', MessageByZoneAPIView.as_view(), name='message_zone'), path('response_msg/', ResponseMessageAPIListView.as_view(), name='response_msg'), - path('response_msg/', ResponseMessageAPIView.as_view(), name='response_msg'), + path('response_msg/', ResponseMessageAPIView.as_view(), name='response_msg'), # URL for views category - path('category/', CategoryAPIView.as_view(), name='category-detail'), + path('category/', CategoryAPIView.as_view(), name='category-detail'), path('category/', CategoryAPIListView.as_view(), name='category-list'), # URL for views indicator path('indicator/', IndicateurAPIListView.as_view(), name='indicator'), - path('indicator/', IndicateurAPIView.as_view(), name='indicator'), + path('indicator/', IndicateurAPIView.as_view(), name='indicator'), path('indicator_incident/', IndicateurOnIncidentAPIListView.as_view(), name='indicator_incident'), path('indicator_incident_zone/', IndicateurOnIncidentByZoneAPIView.as_view(), name='indicator_incident_zone'), - path('indicator_incident_elu/', IndicateurOnIncidentByEluAPIView.as_view(), name='indicator_incident_elu'), + path('indicator_incident_elu/', IndicateurOnIncidentByEluAPIView.as_view(), name='indicator_incident_elu'), # URL for views imageBackground path('image/', ImageBackgroundAPIListView.as_view(), name='image'), - path('image/', ImageBackgroundAPIView.as_view(), name='image'), + path('image/', ImageBackgroundAPIView.as_view(), name='image'), # URL for views password path('password/', PasswordResetRequestView.as_view(), name='passwordRequest'), path('password_reset/', PasswordResetView.as_view(), name='passwordReset'), @@ -157,29 +169,31 @@ path('verify_otp/', PhoneOTPView.as_view(), name="verify_otp"), # Collaboration URL path('collaboration/', CollaborationView.as_view(), name='collaboration'), - path('collaboration//', CollaborationDetailView.as_view(), name='collaboration-detail'), + path('collaboration//', CollaborationDetailView.as_view(), name='collaboration-detail'), path('collaborations/bulk-request/', BulkCollaborationRequestView.as_view(), name='bulk-collaboration-request'), path('collaborations/dashboard/', CollaborationDashboardView.as_view(), name='collaboration-dashboard'), path('accept-collaboration/', AcceptCollaborationView.as_view(), name='accept-collaboration'), path('decline/', DeclineCollaborationView.as_view(), name='decline-collaboration'), path('collaborations/accept/', AcceptCollaborationView.as_view(), name='accept-collaboration'), - path('collaboration///', HandleCollaborationRequestView.as_view(), name="handle_collaboration_request"), - path('discussion//', DiscussionMessageView.as_view(), name='discussion'), + path('collaboration///', HandleCollaborationRequestView.as_view(), name="handle_collaboration_request"), + path('discussion//', DiscussionMessageView.as_view(), name='discussion'), # Search Incident path('Search/', IncidentSearchView.as_view(), name="search"), path('prediction/', PredictionView.as_view(), name="predicton"), - path('histories/', history_list, name='history_list'), - path('history/', ChatHistoryViewByIncident.as_view(), name='history_by_id'), - path('histories/add/', add_history, name='add_history'), # Prediction - path('prediction//', PredictionViewByID.as_view(), name="predicton"), - path('Incidentprediction//', PredictionViewByIncidentID.as_view(), name="prediction"), + path('prediction//', PredictionViewByID.as_view(), name="predicton"), + path('Incidentprediction//', PredictionViewByIncidentID.as_view(), name="prediction"), # Notification path('notifications/', NotificationViewSet.as_view({'get': 'list'}), name="notification"), - path('hadleIncident/', HandleIncidentView.as_view(), name="handle"), + path('notifications/mark-all-read/', NotificationViewSet.as_view({'post': 'mark_all_read'}), name="notification-mark-all-read"), + path('notifications//', NotificationViewSet.as_view({'get': 'retrieve', 'patch': 'partial_update'}), name="notification-detail"), + path('activity-feed/', ActivityFeedView.as_view(), name="activity-feed"), + path('agents/stats/', AgentStatsView.as_view(), name="agents-stats"), + path('agents/', AgentListView.as_view(), name="agents-list"), + path('hadleIncident/', HandleIncidentView.as_view(), name="handle"), path('user_action/', UserActionView.as_view({'get': 'list'}), name="user_action"), - path('incidentDetail/', IncidentUserView.as_view(), name="incident_detail"), + path('incidentDetail/', IncidentUserView.as_view(), name="incident_detail"), path('registerCitizen/', RegisterView.as_view(), name='registerCitizen'), path('verify-email//', VerifyEmailView.as_view(), name='verify-email'), path('set-password/', SetPasswordView.as_view(), name='set-password'), @@ -193,36 +207,51 @@ path('ivr/process-recording/', ProcessRecordingView.as_view(), name='ivr-process-recording'), path('ivr/recording-status/', RecordingStatusView.as_view(), name='ivr-recording-status'), path('ivr/calls/', IVRCallListView.as_view(), name='ivr-calls-list'), - path('ivr/calls//', IVRCallDetailView.as_view(), name='ivr-call-detail'), + path('ivr/calls//', IVRCallDetailView.as_view(), name='ivr-call-detail'), # --- Tâches d'incident (CRUD + complete/fail) --- - path('incidents//tasks/', IncidentTaskListCreateView.as_view(), name='incident-task-list'), - path('incidents//tasks//', IncidentTaskDetailView.as_view(), name='incident-task-detail'), - path('incidents//tasks//complete/', IncidentTaskCompleteView.as_view(), name='incident-task-complete'), - path('incidents//tasks//fail/', IncidentTaskFailView.as_view(), name='incident-task-fail'), - path('incidents//tasks//confirm/', IncidentTaskConfirmView.as_view(), name='incident-task-confirm'), + path('incidents//tasks/', IncidentTaskListCreateView.as_view(), name='incident-task-list'), + path('incidents//tasks//', IncidentTaskDetailView.as_view(), name='incident-task-detail'), + path('incidents//tasks//complete/', IncidentTaskCompleteView.as_view(), name='incident-task-complete'), + path('incidents//tasks//fail/', IncidentTaskFailView.as_view(), name='incident-task-fail'), + path('incidents//tasks//confirm/', IncidentTaskConfirmView.as_view(), name='incident-task-confirm'), + path('incidents//tasks//relaunch/', IncidentTaskRelaunchView.as_view(), name='incident-task-relaunch'), # --- Suggestions de partenaires (CRUD + accept/reject) --- - path('incidents//suggestions/', PartnerSuggestionListCreateView.as_view(), name='partner-suggestion-list'), - path('incidents//suggestions//', PartnerSuggestionDetailView.as_view(), name='partner-suggestion-detail'), - path('incidents//suggestions//accept/', PartnerSuggestionAcceptView.as_view(), name='partner-suggestion-accept'), - path('incidents//suggestions//reject/', PartnerSuggestionRejectView.as_view(), name='partner-suggestion-reject'), + path('incidents//suggestions/', PartnerSuggestionListCreateView.as_view(), name='partner-suggestion-list'), + path('incidents//suggestions//', PartnerSuggestionDetailView.as_view(), name='partner-suggestion-detail'), + path('incidents//suggestions//accept/', PartnerSuggestionAcceptView.as_view(), name='partner-suggestion-accept'), + path('incidents//suggestions//reject/', PartnerSuggestionRejectView.as_view(), name='partner-suggestion-reject'), path('my-suggestions/received/', MyReceivedSuggestionsView.as_view(), name='my-suggestions-received'), path('my-suggestions/sent/', MySentSuggestionsView.as_view(), name='my-suggestions-sent'), # --- Prise en charge et clôture d'incident --- - path('incidents//take_in_charge/', TakeInChargeView.as_view(), name='incident-take-in-charge'), - path('incidents//close/', CloseIncidentView.as_view(), name='incident-close'), - path('incidents//toggle-public/', ToggleIncidentPublicView.as_view(), name='incident-toggle-public'), - path('incidents//assignments/', IncidentAssignmentListCreateView.as_view(), name='incident-assignment-list'), - path('incidents//assignments//', IncidentAssignmentDetailView.as_view(), name='incident-assignment-detail'), + path('incidents//take_in_charge/', TakeInChargeView.as_view(), name='incident-take-in-charge'), + path('incidents//close/', CloseIncidentView.as_view(), name='incident-close'), + # --- Phase 4 : flux de résolution --- + path('incidents//prepare-resolution/', PrepareResolutionView.as_view(), name='incident-prepare-resolution'), + path('incidents//return-for-completion/', ReturnForCompletionView.as_view(), name='incident-return-for-completion'), + path('incidents//declare-resolved/', DeclareResolvedView.as_view(), name='incident-declare-resolved'), + path('incidents//disengage/', DisengageIncidentView.as_view(), name='incident-disengage'), + path('incidents//validate-resolution/', ValidateResolutionView.as_view(), name='incident-validate-resolution'), + path('incidents//reject-resolution/', RejectResolutionView.as_view(), name='incident-reject-resolution'), + path('incidents//report-to-admin/', ReportToAdminView.as_view(), name='incident-report-to-admin'), + path('incidents//toggle-public/', ToggleIncidentPublicView.as_view(), name='incident-toggle-public'), + path('incidents//reports/', IncidentReportsView.as_view(), name='incident-reports'), + path('incidents//assignments/', IncidentAssignmentListCreateView.as_view(), name='incident-assignment-list'), + path('incidents//assignments//', IncidentAssignmentDetailView.as_view(), name='incident-assignment-detail'), + + # --- Phase 4 : assignation d'un incident à une ORGANISATION (Super Admin, spec §2/§3, T5) --- + path('incidents//assign-to-organisation/', AssignIncidentToOrganisationView.as_view(), name='incident-assign-to-organisation'), + path('incident-org-assignments//accept/', AcceptOrgAssignmentView.as_view(), name='incident-org-assignment-accept'), + path('incident-org-assignments//decline/', DeclineOrgAssignmentView.as_view(), name='incident-org-assignment-decline'), path('incidents/bulk-delete/', BulkDeleteIncidentsView.as_view(), name='incident-bulk-delete'), path('incidents/bulk-restore/', BulkRestoreIncidentsView.as_view(), name='incident-bulk-restore'), path('incidents/bulk-force-delete/', BulkForceDeleteIncidentsView.as_view(), name='incident-bulk-force-delete'), # --- Corbeille (Super Admin uniquement) --- path('incidents/trash/', TrashIncidentsView.as_view(), name='incident-trash'), - path('incidents//restore/', RestoreIncidentView.as_view(), name='incident-restore'), + path('incidents//restore/', RestoreIncidentView.as_view(), name='incident-restore'), ] diff --git a/Mapapi/views/__init__.py b/Mapapi/views/__init__.py index b5181e37..7e5c620d 100644 --- a/Mapapi/views/__init__.py +++ b/Mapapi/views/__init__.py @@ -9,6 +9,7 @@ from .user import * # noqa: F401,F403 from .elu import * # noqa: F401,F403 from .incident import * # noqa: F401,F403 +from .impact import * # noqa: F401,F403 from .evenement import * # noqa: F401,F403 from .contact import * # noqa: F401,F403 from .communaute import * # noqa: F401,F403 @@ -26,3 +27,4 @@ from .misc import * # noqa: F401,F403 from .task import * # noqa: F401,F403 from .partner_suggestion import * # noqa: F401,F403 +from .auth_cookie import * # noqa: F401,F403 diff --git a/Mapapi/views/auth_cookie.py b/Mapapi/views/auth_cookie.py new file mode 100644 index 00000000..38eaf5b1 --- /dev/null +++ b/Mapapi/views/auth_cookie.py @@ -0,0 +1,182 @@ +"""Vues d'authentification déposant le JWT dans des cookies httpOnly. + +login → pose access_token + refresh_token (httpOnly) + un cookie csrftoken (lisible +par le SPA pour renvoyer X-CSRFToken). refresh → relit le refresh depuis le cookie. +logout → efface les cookies. Les tokens restent aussi dans le corps JSON pour le +mobile (Bearer) et la transition. +""" +from django.conf import settings +from django.middleware.csrf import get_token +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import ensure_csrf_cookie + +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework_simplejwt.exceptions import InvalidToken, TokenError +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView + +from drf_spectacular.utils import ( + extend_schema, OpenApiExample, OpenApiResponse, inline_serializer, +) +from rest_framework import serializers + + +def _set_auth_cookies(request, response, access=None, refresh=None): + """Pose les cookies JWT httpOnly. Secure + SameSite=None en HTTPS (cross-site + prod : front et back sur des domaines différents) ; Lax + non-secure en HTTP local.""" + secure = request.is_secure() + samesite = 'None' if secure else 'Lax' + common = dict(httponly=True, secure=secure, samesite=samesite, path='/') + if access: + response.set_cookie( + settings.AUTH_COOKIE_ACCESS, access, + max_age=settings.AUTH_COOKIE_ACCESS_MAX_AGE, **common, + ) + if refresh: + response.set_cookie( + settings.AUTH_COOKIE_REFRESH, refresh, + max_age=settings.AUTH_COOKIE_REFRESH_MAX_AGE, **common, + ) + + +@method_decorator(ensure_csrf_cookie, name='dispatch') +class CookieTokenObtainPairView(TokenObtainPairView): + """POST /login/ — pose les cookies httpOnly + renvoie aussi les tokens (mobile).""" + + # Le login établit l'authentification (via identifiants) ; il ne doit PAS faire + # tourner l'auth par cookie (sinon un cookie résiduel déclencherait une + # vérif CSRF et bloquerait la reconnexion). + authentication_classes = [] + + @extend_schema( + tags=['Authentification'], + operation_id='auth_login', + summary="Connexion (obtention des tokens JWT)", + description=( + "Authentifie un utilisateur par `email` + `password` et renvoie les tokens " + "JWT `access`/`refresh` dans le corps (utilisés par le mobile via " + "`Authorization: Bearer`). Pose aussi ces tokens dans des cookies httpOnly " + "(`access_token`, `refresh_token`) et ajoute un `csrftoken` dans le corps " + "(le SPA cross-site le renvoie via l'en-tête `X-CSRFToken`). Endpoint public ; " + "également monté sur `POST /api/token/` (alias)." + ), + request=inline_serializer( + name='AuthLoginRequest', + fields={ + 'email': serializers.EmailField(), + 'password': serializers.CharField(write_only=True), + }, + ), + responses={ + 200: inline_serializer( + name='AuthLoginResponse', + fields={ + 'access': serializers.CharField(), + 'refresh': serializers.CharField(), + 'csrftoken': serializers.CharField(), + }, + ), + 401: OpenApiResponse(description="Identifiants invalides (`{detail}`)."), + }, + examples=[ + OpenApiExample( + 'Connexion', + value={'email': 'agent@example.org', 'password': 'motdepasse'}, + request_only=True, + ), + OpenApiExample( + 'Tokens renvoyés', + value={ + 'access': 'eyJhbGciOiJI...', + 'refresh': 'eyJhbGciOiJI...', + 'csrftoken': 'p0Tk...CSRF', + }, + response_only=True, + ), + ], + ) + def post(self, request, *args, **kwargs): + response = super().post(request, *args, **kwargs) + if response.status_code == 200: + _set_auth_cookies( + request, response, + access=response.data.get('access'), + refresh=response.data.get('refresh'), + ) + # Renvoie aussi le token CSRF DANS LE CORPS : en cross-site le SPA ne + # peut pas lire le cookie csrftoken (domaine backend) → il lit cette + # valeur et la renvoie en X-CSRFToken sur les écritures. + response.data['csrftoken'] = get_token(request) + return response + + +class CookieTokenRefreshView(TokenRefreshView): + """POST /token/refresh/ — relit le refresh depuis le corps OU le cookie.""" + + # Le refresh s'authentifie via le refresh token lui-même, pas via l'access + # cookie → pas d'auth par cookie ici (donc pas de blocage CSRF). + authentication_classes = [] + + @extend_schema( + tags=['Authentification'], + operation_id='auth_token_refresh', + summary="Rafraîchir le token d'accès", + description=( + "Renvoie un nouveau token `access` à partir d'un `refresh` valide. Le refresh " + "est lu depuis le corps `{refresh}` ou, à défaut, depuis le cookie httpOnly " + "`refresh_token`. Met aussi à jour les cookies d'authentification. Endpoint public." + ), + request=inline_serializer( + name='AuthTokenRefreshRequest', + fields={'refresh': serializers.CharField(required=False)}, + ), + responses={ + 200: inline_serializer( + name='AuthTokenRefreshResponse', + fields={'access': serializers.CharField()}, + ), + 401: OpenApiResponse(description="Refresh token invalide ou expiré (`{detail}`)."), + }, + ) + def post(self, request, *args, **kwargs): + refresh = request.data.get('refresh') or request.COOKIES.get(settings.AUTH_COOKIE_REFRESH) + serializer = self.get_serializer(data={'refresh': refresh}) + try: + serializer.is_valid(raise_exception=True) + except TokenError as exc: + raise InvalidToken(exc.args[0]) + response = Response(serializer.validated_data, status=status.HTTP_200_OK) + _set_auth_cookies( + request, response, + access=serializer.validated_data.get('access'), + refresh=serializer.validated_data.get('refresh'), + ) + return response + + +class CookieLogoutView(APIView): + """POST /logout/ — efface les cookies d'authentification. + + Sans authentification ni CSRF : la déconnexion (effacer les cookies) doit + toujours réussir, même avec un token expiré/absent.""" + permission_classes = [AllowAny] + authentication_classes = [] + + @extend_schema( + tags=['Authentification'], + operation_id='auth_logout', + summary="Déconnexion", + description=( + "Efface les cookies d'authentification httpOnly (`access_token`, `refresh_token`). " + "Sans authentification ni CSRF : la déconnexion réussit toujours, même sans token valide." + ), + request=None, + responses={200: OpenApiResponse(description="Déconnecté (`{detail}`).")}, + ) + def post(self, request): + response = Response({'detail': 'Déconnecté.'}, status=status.HTTP_200_OK) + response.delete_cookie(settings.AUTH_COOKIE_ACCESS, path='/') + response.delete_cookie(settings.AUTH_COOKIE_REFRESH, path='/') + return response diff --git a/Mapapi/views/category.py b/Mapapi/views/category.py index ff617b80..a4ef5ffe 100644 --- a/Mapapi/views/category.py +++ b/Mapapi/views/category.py @@ -4,16 +4,51 @@ from rest_framework.response import Response from rest_framework.views import APIView -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import ( + extend_schema, extend_schema_view, OpenApiParameter, OpenApiResponse, +) +from drf_spectacular.types import OpenApiTypes from ..serializer import * from .common import CustomPageNumberPagination -@extend_schema( - description="Endpoint allowing retrieval, updating, and deletion of a category.", - request=CategorySerializer, - responses={200: CategorySerializer, 404: "category not found"}, +@extend_schema_view( + get=extend_schema( + tags=['Catégories & Indicateurs'], + operation_id='categories_retrieve', + summary="Détail d'une catégorie", + description="Retourne une catégorie par son identifiant. Accès public.", + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant de la catégorie")], + request=None, + responses={200: CategorySerializer, 404: OpenApiResponse(description="Catégorie introuvable")}, + ), + put=extend_schema( + tags=['Catégories & Indicateurs'], + operation_id='categories_update', + summary="Mettre à jour une catégorie", + description="Met à jour une catégorie existante. Accès public.", + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant de la catégorie")], + request=CategorySerializer, + responses={ + 200: CategorySerializer, + 400: OpenApiResponse(description="Données invalides"), + 404: OpenApiResponse(description="Catégorie introuvable"), + }, + ), + delete=extend_schema( + tags=['Catégories & Indicateurs'], + operation_id='categories_destroy', + summary="Supprimer une catégorie", + description="Supprime une catégorie. Refusé (400) si des incidents y sont rattachés. Accès public.", + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant de la catégorie")], + request=None, + responses={ + 204: OpenApiResponse(description="Catégorie supprimée"), + 400: OpenApiResponse(description="Catégorie liée à des incidents (suppression refusée)"), + 404: OpenApiResponse(description="Catégorie introuvable"), + }, + ), ) class CategoryAPIView(APIView): permission_classes = () @@ -55,10 +90,25 @@ def delete(self, request, id, format=None): except Category.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) -@extend_schema( - description="Endpoint allowing retrieval and creating of category.", - request=CategorySerializer, - responses={201: CategorySerializer, 400: "serializer error"}, +@extend_schema_view( + get=extend_schema( + tags=['Catégories & Indicateurs'], + operation_id='categories_list', + summary="Lister les catégories", + description="Retourne la liste paginée des catégories. Accès public.", + responses={ + 200: CategorySerializer(many=True), + 500: OpenApiResponse(description="Erreur serveur"), + }, + ), + post=extend_schema( + tags=['Catégories & Indicateurs'], + operation_id='categories_create', + summary="Créer une catégorie", + description="Crée une nouvelle catégorie. Accès public.", + request=CategorySerializer, + responses={201: CategorySerializer, 400: OpenApiResponse(description="Données invalides")}, + ), ) class CategoryAPIListView(generics.ListCreateAPIView): permission_classes = () diff --git a/Mapapi/views/collaboration.py b/Mapapi/views/collaboration.py index 4299c359..73c2d93b 100644 --- a/Mapapi/views/collaboration.py +++ b/Mapapi/views/collaboration.py @@ -2,26 +2,122 @@ from django.db.models import Q, Count from django.utils import timezone -from rest_framework import status, generics -from rest_framework.permissions import IsAuthenticated +from rest_framework import status, generics, serializers +from rest_framework.permissions import IsAuthenticated, SAFE_METHODS from rest_framework.response import Response from rest_framework.views import APIView -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import ( + extend_schema, extend_schema_view, OpenApiParameter, OpenApiResponse, + OpenApiExample, inline_serializer, +) +from drf_spectacular.types import OpenApiTypes from ..models import ( Collaboration, Incident, Notification, COLLAB_ROLE_LEADER, COLLAB_ROLE_CONTRIBUTOR, COLLAB_ROLE_OBSERVER, + COLLAB_STATUS_PENDING, COLLAB_STATUS_ACCEPTED, ) from ..serializer import CollaborationSerializer, CollaborationEnrichedSerializer +from ..permissions import IsOrgAdmin, IsOrgOperative +from ..roles import is_super_admin, is_org_admin, is_bureau_agent from ..Send_mails import send_email from .common import CustomPageNumberPagination -@extend_schema( - description="Vue dashboard des collaborations enrichies avec filtrage par statut, " - "période et recherche textuelle.", - responses={200: CollaborationEnrichedSerializer(many=True)}, +def collaboration_scope_q(user, scope): + """Q de portée pour les listes de collaboration, calquée sur les onglets de l'UI. + + - ``self`` : MES propres collaborations — celles que J'AI faites, ou dont je + suis le leader (``user=moi``). C'est l'onglet « Mes collaborations » : une seule + carte par incident, avec MON rôle. Corrige le doublon où le leader d'un incident + voyait AUSSI la collaboration (contributor/observer) d'une autre organisation et + apparaissait à tort comme « contributeur ». + - ``received`` : demandes REÇUES sur les incidents que JE dirige, faites par + d'AUTRES (``incident.taken_by=moi`` et ``user≠moi``). C'est l'onglet « Demandes ». + - ``all`` (défaut) : union des deux — comportement historique, rétro-compatible. + + Le Super Admin supervise la plateforme : il voit TOUTES les collaborations, + quel que soit le ``scope`` demandé (les filtres status/role/incident_id + s'appliquent toujours par-dessus dans la vue). + """ + if is_super_admin(user): + return Q() + scope = (scope or 'all').lower() + if scope == 'self': + return Q(user=user) + if scope == 'received': + return Q(incident__taken_by=user) & ~Q(user=user) + return Q(user=user) | Q(incident__taken_by=user) + + +def collaboration_read_visibility_q(user): + """Collaborations qu'un utilisateur a le droit de CONSULTER (détail en lecture). + + - Super Admin : toutes (supervision plateforme). + - Admin d'org / agent de bureau : celles de SON organisation — créées par un + membre de son org (``user`` ∈ org) OU portant sur un incident dont le leader + est de son org (``incident.taken_by`` ∈ org) — en plus des siennes. C'est ce + qui permet à un agent de bureau d'ouvrir le détail d'une collaboration de son + org (ex. un incident interne pris en charge par son organisation) sans 404. + - Autres rôles : uniquement les siennes (créées) + reçues (leader de l'incident). + + NB : réservé à la LECTURE. L'écriture (PATCH/DELETE) reste limitée au + propriétaire / leader / super admin (cf. ``CollaborationDetailView``). + """ + if is_super_admin(user): + return Q() + visible = Q(user=user) | Q(incident__taken_by=user) + org_id = getattr(user, 'organisation_member_id', None) + if org_id and (is_org_admin(user) or is_bureau_agent(user)): + visible |= ( + Q(user__organisation_member_id=org_id) + | Q(incident__taken_by__organisation_member_id=org_id) + ) + return visible + + +@extend_schema_view( + get=extend_schema( + tags=['Prise en charge & Collaboration'], + operation_id='collaborations_dashboard', + summary="Dashboard des collaborations", + description=( + "Liste enrichie des collaborations de l'utilisateur authentifié " + "(celles qu'il a demandées ou qu'il reçoit en tant que leader " + "d'incident). Filtrable par statut applicatif, période et recherche." + ), + parameters=[ + OpenApiParameter( + 'scope', OpenApiTypes.STR, OpenApiParameter.QUERY, + description=( + "Portée (onglets UI). 'self' = mes propres collaborations " + "(« Mes collaborations », 1 carte/incident avec MON rôle) ; " + "'received' = demandes reçues sur mes incidents (« Demandes ») ; " + "'all' (défaut) = les deux." + ), + enum=['self', 'received', 'all'], + ), + OpenApiParameter( + 'status', OpenApiTypes.STR, OpenApiParameter.QUERY, + description="Filtre par statut applicatif (défaut 'all').", + enum=['all', 'in-progress', 'completed', 'pending', 'accepted', 'declined'], + ), + OpenApiParameter( + 'date_from', OpenApiTypes.DATE, OpenApiParameter.QUERY, + description="Borne basse : end_date >= date_from (ou end_date nulle).", + ), + OpenApiParameter( + 'date_to', OpenApiTypes.DATE, OpenApiParameter.QUERY, + description="Borne haute : created_at <= date_to.", + ), + OpenApiParameter( + 'search', OpenApiTypes.STR, OpenApiParameter.QUERY, + description="Recherche texte (titre incident, organisation, rôle, zone).", + ), + ], + responses={200: CollaborationEnrichedSerializer(many=True)}, + ), ) class CollaborationDashboardView(generics.ListAPIView): """GET /collaborations/dashboard/ @@ -34,17 +130,20 @@ class CollaborationDashboardView(generics.ListAPIView): """ permission_classes = [IsAuthenticated] serializer_class = CollaborationEnrichedSerializer + # Paginé comme /collaboration/ : réponse {count, next, previous, results} + # (cohérence des formats côté frontend). + pagination_class = CustomPageNumberPagination def get_queryset(self): user = self.request.user - if user.is_superuser: - qs = Collaboration.objects.all() - else: - qs = Collaboration.objects.filter( - Q(user=user) | Q(incident__taken_by=user) - ) - qs = qs.select_related( - 'incident', 'user', 'user__organisation_member' + # ?scope=self | received | all (défaut). « Mes collaborations » -> self + # (1 carte/incident avec MON rôle, plus de doublon côté leader) ; + # « Demandes » -> received. cf. collaboration_scope_q. + qs = Collaboration.objects.filter( + collaboration_scope_q(user, self.request.query_params.get('scope')) + ).select_related( + 'incident', 'user', 'user__organisation_member', + 'incident__taken_by', 'incident__taken_by__organisation_member', ).order_by('-created_at') # --- Filtre par statut --- @@ -87,6 +186,64 @@ def get_queryset(self): return qs +@extend_schema_view( + get=extend_schema( + tags=['Prise en charge & Collaboration'], + operation_id='collaborations_list', + summary="Lister mes collaborations", + description=( + "Liste paginée des collaborations de l'utilisateur authentifié " + "(celles qu'il a demandées ou qu'il reçoit en tant que leader). " + "Filtrable par statut, rôle et incident." + ), + parameters=[ + OpenApiParameter( + 'status', OpenApiTypes.STR, OpenApiParameter.QUERY, + description="Filtre par statut.", enum=['pending', 'accepted', 'declined'], + ), + OpenApiParameter( + 'role', OpenApiTypes.STR, OpenApiParameter.QUERY, + description="Filtre par rôle.", enum=['leader', 'contributor', 'observer'], + ), + OpenApiParameter( + 'incident_id', OpenApiTypes.UUID, OpenApiParameter.QUERY, + description="Filtre par incident (UUID).", + ), + ], + responses={200: CollaborationSerializer(many=True)}, + ), + post=extend_schema( + tags=['Prise en charge & Collaboration'], + operation_id='collaborations_create', + summary="Demander une collaboration", + description=( + "Demande à rejoindre un incident. Réservé aux admins d'organisation " + "(IsOrgAdmin). Le rôle 'leader' est refusé (il est attribué " + "automatiquement lors de la prise en charge). Le statut initial " + "(pending/accepted) est calculé selon le mode de prise en charge de " + "l'incident et le rôle demandé." + ), + request=CollaborationSerializer, + responses={ + 201: CollaborationSerializer, + 400: OpenApiResponse( + description="Données invalides, rôle 'leader' demandé, ou " + "collaboration déjà existante sur cet incident.", + ), + 403: OpenApiResponse(description="Réservé aux admins d'organisation."), + }, + examples=[OpenApiExample( + 'Demande contributor', + value={ + 'incident': '3fa85f64-5717-4562-b3fc-2c963f66afa6', + 'role': 'contributor', + 'motivation': 'Notre organisation peut intervenir sur cette zone.', + 'end_date': '2026-12-31', + }, + request_only=True, + )], + ), +) class CollaborationView(generics.CreateAPIView, generics.ListAPIView): """ GET /collaboration/ — liste paginée des collaborations de l'utilisateur @@ -97,16 +254,25 @@ class CollaborationView(generics.CreateAPIView, generics.ListAPIView): serializer_class = CollaborationSerializer pagination_class = CustomPageNumberPagination + def get_permissions(self): + # « Demander une collaboration » (contributor/observer) = membres opérationnels + # d'une organisation : Admin d'organisation OU agent de bureau. Le rôle 'leader' + # reste refusé au niveau du serializer (prise en charge auto uniquement), donc un + # agent de bureau ne peut jamais prendre l'incident en charge via cet endpoint. + # La lecture (GET) reste ouverte à tout utilisateur authentifié. + if self.request.method == 'POST': + return [IsAuthenticated(), IsOrgOperative()] + return [IsAuthenticated()] + def get_queryset(self): user = self.request.user - if user.is_superuser: - qs = Collaboration.objects.all() - else: - qs = Collaboration.objects.filter( - Q(user=user) | Q(incident__taken_by=user) - ) - qs = qs.select_related( - 'incident', 'user', 'user__organisation_member' + # ?scope=self | received | all (défaut). « Mes collaborations » -> self ; + # « Demandes » -> received. cf. collaboration_scope_q. + qs = Collaboration.objects.filter( + collaboration_scope_q(user, self.request.query_params.get('scope')) + ).select_related( + 'incident', 'user', 'user__organisation_member', + 'incident__taken_by', 'incident__taken_by__organisation_member', ).order_by('-id') # --- Filtres optionnels --- @@ -132,12 +298,18 @@ def post(self, request, *args, **kwargs): incident = serializer.validated_data.get('incident') role = serializer.validated_data.get('role') - # Empêcher doublon - if Collaboration.objects.filter(incident=incident, user=request.user).exists(): + # Empêcher doublon — uniquement si une collaboration ACTIVE existe déjà + # (pending/accepted). Une demande précédemment REFUSÉE (declined/refused) ou + # TERMINÉE peut être relancée : on supprime l'ancienne ligne (contrainte unique + # incident+user) pour en recréer une et re-notifier le leader. + existing = Collaboration.objects.filter(incident=incident, user=request.user).first() + if existing and existing.status in (COLLAB_STATUS_PENDING, COLLAB_STATUS_ACCEPTED): return Response( - {"error": "Vous avez déjà une collaboration sur cet incident."}, + {"error": "Vous avez déjà une collaboration active sur cet incident."}, status=status.HTTP_400_BAD_REQUEST, ) + if existing: + existing.delete() # --- Détermination du statut initial --- # Observer : toujours auto-accepté (toute org a le droit d'observer) @@ -166,12 +338,74 @@ def post(self, request, *args, **kwargs): ) -@extend_schema( - description=( - "Détail d'une collaboration. Accessible au collaborateur lui-même, " - "au leader de l'incident, ou à un super admin." +_COLLAB_PK_PARAM = OpenApiParameter( + 'pk', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="ID de la collaboration (UUID).", +) + + +@extend_schema_view( + get=extend_schema( + tags=['Prise en charge & Collaboration'], + operation_id='collaborations_retrieve', + summary="Détail d'une collaboration", + description=( + "Détail d'une collaboration. Accessible au collaborateur lui-même, " + "au leader de l'incident, ou à un super admin." + ), + parameters=[_COLLAB_PK_PARAM], + responses={ + 200: CollaborationSerializer, + 404: OpenApiResponse(description="Collaboration introuvable."), + }, + ), + put=extend_schema( + tags=['Prise en charge & Collaboration'], + operation_id='collaborations_update', + summary="Remplacer une collaboration", + description=( + "Mise à jour complète d'une collaboration (collaborateur, leader de " + "l'incident, ou super admin). Le statut reste piloté via les " + "endpoints accept/decline." + ), + parameters=[_COLLAB_PK_PARAM], + request=CollaborationSerializer, + responses={ + 200: CollaborationSerializer, + 400: OpenApiResponse(description="Données invalides."), + 404: OpenApiResponse(description="Collaboration introuvable."), + }, + ), + patch=extend_schema( + tags=['Prise en charge & Collaboration'], + operation_id='collaborations_partial_update', + summary="Modifier une collaboration", + description=( + "Mise à jour partielle d'une collaboration (collaborateur, leader de " + "l'incident, ou super admin)." + ), + parameters=[_COLLAB_PK_PARAM], + request=CollaborationSerializer, + responses={ + 200: CollaborationSerializer, + 400: OpenApiResponse(description="Données invalides."), + 404: OpenApiResponse(description="Collaboration introuvable."), + }, + ), + delete=extend_schema( + tags=['Prise en charge & Collaboration'], + operation_id='collaborations_destroy', + summary="Supprimer une collaboration", + description=( + "Supprime une collaboration (collaborateur, leader de l'incident, " + "ou super admin)." + ), + parameters=[_COLLAB_PK_PARAM], + responses={ + 204: OpenApiResponse(description="Collaboration supprimée."), + 404: OpenApiResponse(description="Collaboration introuvable."), + }, ), - responses={200: CollaborationSerializer, 404: "Collaboration not found"}, ) class CollaborationDetailView(generics.RetrieveUpdateDestroyAPIView): """GET/PATCH/DELETE /collaboration//.""" @@ -181,6 +415,12 @@ class CollaborationDetailView(generics.RetrieveUpdateDestroyAPIView): def get_queryset(self): user = self.request.user + # LECTURE (GET) : un membre d'org (admin/agent de bureau) peut consulter le + # détail d'une collaboration de SON organisation — pas seulement les siennes. + if self.request.method in SAFE_METHODS: + return Collaboration.objects.filter(collaboration_read_visibility_q(user)) + # ÉCRITURE (PATCH/DELETE) : réservée au propriétaire, au leader de l'incident, + # ou au super admin — un agent de bureau ne modifie pas la collab d'un autre. if user.is_staff or user.is_superuser: return Collaboration.objects.all() return Collaboration.objects.filter( @@ -189,8 +429,62 @@ def get_queryset(self): class BulkCollaborationRequestView(APIView): - permission_classes = [IsAuthenticated] - + # Spec §6 : « Demander une collaboration » = Admin d'organisation uniquement. + permission_classes = [IsAuthenticated, IsOrgAdmin] + + @extend_schema( + tags=['Prise en charge & Collaboration'], + operation_id='collaborations_bulk_request', + summary="Demandes de collaboration en lot", + description=( + "Crée plusieurs demandes de collaboration en une seule requête. " + "Réservé aux admins d'organisation. Chaque élément est validé " + "indépendamment ; renvoie 201 si toutes réussissent, 207 si " + "certaines échouent (avec le détail par élément)." + ), + request=inline_serializer( + name='BulkCollaborationRequest', + fields={ + 'requests': serializers.ListField( + child=inline_serializer( + name='BulkCollaborationRequestItem', + fields={ + 'incident_id': serializers.UUIDField(), + 'role': serializers.ChoiceField( + choices=['contributor', 'observer'], required=False, + ), + 'motivation': serializers.CharField(required=False), + 'end_date': serializers.DateField(required=False), + }, + ), + ), + }, + ), + responses={ + 201: OpenApiResponse( + description="Toutes les demandes créées — {created, errors, message}.", + ), + 207: OpenApiResponse( + description="Statut mixte, certaines demandes ont échoué — " + "{created, errors, message}.", + ), + 400: OpenApiResponse(description="'requests' doit être une liste non vide."), + 403: OpenApiResponse(description="Réservé aux admins d'organisation."), + }, + examples=[OpenApiExample( + 'Lot de 2 demandes', + value={'requests': [ + { + 'incident_id': '3fa85f64-5717-4562-b3fc-2c963f66afa6', + 'role': 'contributor', + 'motivation': 'Appui terrain disponible.', + 'end_date': '2026-12-31', + }, + {'incident_id': '5fa85f64-5717-4562-b3fc-2c963f66afa6', 'role': 'observer'}, + ]}, + request_only=True, + )], + ) def post(self, request): requests_data = request.data.get('requests', []) if not isinstance(requests_data, list) or not requests_data: @@ -233,8 +527,41 @@ def post(self, request): class HandleCollaborationRequestView(APIView): """POST /collaboration/// (accept|reject)""" - permission_classes = [IsAuthenticated] - + # Spec §6 : accepter/refuser une collaboration (côté leader) = Admin d'organisation. + permission_classes = [IsAuthenticated, IsOrgAdmin] + + @extend_schema( + tags=['Prise en charge & Collaboration'], + operation_id='collaborations_handle_request', + summary="Accepter/rejeter une demande (leader)", + description=( + "Le leader de l'incident accepte ou rejette une demande de " + "collaboration. Réservé aux admins d'organisation et au leader de " + "l'incident. Accepter une collab fait basculer un incident en mode " + "'internal' vers 'collaborative'." + ), + parameters=[ + OpenApiParameter( + 'collaboration_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="ID de la collaboration (UUID).", + ), + OpenApiParameter( + 'action', OpenApiTypes.STR, OpenApiParameter.PATH, + description="Action à effectuer.", enum=['accept', 'reject'], + ), + ], + request=None, + responses={ + 200: OpenApiResponse( + description="{status: 'Collaboration accepted' | 'Collaboration rejected'}.", + ), + 400: OpenApiResponse(description="Action invalide (différente de accept/reject)."), + 403: OpenApiResponse( + description="Seul le leader de l'incident peut gérer la demande.", + ), + 404: OpenApiResponse(description="Collaboration introuvable."), + }, + ) def post(self, request, collaboration_id, action, format=None): try: collaboration = Collaboration.objects.get(id=collaboration_id) @@ -244,9 +571,8 @@ def post(self, request, collaboration_id, action, format=None): if action not in ["accept", "reject"]: return Response({"error": "Invalid action"}, status=status.HTTP_400_BAD_REQUEST) - # Seul le leader de l'incident (ou un super admin) peut accepter/rejeter + # Seul le leader de l'incident peut accepter/rejeter is_leader = ( - request.user.is_superuser or collaboration.incident.taken_by == request.user or Collaboration.objects.filter( incident=collaboration.incident, @@ -270,16 +596,6 @@ def post(self, request, collaboration_id, action, format=None): if incident.take_in_charge_mode == 'internal': incident.take_in_charge_mode = 'collaborative' incident.save(update_fields=['take_in_charge_mode']) - if incident.taken_by: - collab_leader, created = Collaboration.objects.get_or_create( - incident=incident, - user=incident.taken_by, - defaults={'role': COLLAB_ROLE_LEADER, 'status': 'accepted'} - ) - if not created: - collab_leader.role = COLLAB_ROLE_LEADER - collab_leader.status = 'accepted' - collab_leader.save(update_fields=['role', 'status']) return Response({"status": "Collaboration accepted"}, status=status.HTTP_200_OK) elif action == "reject": collaboration.status = 'declined' @@ -288,16 +604,36 @@ def post(self, request, collaboration_id, action, format=None): class DeclineCollaborationView(APIView): - permission_classes = [IsAuthenticated] - + # Spec §6 : décliner une collaboration (côté leader) = Admin d'organisation. + permission_classes = [IsAuthenticated, IsOrgAdmin] + + @extend_schema( + tags=['Prise en charge & Collaboration'], + operation_id='collaborations_decline', + summary="Décliner une collaboration (leader)", + description=( + "Le leader de l'incident décline une demande de collaboration " + "identifiée par {collaboration_id} dans le corps. Réservé aux admins " + "d'organisation et au leader. Envoie un email au demandeur." + ), + request=inline_serializer( + name='DeclineCollaborationRequest', + fields={'collaboration_id': serializers.UUIDField()}, + ), + responses={ + 200: OpenApiResponse(description="{message} — collaboration déclinée."), + 403: OpenApiResponse(description="Seul le leader de l'incident peut décliner."), + 404: OpenApiResponse(description="Collaboration introuvable."), + 500: OpenApiResponse(description="Erreur serveur inattendue."), + }, + ) def post(self, request, *args, **kwargs): try: collaboration_id = request.data.get('collaboration_id') collaboration = Collaboration.objects.get(id=collaboration_id) - # Seul le leader de l'incident (ou un super admin) peut décliner + # Seul le leader de l'incident peut décliner is_leader = ( - request.user.is_superuser or collaboration.incident.taken_by == request.user or Collaboration.objects.filter( incident=collaboration.incident, @@ -327,11 +663,13 @@ def post(self, request, *args, **kwargs): to_email=requesting_user.email, ) - notification_message = f'Votre demande de collaboration sur l\'incident {collaboration.incident.id} a été déclinée.' + notification_message = f"Votre demande de collaboration sur l'incident « {collaboration.incident.title} » a été déclinée." notification = Notification.objects.create( user=requesting_user, + notif_type='collaboration_declined', message=notification_message, - colaboration=collaboration + colaboration=collaboration, + incident=collaboration.incident, ) notification.delete() @@ -344,8 +682,33 @@ def post(self, request, *args, **kwargs): class AcceptCollaborationView(APIView): - permission_classes = [IsAuthenticated] - + # Spec §6 : accepter une collaboration (côté leader) = Admin d'organisation. + permission_classes = [IsAuthenticated, IsOrgAdmin] + + @extend_schema( + tags=['Prise en charge & Collaboration'], + operation_id='collaborations_accept', + summary="Accepter une collaboration (leader)", + description=( + "Le leader de l'incident accepte une demande identifiée par " + "{collaboration_id} dans le corps. Réservé aux admins d'organisation " + "et au leader. Bascule éventuelle de l'incident 'internal' vers " + "'collaborative'. Monté sur deux routes : /accept-collaboration/ et " + "/collaborations/accept/." + ), + request=inline_serializer( + name='AcceptCollaborationRequest', + fields={'collaboration_id': serializers.UUIDField()}, + ), + responses={ + 200: OpenApiResponse(description="{message} — collaboration acceptée."), + 400: OpenApiResponse( + description="collaboration_id manquant, déjà acceptée, ou expirée.", + ), + 403: OpenApiResponse(description="Seul le leader de l'incident peut accepter."), + 404: OpenApiResponse(description="Collaboration introuvable."), + }, + ) def post(self, request, *args, **kwargs): try: collaboration_id = request.data.get('collaboration_id') @@ -357,9 +720,8 @@ def post(self, request, *args, **kwargs): collaboration = Collaboration.objects.get(id=collaboration_id) - # Seul le leader de l'incident (ou un super admin) peut accepter + # Seul le leader de l'incident peut accepter is_leader = ( - request.user.is_superuser or collaboration.incident.taken_by == request.user or Collaboration.objects.filter( incident=collaboration.incident, @@ -396,16 +758,6 @@ def post(self, request, *args, **kwargs): if incident.take_in_charge_mode == 'internal': incident.take_in_charge_mode = 'collaborative' incident.save(update_fields=['take_in_charge_mode']) - if incident.taken_by: - collab_leader, created = Collaboration.objects.get_or_create( - incident=incident, - user=incident.taken_by, - defaults={'role': COLLAB_ROLE_LEADER, 'status': 'accepted'} - ) - if not created: - collab_leader.role = COLLAB_ROLE_LEADER - collab_leader.status = 'accepted' - collab_leader.save(update_fields=['role', 'status']) return Response( {"message": "Collaboration acceptée avec succès"}, diff --git a/Mapapi/views/common.py b/Mapapi/views/common.py index de18598e..254e1359 100644 --- a/Mapapi/views/common.py +++ b/Mapapi/views/common.py @@ -22,6 +22,27 @@ class CustomPageNumberPagination(PageNumberPagination): max_page_size = 1000 +class IncidentPagination(CustomPageNumberPagination): + # Défaut adapté à l'onglet incidents (tableau + miniatures). Surchargeable via + # ?page_size= (ex. ?page_size=12 pour une grille de cartes), plafonné à 100. + page_size = 20 + max_page_size = 100 + + +class NotificationPagination(CustomPageNumberPagination): + # Défaut adapté au panneau de notifications. Surchargeable via ?page_size=. + page_size = 20 + max_page_size = 100 + + +class FieldReportPagination(CustomPageNumberPagination): + # Petits lots pour un bouton « charger plus » sur les rapports d'agents. + # Le front charge la page suivante via `next` jusqu'à ce qu'il soit null. + # Surchargeable via ?page_size= (plafonné à 100). + page_size = 10 + max_page_size = 100 + + def get_random(length=7): """Generate a random alphanumeric code (default length=7, used for password reset). diff --git a/Mapapi/views/communaute.py b/Mapapi/views/communaute.py index b94bcef7..57626b1e 100644 --- a/Mapapi/views/communaute.py +++ b/Mapapi/views/communaute.py @@ -2,16 +2,48 @@ from rest_framework import status, generics from rest_framework.response import Response -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiResponse +from drf_spectacular.types import OpenApiTypes from ..serializer import * from .common import CustomPageNumberPagination -@extend_schema( - description="Endpoint allowing retrieval, updating, and deletion of a community.", - request=CommunauteSerializer, - responses={200: CommunauteSerializer, 404: "Not Found"}, +@extend_schema_view( + get=extend_schema( + tags=['Messages & Communauté'], + operation_id='communities_retrieve', + summary="Récupérer une communauté", + description="Renvoie une communauté par son identifiant. Endpoint public (aucune authentification requise).", + request=None, + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant de la communauté.")], + responses={200: CommunauteSerializer, 404: OpenApiResponse(description="Communauté introuvable (corps vide).")}, + ), + put=extend_schema( + tags=['Messages & Communauté'], + operation_id='communities_update', + summary="Mettre à jour une communauté", + description="Remplace l'intégralité d'une communauté existante. Endpoint public.", + request=CommunauteSerializer, + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant de la communauté.")], + responses={ + 200: CommunauteSerializer, + 400: OpenApiResponse(description="Erreurs de validation du sérialiseur."), + 404: OpenApiResponse(description="Communauté introuvable (corps vide)."), + }, + ), + delete=extend_schema( + tags=['Messages & Communauté'], + operation_id='communities_destroy', + summary="Supprimer une communauté", + description="Supprime définitivement une communauté. Endpoint public.", + request=None, + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant de la communauté.")], + responses={ + 204: OpenApiResponse(description="Communauté supprimée."), + 404: OpenApiResponse(description="Communauté introuvable (corps vide)."), + }, + ), ) class CommunauteAPIView(generics.CreateAPIView): permission_classes = () @@ -45,10 +77,26 @@ def delete(self, request, id, format=None): item.delete() return Response(status=204) -@extend_schema( - description="Endpoint allowing retrieval and creating of a community.", - request=CommunauteSerializer, - responses={201: CommunauteSerializer, 400: "Serializer error"}, +@extend_schema_view( + get=extend_schema( + tags=['Messages & Communauté'], + operation_id='communities_list', + summary="Lister les communautés", + description="Renvoie la liste paginée des communautés. Endpoint public.", + request=None, + responses={200: CommunauteSerializer(many=True)}, + ), + post=extend_schema( + tags=['Messages & Communauté'], + operation_id='communities_create', + summary="Créer une communauté", + description="Crée une nouvelle communauté. Endpoint public.", + request=CommunauteSerializer, + responses={ + 201: CommunauteSerializer, + 400: OpenApiResponse(description="Erreurs de validation du sérialiseur."), + }, + ), ) class CommunauteAPIListView(generics.CreateAPIView): permission_classes = () diff --git a/Mapapi/views/contact.py b/Mapapi/views/contact.py index 2114268a..2d740c3a 100644 --- a/Mapapi/views/contact.py +++ b/Mapapi/views/contact.py @@ -7,16 +7,48 @@ from rest_framework import status, generics from rest_framework.response import Response -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiResponse +from drf_spectacular.types import OpenApiTypes from ..serializer import * from .common import CustomPageNumberPagination -@extend_schema( - description="Endpoint allowing retrieval, updating, and deletion of a contact.", - request=ContactSerializer, - responses={200: ContactSerializer, 404: "Not Found"}, +@extend_schema_view( + get=extend_schema( + tags=['Contacts & Élus'], + operation_id='contacts_retrieve', + summary="Récupérer un contact", + description="Renvoie un message de contact par son identifiant. Endpoint public (aucune authentification requise).", + request=None, + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant du contact.")], + responses={200: ContactSerializer, 404: OpenApiResponse(description="Contact introuvable (corps vide).")}, + ), + put=extend_schema( + tags=['Contacts & Élus'], + operation_id='contacts_update', + summary="Mettre à jour un contact", + description="Remplace l'intégralité d'un message de contact existant. Endpoint public.", + request=ContactSerializer, + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant du contact.")], + responses={ + 200: ContactSerializer, + 400: OpenApiResponse(description="Erreurs de validation du sérialiseur."), + 404: OpenApiResponse(description="Contact introuvable (corps vide)."), + }, + ), + delete=extend_schema( + tags=['Contacts & Élus'], + operation_id='contacts_destroy', + summary="Supprimer un contact", + description="Supprime définitivement un message de contact. Endpoint public.", + request=None, + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant du contact.")], + responses={ + 204: OpenApiResponse(description="Contact supprimé."), + 404: OpenApiResponse(description="Contact introuvable (corps vide)."), + }, + ), ) class ContactAPIView(generics.CreateAPIView): permission_classes = () @@ -50,10 +82,26 @@ def delete(self, request, id, format=None): item.delete() return Response(status=204) -@extend_schema( - description="Endpoint allowing retrieval and creating of a contact.", - request=ContactSerializer, - responses={201: ContactSerializer, 400: "Serializer error"}, +@extend_schema_view( + get=extend_schema( + tags=['Contacts & Élus'], + operation_id='contacts_list', + summary="Lister les contacts", + description="Renvoie la liste paginée des messages de contact. Endpoint public.", + request=None, + responses={200: ContactSerializer(many=True)}, + ), + post=extend_schema( + tags=['Contacts & Élus'], + operation_id='contacts_create', + summary="Créer un contact", + description="Crée un message de contact puis envoie un e-mail de notification aux administrateurs. Endpoint public.", + request=ContactSerializer, + responses={ + 201: ContactSerializer, + 400: OpenApiResponse(description="Erreurs de validation du sérialiseur."), + }, + ), ) class ContactAPIListView(generics.CreateAPIView): permission_classes = () diff --git a/Mapapi/views/elu.py b/Mapapi/views/elu.py index fe1b68ec..3540c7aa 100644 --- a/Mapapi/views/elu.py +++ b/Mapapi/views/elu.py @@ -8,16 +8,43 @@ from rest_framework.pagination import PageNumberPagination from rest_framework.response import Response -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import ( + extend_schema, + extend_schema_view, + OpenApiParameter, + OpenApiResponse, + inline_serializer, +) +from drf_spectacular.types import OpenApiTypes +from rest_framework import serializers from ..serializer import * from ..Send_mails import send_email from .common import CustomPageNumberPagination -@extend_schema( - description="Endpoint allowing retrieval and creating of an elu.", - responses={201: UserEluSerializer, 400: "Serializer error"}, +@extend_schema_view( + get=extend_schema( + tags=['Contacts & Élus'], + operation_id='elus_list', + summary="Lister les élus", + description="Renvoie la liste paginée des utilisateurs de type « élu ». Endpoint public.", + request=None, + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Présent dans l'URL mais non utilisé par la vue.")], + responses={200: UserSerializer(many=True)}, + ), + post=extend_schema( + tags=['Contacts & Élus'], + operation_id='elus_create', + summary="Créer un élu", + description="Crée un utilisateur « élu », l'associe éventuellement à des zones (champ `zones`), génère un mot de passe aléatoire et envoie les identifiants par e-mail. Endpoint public.", + request=UserEluSerializer, + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Présent dans l'URL mais non utilisé par la vue.")], + responses={ + 201: UserEluSerializer, + 400: OpenApiResponse(description="Erreurs de validation du sérialiseur."), + }, + ), ) class EluAPIListView(generics.CreateAPIView): permission_classes = ( @@ -61,10 +88,33 @@ def post(self, request, format=None): return Response(UserEluSerializer(user).data, status=201) return Response(serializer.errors, status=400) -@extend_schema( - description="Endpoint allowing creating of an elu by zone.", - request=UserEluSerializer, - responses={201: UserEluSerializer, 400: "serializer error"}, +@extend_schema_view( + get=extend_schema( + tags=['Contacts & Élus'], + operation_id='elus_zones_list', + summary="Lister les attributions élu-zone", + description="Renvoie la liste des attributions élu↔zone. Endpoint public.", + request=None, + responses={ + 200: EluToZoneSerializer(many=True), + 500: OpenApiResponse(description="Erreur serveur : {\"error\": \"...\"}."), + }, + ), + post=extend_schema( + tags=['Contacts & Élus'], + operation_id='elus_assign_to_zone', + summary="Attribuer une zone à un élu", + description="Associe une zone à un élu à partir des identifiants `elu` et `zone` fournis dans le corps. Endpoint public.", + request=EluToZoneSerializer, + responses={ + 200: inline_serializer( + name='EluZoneAssignResponse', + fields={'status': serializers.CharField(), 'message': serializers.CharField()}, + ), + 400: OpenApiResponse(description="Erreurs de validation du sérialiseur."), + 500: OpenApiResponse(description="Erreur serveur : {\"error\": \"...\"}."), + }, + ), ) class EluToZoneAPIListView(generics.ListCreateAPIView): permission_classes = () diff --git a/Mapapi/views/evenement.py b/Mapapi/views/evenement.py index be2858d0..8f799277 100644 --- a/Mapapi/views/evenement.py +++ b/Mapapi/views/evenement.py @@ -2,7 +2,8 @@ from rest_framework import status, generics from rest_framework.response import Response -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiResponse +from drf_spectacular.types import OpenApiTypes from ..serializer import * from .common import CustomPageNumberPagination @@ -12,6 +13,63 @@ from ..serializer import EvenementSerializer +@extend_schema_view( + get=extend_schema( + tags=['Événements & Participation'], + operation_id='events_retrieve', + summary="Détails d'un événement", + description="Retourne un événement par son id. Endpoint public.", + parameters=[ + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="id de l'événement"), + ], + responses={200: EvenementSerializer, 404: OpenApiResponse(description="Événement introuvable")}, + ), + put=extend_schema( + tags=['Événements & Participation'], + operation_id='events_update', + summary="Mettre à jour un événement", + description="Met à jour entièrement un événement existant. `multipart/form-data` " + "pour `photo`/`video`/`audio`. Endpoint public.", + parameters=[ + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="id de l'événement"), + ], + request=EvenementSerializer, + responses={ + 200: EvenementSerializer, + 400: OpenApiResponse(description="Erreurs de validation"), + 404: OpenApiResponse(description="Événement introuvable"), + }, + ), + delete=extend_schema( + tags=['Événements & Participation'], + operation_id='events_destroy', + summary="Supprimer un événement", + description="Supprime un événement par son id. Endpoint public.", + parameters=[ + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="id de l'événement"), + ], + responses={ + 204: OpenApiResponse(description="Événement supprimé"), + 404: OpenApiResponse(description="Événement introuvable"), + }, + ), + post=extend_schema( + tags=['Événements & Participation'], + operation_id='events_create_at_id', + summary="Créer un événement (route /Event/)", + description="Hérité de `CreateAPIView` : crée un nouvel événement (l'id du chemin " + "est ignoré). Préférer `POST /Event/`. Endpoint public.", + parameters=[ + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="ignoré"), + ], + request=EvenementSerializer, + responses={201: EvenementSerializer, 400: OpenApiResponse(description="Erreurs de validation")}, + ), +) class EvenementAPIView(generics.CreateAPIView): permission_classes = () queryset = Evenement.objects.all() @@ -44,11 +102,30 @@ def delete(self, request, id, format=None): item.delete() return Response(status=204) +@extend_schema_view( + get=extend_schema( + tags=['Événements & Participation'], + operation_id='events_list', + summary="Lister les événements", + description="Retourne la liste paginée des événements (triés par id). Endpoint public.", + responses={200: EvenementSerializer(many=True)}, + ), + post=extend_schema( + tags=['Événements & Participation'], + operation_id='events_create', + summary="Créer un événement", + description="Crée un nouvel événement. `multipart/form-data` pour `photo`/`video`/" + "`audio` ; `user_id` (organisateur) requis. Effet de bord : +2 points " + "pour l'utilisateur. Endpoint public.", + request=EvenementSerializer, + responses={201: EvenementSerializer, 400: OpenApiResponse(description="Erreurs de validation")}, + ), +) class EvenementAPIListView(generics.CreateAPIView): permission_classes = () queryset = Evenement.objects.all() serializer_class = EvenementSerializer - + def get(self, request, format=None): items = Evenement.objects.order_by('pk') paginator = CustomPageNumberPagination() diff --git a/Mapapi/views/image_background.py b/Mapapi/views/image_background.py index 79975a3f..25c62dea 100644 --- a/Mapapi/views/image_background.py +++ b/Mapapi/views/image_background.py @@ -2,12 +2,49 @@ from rest_framework import status, generics from rest_framework.response import Response -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiResponse +from drf_spectacular.types import OpenApiTypes from ..serializer import * from .common import CustomPageNumberPagination +@extend_schema_view( + get=extend_schema( + tags=['Médias'], + operation_id='media_image_retrieve', + summary="Récupérer une image de fond", + description="Renvoie une image de fond par son identifiant. Endpoint public (aucune authentification requise).", + request=None, + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant de l'image de fond.")], + responses={200: ImageBackgroundSerializer, 404: OpenApiResponse(description="Image introuvable (corps vide).")}, + ), + put=extend_schema( + tags=['Médias'], + operation_id='media_image_update', + summary="Mettre à jour une image de fond", + description="Remplace une image de fond existante. Upload en `multipart/form-data` (champ `photo`). Endpoint public.", + request=ImageBackgroundSerializer, + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant de l'image de fond.")], + responses={ + 200: ImageBackgroundSerializer, + 400: OpenApiResponse(description="Erreurs de validation du sérialiseur."), + 404: OpenApiResponse(description="Image introuvable (corps vide)."), + }, + ), + delete=extend_schema( + tags=['Médias'], + operation_id='media_image_destroy', + summary="Supprimer une image de fond", + description="Supprime définitivement une image de fond. Endpoint public.", + request=None, + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant de l'image de fond.")], + responses={ + 204: OpenApiResponse(description="Image supprimée."), + 404: OpenApiResponse(description="Image introuvable (corps vide)."), + }, + ), +) class ImageBackgroundAPIView(generics.CreateAPIView): permission_classes = ( ) @@ -42,6 +79,27 @@ def delete(self, request, id, format=None): return Response(status=204) +@extend_schema_view( + get=extend_schema( + tags=['Médias'], + operation_id='media_image_latest', + summary="Récupérer la dernière image de fond", + description="Renvoie la dernière image de fond enregistrée. Code de statut non idiomatique : la vue répond **201** (et non 200). Endpoint public.", + request=None, + responses={201: ImageBackgroundSerializer}, + ), + post=extend_schema( + tags=['Médias'], + operation_id='media_image_create', + summary="Créer une image de fond", + description="Enregistre une nouvelle image de fond. Upload en `multipart/form-data` (champ `photo`). Endpoint public.", + request=ImageBackgroundSerializer, + responses={ + 201: ImageBackgroundSerializer, + 400: OpenApiResponse(description="Erreurs de validation du sérialiseur."), + }, + ), +) class ImageBackgroundAPIListView(generics.CreateAPIView): permission_classes = ( ) diff --git a/Mapapi/views/impact.py b/Mapapi/views/impact.py new file mode 100644 index 00000000..45e8ea76 --- /dev/null +++ b/Mapapi/views/impact.py @@ -0,0 +1,406 @@ +"""Tableau de bord IMPACT (réservé au Super Admin). + +Bilan consolidé de l'impact de Map Action sur les incidents **résolus** et/ou +**pris en compte avec au moins une action** (une tâche effectuée). Alimenté par les +analyses IA (`Prediction`) : + + - bénéficiaires DIRECTS (personnes exposées) et INDIRECTS (population potentielle), + avec ventilation hommes / femmes / enfants ; + - structures & infrastructures sensibles PROTÉGÉES (écoles, marchés, sources d'eau, + routes/ponts, bâtiments, maternités, centres de santé, crèches) — filtrables par type ; + - temps moyen de résolution et taux de résolution ; + - mobilisation des acteurs (organisations, agents, collaborations) ; + - contribution citoyenne (signalements reçus / vérifiés / ayant conduit à une action) ; + - superficie d'impact cumulée (somme des π·rayon² des zones d'impact, en hectares). + +Filtres : `?status=resolved|taken_action|all` (défaut `all`) et période +`?filter_type=today|yesterday|last_7_days|last_30_days|this_month|last_month|custom_range` +(`custom_range` → `custom_start` & `custom_end`). +""" +import math +from datetime import timedelta + +from django.db.models import Q, Sum, F, Prefetch +from django.utils import timezone + +from rest_framework.views import APIView +from rest_framework import generics, serializers, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from drf_spectacular.utils import extend_schema, extend_schema_field, OpenApiParameter, OpenApiResponse +from drf_spectacular.types import OpenApiTypes + +from ..models import ( + Incident, Prediction, PredictionStatus, IncidentTask, Collaboration, + IncidentAssignment, IncidentOrgAssignment, ORG_ROLE_FIELD, + RESOLVED, RESOLVED_DEFINITIVE, DECLARED, TASK_DONE, COLLAB_STATUS_ACCEPTED, +) +from ..roles import is_super_admin, is_org_admin, is_bureau_agent +from ..services.incident_orgs import acting_organisations +from .common import IncidentPagination + +RESOLVED_ETATS = [RESOLVED, RESOLVED_DEFINITIVE] +# Types d'infrastructures sensibles (clés communes aux colonnes directes et au JSON indirect). +INFRA_TYPES = ['schools', 'health_centers', 'water_points', 'markets', + 'maternities', 'nurseries', 'main_roads_bridges', 'residential_buildings'] +_COMPLETED = [PredictionStatus.COMPLETED, PredictionStatus.COMPLETED_WITH_WARNING] + + +def _apply_period(qs, field, filter_type, custom_start, custom_end): + """Restreint un queryset sur une fenêtre de date appliquée à `field` (created_at).""" + now = timezone.now() + if filter_type == 'today': + return qs.filter(**{f'{field}__date': now.date()}) + if filter_type == 'yesterday': + return qs.filter(**{f'{field}__date': now.date() - timedelta(days=1)}) + if filter_type == 'last_7_days': + return qs.filter(**{f'{field}__date__gte': now.date() - timedelta(days=7)}) + if filter_type == 'last_30_days': + return qs.filter(**{f'{field}__date__gte': now.date() - timedelta(days=30)}) + if filter_type == 'this_month': + return qs.filter(**{f'{field}__year': now.year, f'{field}__month': now.month}) + if filter_type == 'last_month': + lm = now.month - 1 or 12 + ly = now.year if now.month > 1 else now.year - 1 + return qs.filter(**{f'{field}__year': ly, f'{field}__month': lm}) + if filter_type == 'custom_range' and custom_start and custom_end: + return qs.filter(**{f'{field}__date__range': [custom_start, custom_end]}) + return qs + + +def impact_role(user): + """Détermine le périmètre IMPACT selon le rôle. + + Retourne ``(is_super, org, error)`` : ``error`` est une ``Response`` 403 si l'accès + n'est pas permis (sinon None). Super Admin → ``org=None`` (plateforme) ; admin d'org / + agent de bureau → leur organisation ; autres rôles → 403. + """ + if is_super_admin(user): + return True, None, None + if is_org_admin(user) or is_bureau_agent(user): + org = getattr(user, 'organisation_member', None) + if org is None: + return False, None, Response( + {"error": "Aucune organisation associée à ce compte."}, + status=status.HTTP_403_FORBIDDEN) + return False, org, None + return False, None, Response( + {"error": "Réservé au super admin et aux admins / agents de bureau d'organisation."}, + status=status.HTTP_403_FORBIDDEN) + + +def impact_scope(request, org, acted_ids=None): + """Construit le périmètre d'incidents IMPACT (filtré par ?status= et la période). + + Restreint aux incidents pris en charge par ``org`` (taken_by ∈ org) si non None. + Retourne ``(base_qs, scope_qs, statut, acted_ids)``. + """ + p = request.query_params + statut = (p.get('status') or 'all').lower() + base = _apply_period( + Incident.objects.filter(is_deleted=False), 'created_at', + p.get('filter_type') or p.get('period'), p.get('custom_start'), p.get('custom_end')) + if org is not None: + base = base.filter(taken_by__organisation_member=org) + if acted_ids is None: + acted_ids = set( + IncidentTask.objects.filter(state=TASK_DONE).values_list('incident_id', flat=True)) + resolved_q = Q(etat__in=RESOLVED_ETATS) + taken_action_q = (~Q(etat__in=RESOLVED_ETATS) + & Q(taken_by__isnull=False) & Q(id__in=acted_ids)) + if statut in ('resolved', 'resolu', 'résolu', 'resolus'): + scope = base.filter(resolved_q) + elif statut in ('taken_action', 'taken_with_action', 'pris_en_compte', 'action'): + scope = base.filter(taken_action_q) + else: # all / les deux + scope = base.filter(resolved_q | taken_action_q) + return base, scope, statut, acted_ids + + +@extend_schema( + tags=['Référentiel & Statistiques'], + operation_id='impact_dashboard', + summary="Tableau de bord IMPACT", + description="Agrégats d'impact des incidents résolus / pris en compte avec action. " + "**Super Admin** : toutes les organisations (vue plateforme, avec performance " + "des interventions, mobilisation des acteurs et contribution citoyenne). " + "**Admin / Agent de bureau d'organisation** : uniquement l'impact de SON " + "organisation (bénéficiaires, infrastructures protégées, superficie cumulée) " + "— les sections plateforme ne sont pas renvoyées.", + parameters=[ + OpenApiParameter('status', OpenApiTypes.STR, OpenApiParameter.QUERY, required=False, + enum=['all', 'resolved', 'taken_action'], + description="Portée : résolus, pris en compte AVEC action (≥1 tâche " + "effectuée), ou les deux (défaut all)."), + OpenApiParameter('filter_type', OpenApiTypes.STR, OpenApiParameter.QUERY, required=False, + enum=['today', 'yesterday', 'last_7_days', 'last_30_days', + 'this_month', 'last_month', 'custom_range'], + description="Fenêtre de date sur created_at."), + OpenApiParameter('custom_start', OpenApiTypes.DATE, OpenApiParameter.QUERY, required=False), + OpenApiParameter('custom_end', OpenApiTypes.DATE, OpenApiParameter.QUERY, required=False), + ], + responses={200: OpenApiResponse(description=( + "{filters, beneficiaries{direct,indirect}, infrastructure_protected{total,by_type," + "indirect_by_type}, resolution{avg_resolution_days,resolution_rate}, mobilization, " + "citizen_contribution, cumulative_impact_area_ha}." + ))}, +) +class ImpactView(APIView): + """GET /MapApi/impact/ — bilan d'impact. + + Super Admin → vue plateforme (toutes orgs) + sections plateforme. + Admin / Agent de bureau → impact de SON organisation uniquement (incidents pris + en charge par son org), sans les sections plateforme. + """ + permission_classes = [IsAuthenticated] + + def get(self, request): + is_super, org, err = impact_role(request.user) + if err is not None: + return err + + base, scope, statut, acted_ids = impact_scope(request, org) + scope_ids = list(scope.values_list('id', flat=True)) + + preds = Prediction.objects.filter(incident_id__in=scope_ids, status__in=_COMPLETED) + + # 1-2) Bénéficiaires DIRECTS (colonnes dédiées). + d = preds.aggregate(total=Sum('total_population_exposed'), men=Sum('adult_men_exposed'), + women=Sum('adult_women_exposed'), children=Sum('children_exposed')) + direct = {k: int(v or 0) for k, v in d.items()} + + # 1-2) Bénéficiaires INDIRECTS + infra (directe en colonnes, indirecte en JSON) + # + superficie d'impact (π·rayon²) — un seul passage sur les prédictions. + ind = {'total': 0, 'men': 0, 'women': 0, 'children': 0} + infra_direct = {t: 0 for t in INFRA_TYPES} + infra_indirect = {t: 0 for t in INFRA_TYPES} + area_m2 = 0.0 + for pr in preds: + fr = pr.full_response or {} + ihi = fr.get('indirect_human_impact') or {} + ind['total'] += ihi.get('total_population_exposed') or 0 + ind['men'] += ihi.get('adult_men_exposed') or 0 + ind['women'] += ihi.get('adult_women_exposed') or 0 + ind['children'] += ihi.get('children_exposed') or 0 + isd = fr.get('indirect_social_data') or {} + for t in INFRA_TYPES: + infra_direct[t] += getattr(pr, t, 0) or 0 + infra_indirect[t] += isd.get(t) or 0 + r = pr.impact_radius_meters or 0 + if r: + area_m2 += math.pi * (r ** 2) + + # --- Sections communes (super admin ET organisation) : « son impact ». --- + payload = { + 'filters': { + 'scope': 'organisation' if org is not None else 'platform', + 'organisation': org.name if org is not None else None, + 'status': statut, + 'period': request.query_params.get('filter_type') or request.query_params.get('period') or 'all', + 'incidents_in_scope': len(scope_ids), + 'incidents_with_prediction': preds.count(), + }, + 'beneficiaries': { + # DIRECTS = personnes exposées dans l'analyse ; INDIRECTS = population potentielle. + 'direct': direct, + 'indirect': {k: int(v) for k, v in ind.items()}, + }, + 'infrastructure_protected': { + 'total': sum(infra_direct.values()), + 'by_type': infra_direct, # filtrable par type côté front + 'indirect_by_type': infra_indirect, + }, + # Superficie d'impact cumulée (ha) = Σ (π·rayon² / 10 000) sur le scope. + 'cumulative_impact_area_ha': round(area_m2 / 10000.0, 2), + } + + # --- Sections PLATEFORME : réservées au Super Admin (non renvoyées à une org). --- + if not is_super: + return Response(payload, status=status.HTTP_200_OK) + + # 4) Temps moyen de résolution (jours) : created_at → resolution_end_date. + durations = [] + for inc in scope.filter(etat__in=RESOLVED_ETATS, resolution_end_date__isnull=False): + if inc.created_at and inc.resolution_end_date: + days = (inc.resolution_end_date - inc.created_at.date()).days + if days >= 0: + durations.append(days) + avg_res_days = round(sum(durations) / len(durations), 1) if durations else None + + # 5) Taux de résolution = résolus / signalés (sur la période). + reported_total = base.count() + resolved_total = base.filter(etat__in=RESOLVED_ETATS).count() + rate = round(resolved_total / reported_total, 4) if reported_total else 0 + + # 6) Mobilisation des acteurs (sur le scope). + orgs = set( + o for o in scope.values_list('taken_by__organisation_member_id', flat=True) if o) + collabs = Collaboration.objects.filter(incident_id__in=scope_ids) + for o in collabs.filter(status=COLLAB_STATUS_ACCEPTED).values_list( + 'user__organisation_member_id', flat=True): + if o: + orgs.add(o) + field_agents = set( + IncidentAssignment.objects.filter(incident_id__in=scope_ids) + .values_list('agent_id', flat=True)) + + # 7) Contribution citoyenne (sur la période). + active_citizens = (base.exclude(user_id__org_role=ORG_ROLE_FIELD) + .exclude(user_id__isnull=True).values('user_id').distinct().count()) + + payload.update({ + 'resolution': { + 'avg_resolution_days': avg_res_days, + 'resolution_rate': { + 'resolved': resolved_total, + 'reported': reported_total, + 'rate': rate, + 'percentage': round(rate * 100, 1), + }, + }, + 'mobilization': { + 'organisations_involved': len(orgs), + 'field_agents_mobilized': len(field_agents), + 'collaborations_created': collabs.count(), + 'incidents_collaborative': scope.filter(take_in_charge_mode__iexact='collaborative').count(), + 'incidents_individual': scope.filter(take_in_charge_mode__iexact='internal').count(), + }, + 'citizen_contribution': { + # signalements vérifiés = pris en compte par une org (etat ≠ 'declared'). + 'reports_received': reported_total, + 'reports_verified': base.exclude(etat=DECLARED).count(), + 'reports_led_to_action': base.filter(id__in=acted_ids).count(), + 'active_citizen_contributors': active_citizens, + }, + }) + return Response(payload, status=status.HTTP_200_OK) + + +class ImpactIncidentSerializer(serializers.ModelSerializer): + """Incident du périmètre impact, enrichi pour la carte : prédiction IA (si elle + existe), toutes les tâches, et les organisations ayant agi/collaboré dessus.""" + prediction = serializers.SerializerMethodField() + tasks = serializers.SerializerMethodField() + collaborating_organisations = serializers.SerializerMethodField() + + class Meta: + model = Incident + fields = [ + 'id', 'title', 'zone', 'description', 'lattitude', 'longitude', + 'severity', 'etat', 'take_in_charge_mode', 'photo', 'thumbnail', + 'created_at', 'resolution_start_date', 'resolution_end_date', + 'prediction', 'tasks', 'collaborating_organisations', + ] + + @extend_schema_field(OpenApiTypes.OBJECT) + def get_prediction(self, obj): + p = getattr(obj, 'prediction', None) + if p is None or p.status not in _COMPLETED: + return None + fr = p.full_response or {} + ihi = fr.get('indirect_human_impact') or {} + isd = fr.get('indirect_social_data') or {} + return { + 'status': p.status, + 'incident_type': p.incident_type, + 'macro_category': p.macro_category, + 'sub_category': p.sub_category, + 'global_impact_score': p.global_impact_score, + 'impact_radius_meters': p.impact_radius_meters, + 'spread_vectors': p.spread_vectors or [], + 'direct': { + 'total_population_exposed': p.total_population_exposed or 0, + 'adult_men_exposed': p.adult_men_exposed or 0, + 'adult_women_exposed': p.adult_women_exposed or 0, + 'children_exposed': p.children_exposed or 0, + }, + 'indirect': { + 'total_population_exposed': ihi.get('total_population_exposed') or 0, + 'adult_men_exposed': ihi.get('adult_men_exposed') or 0, + 'adult_women_exposed': ihi.get('adult_women_exposed') or 0, + 'children_exposed': ihi.get('children_exposed') or 0, + 'residential_buildings': isd.get('residential_buildings') or 0, + 'vigilance_radius_meters': fr.get('indirect_vigilance_radius_meters'), + }, + 'infrastructure': {t: getattr(p, t, 0) or 0 for t in INFRA_TYPES}, + 'indirect_infrastructure': {t: (isd.get(t) or 0) for t in INFRA_TYPES}, + } + + @extend_schema_field(OpenApiTypes.OBJECT) + def get_tasks(self, obj): + out = [] + for t in obj.tasks.all(): + ag = t.assigned_to + out.append({ + 'id': t.id, 'title': t.title, 'description': t.description, + 'state': t.state, 'is_confirmed': t.is_confirmed, + 'start_date': t.start_date, 'end_date': t.end_date, + 'assigned_to': str(t.assigned_to_id) if t.assigned_to_id else None, + 'assigned_to_name': ((f"{ag.first_name or ''} {ag.last_name or ''}".strip() or ag.email) + if ag else None), + }) + return out + + @extend_schema_field(OpenApiTypes.OBJECT) + def get_collaborating_organisations(self, obj): + return acting_organisations(obj) + + +@extend_schema( + tags=['Référentiel & Statistiques'], + operation_id='impact_incidents_list', + summary="Incidents du périmètre impact (paginé + recherche)", + description="Liste **paginée** et **cherchable** des incidents derrière le tableau de " + "bord impact — mêmes portée et filtres que `/impact/` (Super Admin = toutes " + "orgs ; admin/agent de bureau = incidents pris en charge par SON org). Chaque " + "incident porte sa **prédiction IA** (si elle existe), **toutes ses tâches** et " + "les **organisations** ayant agi/collaboré dessus.", + parameters=[ + OpenApiParameter('status', OpenApiTypes.STR, OpenApiParameter.QUERY, required=False, + enum=['all', 'resolved', 'taken_action']), + OpenApiParameter('search', OpenApiTypes.STR, OpenApiParameter.QUERY, required=False, + description="Recherche titre / description / zone."), + OpenApiParameter('filter_type', OpenApiTypes.STR, OpenApiParameter.QUERY, required=False, + enum=['today', 'yesterday', 'last_7_days', 'last_30_days', + 'this_month', 'last_month', 'custom_range']), + OpenApiParameter('custom_start', OpenApiTypes.DATE, OpenApiParameter.QUERY, required=False), + OpenApiParameter('custom_end', OpenApiTypes.DATE, OpenApiParameter.QUERY, required=False), + OpenApiParameter('page', OpenApiTypes.INT, OpenApiParameter.QUERY, required=False), + OpenApiParameter('page_size', OpenApiTypes.INT, OpenApiParameter.QUERY, required=False), + ], + responses={200: ImpactIncidentSerializer(many=True)}, +) +class ImpactIncidentsView(generics.ListAPIView): + """GET /MapApi/impact/incidents/ — liste paginée + recherche des incidents impact.""" + permission_classes = [IsAuthenticated] + serializer_class = ImpactIncidentSerializer + pagination_class = IncidentPagination + + def list(self, request, *args, **kwargs): + _, _, err = impact_role(request.user) + if err is not None: + return err + return super().list(request, *args, **kwargs) + + def get_queryset(self): + is_super, org, err = impact_role(self.request.user) + if err is not None: + return Incident.objects.none() + _, scope, _, _ = impact_scope(self.request, org) + search = (self.request.query_params.get('search') or '').strip() + if search: + scope = scope.filter( + Q(title__icontains=search) | Q(description__icontains=search) + | Q(zone__icontains=search)) + return ( + scope + .select_related('user_id', 'category_id', 'taken_by__organisation_member', 'prediction') + .prefetch_related( + 'tasks', 'tasks__assigned_to', + 'collaboration_set__user__organisation_member', + Prefetch('org_assignments', + queryset=IncidentOrgAssignment.objects.select_related('organisation')), + ) + .order_by(F('resolution_end_date').desc(nulls_last=True), '-created_at') + ) diff --git a/Mapapi/views/incident.py b/Mapapi/views/incident.py index 87b6f375..dc941915 100644 --- a/Mapapi/views/incident.py +++ b/Mapapi/views/incident.py @@ -3,9 +3,9 @@ from datetime import timedelta from django.conf import settings -from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError from django.core.mail import EmailMultiAlternatives -from django.db.models import Q +from django.db.models import Q, Prefetch, Count from django.template.loader import render_to_string from django.utils import timezone from django.utils.html import strip_tags @@ -15,53 +15,247 @@ from rest_framework.response import Response from rest_framework.views import APIView -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import ( + extend_schema, extend_schema_view, OpenApiParameter, OpenApiResponse, + OpenApiExample, inline_serializer, +) +from drf_spectacular.types import OpenApiTypes +from rest_framework import serializers as drf_serializers from ..serializer import * from ..models import ( Collaboration, COLLAB_ROLE_LEADER, COLLAB_ROLE_CONTRIBUTOR, COLLAB_ROLE_OBSERVER, - RESOLVED, TAKEN, TASK_DONE, TASK_FAILED, + COLLAB_STATUS_ACCEPTED, COLLAB_STATUS_TERMINATED, COLLAB_STATUS_PENDING, + RESOLVED, TASK_DONE, TASK_FAILED, + DECLARED, TAKEN, RESOLUTION_PREPARED, IN_VALIDATION, RESOLVED_DEFINITIVE, ORG_ROLE_FIELD, ORG_ROLE_ADMIN, ORG_ROLE_BUREAU, - Prediction, PredictionStatus, + Organisation, IncidentOrgAssignment, + ORG_ASSIGNMENT_PENDING, ORG_ASSIGNMENT_ACCEPTED, ORG_ASSIGNMENT_DECLINED, + Prediction, PredictionStatus, Notification, ChatHistory, CHAT_ROLE_USER, CHAT_ROLE_ASSISTANT, + Rapport, IncidentAssignment, +) +from ..permissions import ( + IsIncidentLeader, IsSuperAdminOrOrgOwnIncident, IsSuperAdmin, + IsOrgAdmin, IsAgentBureau, IsOrgOperative, IsSuperAdminRole, ) -from ..permissions import IsIncidentLeader, IsSuperAdminOrOrgOwnIncident, IsSuperAdmin +from ..roles import is_org_admin from ..tasks import analyze_incident_with_model_task from ..Send_mails import send_email import logging -User = get_user_model() - logger = logging.getLogger(__name__) from ..services.model_chat_client import ask_model_chat -from .common import CustomPageNumberPagination +from .common import CustomPageNumberPagination, IncidentPagination, FieldReportPagination from rest_framework_simplejwt.tokens import RefreshToken from django.contrib.auth.hashers import check_password +from .. import roles as web_roles + +def visible_incidents_qs(base_qs, user): + """Restreint un queryset d'incidents selon le rôle web du demandeur (spec §1, §6). + + - super_admin → tout (aucun filtre). + - org_admin/bureau_agent → incidents INTERNES de SON org (reporter dans l'org) + UNION incidents PUBLICS de son PAYS (pays dérivé de l'org + du reporter ; si le reporter n'a pas d'org, le pays est + indéterminable → on garde l'incident public, signalement citoyen). + - sinon (pas de rôle web) → incidents publics uniquement. + + NB : il n'existe pas de champ `Incident.country` ; le pays est dérivé de + l'organisation du reporter (`user_id.organisation_member.intervention_country`). + """ + role = web_roles.get_web_role(user) + + if role == web_roles.SUPER_ADMIN: + return base_qs + + if role in (web_roles.ORG_ADMIN, web_roles.BUREAU_AGENT): + org = getattr(user, 'organisation_member', None) + country = getattr(org, 'intervention_country', None) if org else None + # (a) incidents internes dont le reporter appartient à MON org + internal_own = Q(is_public=False, user_id__organisation_member=org) + # (b) incidents publics de mon pays OU au pays indéterminable (reporter sans org) + public_in_country = Q(is_public=True) & ( + Q(user_id__organisation_member__intervention_country=country) + | Q(user_id__organisation_member__isnull=True) + | Q(user_id__isnull=True) + ) + return base_qs.filter(internal_own | public_in_country) -@extend_schema( - description="Endpoint allowing retrieval of incident by zone.", - request=IncidentSerializer, - responses={200: IncidentSerializer, 404: "Incident not found"}, + # Aucun rôle web reconnu → public seulement. + return base_qs.filter(is_public=True) + + +# États « résolus » exposés par /incidentResolved/ (résolu + résolu définitif). +# /incidentNotResolved/ = tout le reste (declared, taken_into_account, in_progress, +# in_validation) — et non plus seulement 'declared' comme avant. +RESOLVED_ETATS = [RESOLVED, RESOLVED_DEFINITIVE] + + +def org_own_work_q(user): + """Q : incidents que MON organisation gère déjà — à EXCLURE des listes + publiques résolus/non-résolus (ils sont déjà dans « Mes interventions »). + + Deux cas, exactement comme « Mes interventions » (OrgIncidentsView) : + - pris en charge EN INTERNE par mon org (``take_in_charge_mode='internal'`` + ET ``taken_by`` ∈ mon org), OU + - signalés par un agent de TERRAIN de mon org (``user_id`` ∈ mes agents). + + Utilisateur sans organisation → rien à exclure (Q qui ne matche aucune ligne). + """ + org = getattr(user, 'organisation_member', None) + if not org: + return Q(pk__in=[]) + agent_ids = list(org.members.filter(org_role=ORG_ROLE_FIELD).values_list('id', flat=True)) + internal_q = Q(take_in_charge_mode__iexact='internal', taken_by__organisation_member=org) + agents_q = Q(user_id__in=agent_ids) + return internal_q | agents_q + + +def engage_incident(incident, leader): + """Engage l'organisation sur l'incident (sémantique « prise en compte »). + + Reprend la logique de TakeInChargeView (mode interne) : si l'incident est + 'declared', il passe en 'taken_into_account', avec taken_by = `leader` et + taken_in_charge_at = maintenant. Aucun changement si l'incident est déjà + engagé (idempotent côté état). Renvoie True si une transition a eu lieu. + + NB : « l'organisation devient leader » est représenté par incident.taken_by + (un User) — le modèle n'a pas de FK organisation directe sur Incident ; on + réutilise donc taken_by, comme TakeInChargeView et HandleIncidentView. + """ + if incident.etat == DECLARED: + incident.etat = TAKEN + incident.taken_by = leader + incident.taken_in_charge_at = timezone.now() + # Anti-gel (spec T3) : (re)prise en compte => réarmer les avertissements. + incident.antigel_warned_75 = False + incident.antigel_warned_90 = False + incident.save(update_fields=[ + 'etat', 'taken_by', 'taken_in_charge_at', + 'antigel_warned_75', 'antigel_warned_90', + ]) + return True + return False + + +def terminate_active_collaborations(incident): + """Clôt les collaborations encore actives d'un incident (spec §5). + + Lorsqu'un incident devient « Résolu (définitif) », ses collaborations encore + 'accepted' passent à 'terminated' (Terminée). Idempotent : ne touche que les + lignes encore 'accepted', donc relancer ne refait rien. Retourne le nombre + de collaborations clôturées. + """ + return Collaboration.objects.filter( + incident=incident, + status=COLLAB_STATUS_ACCEPTED, + ).update(status=COLLAB_STATUS_TERMINATED) + + +@extend_schema_view( + get=extend_schema( + tags=['Incidents'], + operation_id='incidents_by_zone_list', + summary="Incidents d'une zone", + description=( + "Liste les incidents d'une zone (identifiant numérique). " + "Authentification requise ; le résultat est restreint selon le rôle web " + "du demandeur (super admin → tout, org/bureau → interne de son org + public " + "de son pays, sinon public uniquement)." + ), + parameters=[ + OpenApiParameter('zone', OpenApiTypes.INT, OpenApiParameter.PATH, + description="Identifiant numérique de la zone."), + ], + responses={ + 200: IncidentGetSerializer(many=True), + 404: OpenApiResponse(description="Zone/incident introuvable."), + }, + ), + post=extend_schema(exclude=True), ) class IncidentByZoneAPIView(generics.CreateAPIView): permission_classes = () queryset = Incident.objects.all() serializer_class = IncidentSerializer - + + def get_permissions(self): + # La LISTE par zone (GET) est restreinte par rôle (auth requise). + if self.request.method == 'GET': + return [IsAuthenticated()] + return [] + def get(self, request, format=None, **kwargs): try: zone = kwargs['zone'] - item = Incident.objects.filter(zone=zone).select_related('user_id', 'category_id').order_by('-pk') + base = ( + Incident.objects + .filter(zone=zone) + .select_related( + 'user_id', 'user_id__organisation_member', 'category_id', + 'taken_by__organisation_member', + ) + .prefetch_related( + 'org_assignments__organisation', + 'collaboration_set__user__organisation_member', + ) + .order_by('-pk') + ) + item = visible_incidents_qs(base, request.user) serializer = IncidentGetSerializer(item, many=True) return Response(serializer.data) except Incident.DoesNotExist: return Response(status=404) -@extend_schema( - description="Endpoint allowing retrieval, updating, and deletion of an incident.", - request=IncidentSerializer, - responses={200: IncidentSerializer, 404: "Incident not found"}, +@extend_schema_view( + get=extend_schema( + tags=['Incidents'], + operation_id='incidents_retrieve', + summary="Détail d'un incident", + description="Récupère un incident par son identifiant (UUID), avec ses " + "organisations assignées, catégories et collaborations. Public.", + parameters=[ + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + responses={200: IncidentSerializer, 404: OpenApiResponse(description="Incident non trouvé.")}, + ), + put=extend_schema( + tags=['Incidents'], + operation_id='incidents_update', + summary="Mettre à jour un incident", + description="Remplace les données d'un incident. Si `etat` passe à `resolved` " + "ou `in_progress`, un email est envoyé au reporter.", + parameters=[ + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + request=IncidentSerializer, + responses={ + 200: IncidentSerializer, + 400: OpenApiResponse(description="Données invalides."), + 404: OpenApiResponse(description="Incident non trouvé."), + }, + ), + delete=extend_schema( + tags=['Incidents'], + operation_id='incidents_destroy', + summary="Supprimer un incident (corbeille)", + description="Suppression logique (is_deleted=True). Réservé au Super Admin ou à " + "une organisation propriétaire de l'incident.", + parameters=[ + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + responses={ + 204: OpenApiResponse(description="Incident supprimé (logique)."), + 403: OpenApiResponse(description="Droits insuffisants."), + 404: OpenApiResponse(description="Incident non trouvé."), + }, + ), + post=extend_schema(exclude=True), ) class IncidentAPIView(generics.CreateAPIView): permission_classes = () @@ -70,7 +264,23 @@ class IncidentAPIView(generics.CreateAPIView): def get(self, request, id, format=None): try: - item = Incident.objects.get(pk=id) + # Détail d'un incident : on précharge les org_assignments AVEC leur + # organisation (select_related) en une requête au lieu d'une requête + # organisation par assignation, et les catégories (M2M). Sur un pooler + # distant chaque aller-retour compte (≈80 ms) : 4 requêtes → 3. + item = ( + Incident.objects + .select_related('taken_by__organisation_member') + .prefetch_related( + Prefetch( + 'org_assignments', + queryset=IncidentOrgAssignment.objects.select_related('organisation'), + ), + 'category_ids', + 'collaboration_set__user__organisation_member', + ) + .get(pk=id) + ) serializer = IncidentSerializer(item) return Response(serializer.data) except Incident.DoesNotExist: @@ -121,30 +331,87 @@ def delete(self, request, id, format=None): return Response({"error": permission.message}, status=403) item.is_deleted = True - item.save(update_fields=['is_deleted']) + item.deleted_at = timezone.now() + item.save(update_fields=['is_deleted', 'deleted_at']) return Response(status=204) -@extend_schema( - description="Endpoint for creating and retrieve a new incident." - "Users can submit details of an incident by providing the required information via a POST request." - "The submitted data will be validated and stored in the system." - "Upon success, a status code 201 (Created) will be returned along with details of the newly created incident." - "In case of validation errors or issues with creating the incident, a status code 400 (Bad Request) will be returned along with information about the encountered errors." - "Users must ensure that the provided data adheres to the format and constraints defined for incidents in the system.", - request=IncidentSerializer, - responses={201: IncidentSerializer, 400: "Bad Request"}, +@extend_schema_view( + get=extend_schema( + tags=['Incidents'], + operation_id='incidents_list', + summary="Lister les incidents", + description="Liste paginée des incidents, restreinte selon le rôle web du " + "demandeur (authentification requise).", + parameters=[ + OpenApiParameter('page', OpenApiTypes.INT, OpenApiParameter.QUERY, + description="Numéro de page."), + OpenApiParameter('page_size', OpenApiTypes.INT, OpenApiParameter.QUERY, + description="Taille de page."), + ], + responses={200: IncidentGetSerializer(many=True)}, + ), + post=extend_schema( + tags=['Incidents'], + operation_id='incidents_create', + summary="Déclarer un incident", + description="Crée un incident (déclaration citoyenne/mobile, public). Crée la zone " + "si nécessaire, +1 point au reporter, déclenche l'analyse IA (Prediction) " + "et la conversion vidéo éventuelle.", + request=IncidentSerializer, + responses={ + 201: IncidentSerializer, + 400: OpenApiResponse(description="Données invalides ou champ `zone` manquant."), + }, + ), ) class IncidentAPIListView(generics.CreateAPIView): permission_classes = () - + queryset = Incident.objects.all() serializer_class = IncidentSerializer - + + def get_permissions(self): + # La LISTE (GET) est restreinte par rôle (auth requise) ; la création POST + # reste ouverte (déclaration mobile/citoyen, inchangée). + if self.request.method == 'GET': + return [IsAuthenticated()] + return [] + def get(self, request, format=None): - items = Incident.objects.select_related('user_id', 'category_id').order_by('-pk') - paginator = CustomPageNumberPagination() + base = ( + Incident.objects + .select_related( + 'category_id', 'user_id', 'user_id__organisation_member', + 'taken_by__organisation_member', + ) + .prefetch_related( + 'user_id__zones', + 'category_ids', + 'org_assignments__organisation', + 'collaboration_set__user__organisation_member', + ) + .order_by('-pk') + ) + items = visible_incidents_qs(base, request.user) + # Recherche + filtres (onglet incidents) : ?search= (titre/description/zone), + # ?etat= (statut), ?severity=. Combinables. + p = request.query_params + search = (p.get('search') or '').strip() + if search: + items = items.filter( + Q(title__icontains=search) | Q(description__icontains=search) | Q(zone__icontains=search) + ) + etat = p.get('etat') or p.get('status') + if etat: + items = items.filter(etat=etat) + severity = p.get('severity') + if severity: + items = items.filter(severity=severity) + paginator = IncidentPagination() result_page = paginator.paginate_queryset(items, request) - serializer = IncidentGetSerializer(result_page, many=True) + # context={'request'} : nécessaire pour le champ `my_collaboration` + # (demande de collaboration du viewer sur chaque incident). + serializer = IncidentGetSerializer(result_page, many=True, context={'request': request}) return paginator.get_paginated_response(serializer.data) def post(self, request, format=None): @@ -216,14 +483,20 @@ def post(self, request, format=None): return Response(serializer.data, status=status.HTTP_201_CREATED) -@extend_schema( - description="Endpoint allowing retrieval of incidents reported by the authenticated user.", +@extend_schema_view( + get=extend_schema( + tags=['Incidents'], + operation_id='incidents_mine_list', + summary="Mes incidents déclarés", + description="Incidents reportés par l'utilisateur connecté (authentification requise).", responses={200: IncidentGetSerializer(many=True)}, + ), ) class MyIncidentsView(generics.ListAPIView): """GET /my-incidents/ — incidents reportés par l'utilisateur connecté.""" permission_classes = [IsAuthenticated] serializer_class = IncidentGetSerializer + pagination_class = IncidentPagination def get_queryset(self): return ( @@ -234,56 +507,27 @@ def get_queryset(self): ) -@extend_schema( - description="Notifications (dérivées, sans modèle) de l'état des incidents du citoyen connecté. " - "Renvoie les incidents passés à 'taken_into_account' ou 'resolved'. " - "Le suivi lu/non-lu est géré côté application mobile.", - responses={200: dict}, -) -class MyIncidentNotificationsView(APIView): - """GET /my-incidents/notifications/ - - Endpoint léger destiné à l'app mobile du citoyen : il liste l'état actuel - des incidents qu'il a déclarés et qui ont avancé (pris en compte ou résolu). - Aucun modèle de notification n'est utilisé ; la liste est calculée à la volée. - """ - permission_classes = [IsAuthenticated] - - _MESSAGES = { - TAKEN: "Votre incident « {title} » a été pris en compte.", - RESOLVED: "Votre incident « {title} » a été résolu.", - } - - def get(self, request, *args, **kwargs): - incidents = ( - Incident.objects - .filter(user_id=request.user, etat__in=[TAKEN, RESOLVED]) - .only('id', 'title', 'zone', 'etat', 'created_at', 'resolution_end_date') - .order_by('-created_at') - ) - - notifications = [] - for inc in incidents: - title = inc.title or inc.zone or f"#{inc.id}" - notifications.append({ - "incident_id": inc.id, - "title": inc.title, - "zone": inc.zone, - "etat": inc.etat, - "message": self._MESSAGES[inc.etat].format(title=title), - "resolution_end_date": inc.resolution_end_date, - "created_at": inc.created_at, - }) - - return Response({ - "count": len(notifications), - "results": notifications, - }, status=status.HTTP_200_OK) - - -@extend_schema( - description="Incidents de l'organisation. Filtre ?source=agents|citizens|all (défaut: all).", +@extend_schema_view( + get=extend_schema( + tags=['Incidents'], + operation_id='incidents_org_list', + summary="Incidents de mon organisation", + description="Incidents liés à l'organisation de l'utilisateur connecté, en mode de prise " + "en charge interne. Filtrable par origine du reporter.", + parameters=[ + OpenApiParameter('source', OpenApiTypes.STR, OpenApiParameter.QUERY, + enum=['agents_or_internal', 'agents', 'internal', 'citizens', 'all'], required=False, + description="agents_or_internal (défaut) = interne + signalés par mes agents ; " + "agents = signalés par mes agents ; internal = pris en charge en interne ; " + "citizens = internes signalés par des citoyens ; all = union."), + OpenApiParameter('search', OpenApiTypes.STR, OpenApiParameter.QUERY, required=False, + description="Recherche dans le titre, la description et la localisation (zone)."), + OpenApiParameter('status', OpenApiTypes.STR, OpenApiParameter.QUERY, required=False, + enum=['declared', 'taken_into_account', 'in_progress', 'resolved'], + description="Filtre par statut (etat) de l'incident. Alias : ?etat=."), + ], responses={200: IncidentGetSerializer(many=True)}, + ), ) class OrgIncidentsView(generics.ListAPIView): """GET /org-incidents/ — incidents liés à l'organisation de l'utilisateur. @@ -294,67 +538,243 @@ class OrgIncidentsView(generics.ListAPIView): """ permission_classes = [IsAuthenticated] serializer_class = IncidentGetSerializer + pagination_class = IncidentPagination def get_queryset(self): user = self.request.user - if user.is_superuser: - return Incident.objects.all().select_related('user_id', 'category_id').order_by('-created_at') - org = user.organisation_member if not org: return Incident.objects.none() - source = self.request.query_params.get('source', 'all') - mode = self.request.query_params.get('mode', None) - # IDs des agents de terrain de CETTE org - agent_ids = org.members.filter(org_role=ORG_ROLE_FIELD).values_list('id', flat=True) + source = (self.request.query_params.get('source') or 'agents_or_internal').lower() + mode = (self.request.query_params.get('mode') or '').lower() + # IDs des agents de terrain de l'org + agent_ids = list(org.members.filter(org_role=ORG_ROLE_FIELD).values_list('id', flat=True)) + + # « Mes interventions » = ce que MON organisation gère réellement : + # - interne : incident pris en charge EN INTERNE par mon org + # (take_in_charge_mode='internal' ET taken_by ∈ mon org), ET + # - agents : incident SIGNALÉ par un agent de terrain de mon org. + # Le scope par org (taken_by / reporter) est essentiel : sans lui on renvoyait + # tous les incidents internes de TOUTES les orgs (« peu importe mon org »). Les + # incidents où mon org ne fait que COLLABORER (taken_by = autre org, mode + # 'collaborative') sont exclus → ils relèvent de « Mes collaborations ». + internal_q = Q(take_in_charge_mode__iexact='internal', taken_by__organisation_member=org) + agents_q = Q(user_id__in=agent_ids) + + if mode == 'internal' or source == 'internal': + flt = internal_q + elif source == 'agents': + flt = agents_q + elif source == 'citizens': + # Incidents internes de mon org signalés par des non-agents (citoyens). + flt = internal_q & ~agents_q + else: # 'agents_or_internal' (défaut) | 'all' → union interne + agents + flt = internal_q | agents_q - qs = Incident.objects.select_related('user_id', 'category_id') + qs = ( + Incident.objects + .select_related('user_id', 'category_id', 'taken_by__organisation_member') + .filter(flt) + .exclude(is_deleted=True) + .distinct() + ) - org_members = org.members.all() + # Recherche (titre / description / localisation=zone) + filtre par statut (etat). + p = self.request.query_params + search = (p.get('search') or '').strip() + if search: + qs = qs.filter( + Q(title__icontains=search) + | Q(description__icontains=search) + | Q(zone__icontains=search) + ) + status_etat = p.get('status') or p.get('etat') + if status_etat: + qs = qs.filter(etat=status_etat) - # IDs des agents de terrain de TOUTES les organisations - all_field_agent_ids = User.objects.filter(org_role=ORG_ROLE_FIELD).values_list('id', flat=True) - # Agents de terrain des AUTRES organisations - other_field_agent_ids = all_field_agent_ids.exclude(id__in=agent_ids) + return qs.order_by('-created_at') - if source == 'agents_or_internal': - # Option B: Logique OU (Incidents créés par les agents de l'org OU pris en interne par l'org) - qs = qs.filter( - Q(user_id__in=agent_ids) | - Q(take_in_charge_mode__iexact='internal', taken_by__in=org_members) + def list(self, request, *args, **kwargs): + # Ajoute `reports_count` à chaque incident = nombre de rapports de terrain + # (FieldReport) des agents de MON organisation sur cet incident — la même + # portée que le panneau « Rapports des agents » (GET /field-reports/?incident=). + # Une seule requête groupée par page (pas de N+1). + queryset = self.get_queryset() + page = self.paginate_queryset(queryset) + objs = list(page) if page is not None else list(queryset) + serializer = self.get_serializer(objs, many=True) + data = serializer.data + + org = getattr(request.user, 'organisation_member', None) + counts = {} + ids = [o.id for o in objs] + if ids and org: + counts = dict( + FieldReport.objects + .filter(incident_id__in=ids, agent__organisation_member=org) + .values('incident_id') + .annotate(n=Count('id')) + .values_list('incident_id', 'n') ) - # Désactiver le filtre mode strict car il casserait l'union "OU" - mode = None - elif source == 'agents': - qs = qs.filter(user_id__in=agent_ids) - elif source == 'citizens': - qs = qs.exclude(user_id__in=agent_ids) - # source == 'all' : pas de filtre supplémentaire - - # Règle de sécurité globale : on masque les incidents 'internal' qui appartiennent à D'AUTRES organisations - qs = qs.filter( - Q(take_in_charge_mode__isnull=True) | - ~Q(take_in_charge_mode__iexact='internal') | - Q(take_in_charge_mode__iexact='internal', taken_by__in=org_members) - ) + for d, o in zip(data, objs): + d['reports_count'] = counts.get(o.id, 0) + + if page is not None: + return self.get_paginated_response(data) + return Response(data) + + +def _assigned_agent_dict(assignment): + """Représentation explicite d'un agent assigné à un incident.""" + ag = assignment.agent + org = getattr(ag, 'organisation_member', None) if ag else None + return { + 'id': ag.id if ag else None, + 'name': (f"{ag.first_name or ''} {ag.last_name or ''}".strip() or ag.email) if ag else None, + 'email': ag.email if ag else None, + 'phone': getattr(ag, 'phone', None) if ag else None, + 'org_role': getattr(ag, 'org_role', None) if ag else None, + 'organisation_id': org.id if org else None, + 'organisation_name': org.name if org else None, + 'assignment_status': assignment.status, + 'deadline': assignment.deadline, + } + - # Règle de sécurité globale : on masque les incidents déclarés par les agents de terrain - # d'AUTRES organisations (sauf si pris en charge par cette org). - qs = qs.exclude( - Q(user_id__in=other_field_agent_ids) & ~Q(taken_by__in=org_members) +@extend_schema_view( + get=extend_schema( + tags=['Prise en charge & Collaboration'], + operation_id='incidents_my_interventions_list', + summary="Mes interventions", + description="Incidents sur lesquels l'utilisateur (ou son organisation) travaille " + "réellement (prise en charge perso/org, assignation org acceptée, " + "collaboration acceptée, ou agent assigné). Chaque incident est enrichi " + "de `assigned_agents` et `reports_count`. Authentification requise.", + responses={200: OpenApiResponse( + response=IncidentGetSerializer(many=True), + description="Liste d'incidents (IncidentGetSerializer) enrichis de " + "`assigned_agents`[{id,name,email,phone,org_role,organisation_id," + "organisation_name,assignment_status,deadline}] et `reports_count`.", + )}, + ), +) +class MyInterventionsView(APIView): + """GET /my-interventions/ — incidents sur lesquels l'utilisateur (ou son + organisation) travaille réellement, chacun avec TOUS les agents assignés et + le nombre de rapports. Les rapports détaillés d'un incident sont servis par + GET /incidents//reports/. + + « Travaille dessus » = a pris l'incident en charge (perso ou via son org), + org assignée et acceptée, collaboration acceptée de son org, ou agent assigné. + """ + permission_classes = [IsAuthenticated] + + def get(self, request): + from ..services.incident_orgs import org_acting_q + + incidents = ( + Incident.objects.filter(org_acting_q(request.user)) + .exclude(is_deleted=True) + .distinct() + .select_related('taken_by__organisation_member', 'user_id', 'category_id') + .prefetch_related( + 'org_assignments__organisation', + 'collaboration_set__user__organisation_member', + 'assignments__agent__organisation_member', + 'incident_rapport', + ) + .order_by('-created_at') ) - # Si le front-end demande explicitement un filtre mode additionnel (uniquement si ce n'est pas l'Option B) - if mode == 'internal': - qs = qs.filter(take_in_charge_mode__iexact='internal', taken_by__in=org_members) - elif mode: - qs = qs.filter(take_in_charge_mode__iexact=mode) - - return qs.order_by('-created_at') + data = [] + for inc in incidents: + inc_data = IncidentGetSerializer(inc).data + inc_data['assigned_agents'] = [_assigned_agent_dict(a) for a in inc.assignments.all()] + inc_data['reports_count'] = len(inc.incident_rapport.all()) + data.append(inc_data) + return Response(data, status=status.HTTP_200_OK) +@extend_schema_view( + get=extend_schema( + tags=['Prise en charge & Collaboration'], + operation_id='incidents_reports_list', + summary="Rapports d'un incident", + description="Tous les rapports d'agents liés à l'incident (via FK ou M2M), avec " + "auteur et organisation. Authentification requise.", + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + responses={ + 200: IncidentReportSerializer(many=True), + 404: OpenApiResponse(description="Incident non trouvé."), + }, + ), +) +class IncidentReportsView(APIView): + """GET /incidents//reports/ — tous les rapports d'agents liés à + l'incident (FK incident OU M2M incidents), avec auteur + organisation. + Sert la page Mes interventions ET le détail de collaboration (rapports des + agents de chaque organisation travaillant sur l'incident).""" + permission_classes = [IsAuthenticated] + + def get(self, request, incident_id): + try: + incident = Incident.objects.get(pk=incident_id) + except Incident.DoesNotExist: + return Response({"error": "Incident non trouvé."}, status=status.HTTP_404_NOT_FOUND) + reports = ( + Rapport.objects + .filter(Q(incident=incident) | Q(incidents=incident)) + .select_related('user_id', 'user_id__organisation_member') + .distinct() + .order_by('-created_at') + ) + return Response( + IncidentReportSerializer(reports, many=True).data, + status=status.HTTP_200_OK, + ) + + +@extend_schema_view( + get=extend_schema( + tags=['Prise en charge & Collaboration'], + operation_id='incidents_assignments_list', + summary="Lister les assignations d'un incident", + description="Liste les agents de terrain assignés à l'incident. Réservé au staff " + "ou à un admin/bureau de l'organisation propriétaire de l'incident.", + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + responses={ + 200: IncidentAssignmentSerializer(many=True), + 403: OpenApiResponse(description="Droits insuffisants."), + 404: OpenApiResponse(description="Incident non trouvé."), + }, + ), + post=extend_schema( + tags=['Prise en charge & Collaboration'], + operation_id='incidents_assignments_create', + summary="Assigner un incident à un agent", + description="Assigne un agent de terrain à l'incident et lui envoie un email. " + "Réservé à un administrateur d'organisation (ou Super Admin).", + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + request=IncidentAssignmentSerializer, + responses={ + 201: IncidentAssignmentSerializer, + 400: OpenApiResponse(description="Données invalides."), + 403: OpenApiResponse(description="Droits insuffisants (admin d'organisation requis)."), + 404: OpenApiResponse(description="Incident non trouvé."), + }, + ), +) class IncidentAssignmentListCreateView(generics.ListCreateAPIView): permission_classes = [IsAuthenticated] serializer_class = IncidentAssignmentSerializer @@ -381,8 +801,16 @@ def create(self, request, *args, **kwargs): except Incident.DoesNotExist: return Response({"error": "Incident non trouvé."}, status=status.HTTP_404_NOT_FOUND) + # Assigner un incident à un agent de terrain = staff/super admin, OU + # Admin d'organisation OU agent de bureau de l'organisation propriétaire de + # l'incident (cf. _can_manage_assignment). L'agent de bureau gère les agents + # de terrain de son org (création/édition/suppression… et assignation). + # Le serializer garantit que l'agent assigné est bien un agent de terrain. if not self._can_manage_assignment(request.user, incident): - return Response({"error": "Droits insuffisants."}, status=status.HTTP_403_FORBIDDEN) + return Response( + {"error": "Vous n'avez pas les droits pour assigner cet incident à un agent."}, + status=status.HTTP_403_FORBIDDEN, + ) data = request.data.copy() data['incident'] = incident.id @@ -450,17 +878,86 @@ def _send_assignment_email(assignment, request): def _can_manage_assignment(user, incident): if user.is_staff or user.is_superuser: return True + # Admin d'org ET agent de bureau gèrent les agents de terrain de leur org. if user.org_role not in [ORG_ROLE_ADMIN, ORG_ROLE_BUREAU]: return False if not user.organisation_member: return False - if incident.user_id and incident.user_id.organisation_member == user.organisation_member: + org = user.organisation_member + # 1) Incident de MON organisation : signalé par mon org, ou pris en charge par mon org. + if incident.user_id and incident.user_id.organisation_member == org: return True - if incident.taken_by and incident.taken_by.organisation_member == user.organisation_member: + if incident.taken_by and incident.taken_by.organisation_member == org: + return True + # 2) Incident PUBLIC encore NON pris en charge (taken_by=None) : n'importe quelle + # organisation peut y dépêcher un de ses agents de terrain (investigation d'un + # signalement citoyen). Dès qu'une org le prend en charge, seul elle gère (cas 1). + if getattr(incident, 'is_public', False) and incident.taken_by_id is None: return True return False +_ASSIGNMENT_DETAIL_PARAMS = [ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + OpenApiParameter('pk', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'assignation."), +] + + +@extend_schema_view( + get=extend_schema( + tags=['Prise en charge & Collaboration'], + operation_id='incidents_assignments_retrieve', + summary="Détail d'une assignation", + description="Récupère une assignation d'agent. Réservé au staff ou à un " + "admin/bureau de l'organisation propriétaire de l'incident.", + parameters=_ASSIGNMENT_DETAIL_PARAMS, + responses={ + 200: IncidentAssignmentSerializer, + 403: OpenApiResponse(description="Droits insuffisants."), + 404: OpenApiResponse(description="Assignation non trouvée."), + }, + ), + put=extend_schema( + tags=['Prise en charge & Collaboration'], + operation_id='incidents_assignments_update', + summary="Modifier une assignation", + description="Met à jour une assignation d'agent (droits de gestion requis).", + parameters=_ASSIGNMENT_DETAIL_PARAMS, + request=IncidentAssignmentSerializer, + responses={ + 200: IncidentAssignmentSerializer, + 403: OpenApiResponse(description="Droits insuffisants."), + 404: OpenApiResponse(description="Assignation non trouvée."), + }, + ), + patch=extend_schema( + tags=['Prise en charge & Collaboration'], + operation_id='incidents_assignments_partial_update', + summary="Modifier partiellement une assignation", + description="Met à jour partiellement une assignation d'agent (droits de gestion requis).", + parameters=_ASSIGNMENT_DETAIL_PARAMS, + request=IncidentAssignmentSerializer, + responses={ + 200: IncidentAssignmentSerializer, + 403: OpenApiResponse(description="Droits insuffisants."), + 404: OpenApiResponse(description="Assignation non trouvée."), + }, + ), + delete=extend_schema( + tags=['Prise en charge & Collaboration'], + operation_id='incidents_assignments_destroy', + summary="Supprimer une assignation", + description="Supprime une assignation d'agent (droits de gestion requis).", + parameters=_ASSIGNMENT_DETAIL_PARAMS, + responses={ + 204: OpenApiResponse(description="Assignation supprimée."), + 403: OpenApiResponse(description="Droits insuffisants."), + 404: OpenApiResponse(description="Assignation non trouvée."), + }, + ), +) class IncidentAssignmentDetailView(generics.RetrieveUpdateDestroyAPIView): permission_classes = [IsAuthenticated] serializer_class = IncidentAssignmentSerializer @@ -478,72 +975,84 @@ def get_object(self): return obj +@extend_schema_view( + get=extend_schema( + tags=['Prise en charge & Collaboration'], + operation_id='incidents_agent_assigned_list', + summary="Incidents assignés à l'agent connecté", + description="Assignations de l'agent de terrain connecté (réservé aux agents de " + "terrain ; vide pour les autres rôles).", + responses={200: IncidentAssignmentSerializer(many=True)}, + ), +) class AgentAssignedIncidentsView(generics.ListAPIView): permission_classes = [IsAuthenticated] serializer_class = IncidentAssignmentSerializer def get_queryset(self): user = self.request.user - if user.is_superuser: - return IncidentAssignment.objects.all().select_related('incident', 'agent', 'assigned_by') if user.org_role != ORG_ROLE_FIELD: return IncidentAssignment.objects.none() return IncidentAssignment.objects.filter(agent=user).select_related('incident', 'agent', 'assigned_by') +@extend_schema_view( + get=extend_schema( + tags=['Prise en charge & Collaboration'], + operation_id='field_reports_list', + summary="Lister les rapports de terrain", + description="Rapports de visite terrain, filtrés selon le rôle (staff → tous, " + "agent de terrain → les siens, sinon ceux de son organisation). " + "Filtre optionnel `?incident=` pour n'avoir que les rapports " + "des agents sur un incident donné. **Paginé** (`{count, next, previous, " + "results}`, 10/page par défaut) : pour un bouton « charger plus », " + "suivre `next` (ou `?page=`) jusqu'à `next: null`. Taille de lot via " + "`?page_size=`.", + parameters=[ + OpenApiParameter('incident', OpenApiTypes.UUID, OpenApiParameter.QUERY, + description="Ne renvoyer que les rapports de terrain de cet incident."), + ], + responses={200: FieldReportSerializer(many=True)}, + ), + post=extend_schema( + tags=['Prise en charge & Collaboration'], + operation_id='field_reports_create', + summary="Créer un rapport de terrain", + description="Crée un rapport de visite (multipart, champ `photo`). Réservé aux " + "agents de terrain pour un incident qui leur est assigné ; passe " + "l'assignation correspondante à l'état `reported`.", + request=FieldReportSerializer, + responses={ + 201: FieldReportSerializer, + 400: OpenApiResponse(description="Données invalides."), + 403: OpenApiResponse(description="Réservé aux agents de terrain assignés."), + 404: OpenApiResponse(description="Incident non trouvé."), + }, + ), +) class FieldReportListCreateView(generics.ListCreateAPIView): permission_classes = [IsAuthenticated] serializer_class = FieldReportSerializer + # Pagination « charger plus » : réponse {count, next, previous, results}. + # Le front suit `next` jusqu'à null. Taille de lot réglable via ?page_size=. + pagination_class = FieldReportPagination def get_queryset(self): user = self.request.user - qs = FieldReport.objects.select_related( - 'incident', 'agent', 'agent__organisation_member' - ).order_by('-visited_at') - - # Super admin / staff : accès total - if user.is_superuser or user.is_staff: + qs = FieldReport.objects.select_related('incident', 'agent').order_by('-visited_at') + # Portée par rôle (staff → tous ; agent terrain → les siens ; sinon ceux de son org) + if user.is_staff or user.is_superuser: pass elif user.org_role == ORG_ROLE_FIELD: - # Agent de terrain : ses propres rapports uniquement qs = qs.filter(agent=user) - elif user.organisation_member and user.org_role in [ORG_ROLE_ADMIN, ORG_ROLE_BUREAU]: - # Agent de bureau / admin d'organisation - incident_id = self.request.query_params.get('incident') or self.request.query_params.get('incident_id') - if incident_id: - try: - incident = Incident.objects.get(pk=incident_id) - # L'org a-t-elle un lien avec l'incident ? - is_owner = incident.user_id and incident.user_id.organisation_member == user.organisation_member - is_taker = incident.taken_by and incident.taken_by.organisation_member == user.organisation_member - has_collab = Collaboration.objects.filter( - incident=incident, - user__organisation_member=user.organisation_member, - status='accepted' - ).exists() - - if is_owner or is_taker or has_collab: - qs = qs.filter(incident=incident) - else: - return FieldReport.objects.none() - except (Incident.DoesNotExist, ValueError): - return FieldReport.objects.none() - else: - # Liste générale : rapports des agents de son org ou sur des incidents liés à son org - qs = qs.filter( - Q(agent__organisation_member=user.organisation_member) | - Q(incident__user_id__organisation_member=user.organisation_member) | - Q(incident__taken_by__organisation_member=user.organisation_member) | - Q(incident__collaboration__user__organisation_member=user.organisation_member, incident__collaboration__status='accepted') - ).distinct() + elif user.organisation_member: + qs = qs.filter(agent__organisation_member=user.organisation_member) else: return FieldReport.objects.none() - - # Filtre optionnel par incident (appliqué à tous les rôles restants qui ont pu passer) + # Filtre optionnel : les rapports de terrain d'UN incident donné. incident_id = self.request.query_params.get('incident') or self.request.query_params.get('incident_id') - if incident_id and (user.is_superuser or user.is_staff or user.org_role == ORG_ROLE_FIELD): + if incident_id: qs = qs.filter(incident_id=incident_id) - return qs def create(self, request, *args, **kwargs): @@ -566,10 +1075,24 @@ def create(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_201_CREATED) -@extend_schema( - description="Connexion d'un agent de terrain via son code agent.", - request={'application/json': {'type': 'object', 'properties': {'agent_code': {'type': 'string'}}}}, - responses={200: 'Tokens JWT'}, +@extend_schema_view( + post=extend_schema( + tags=['Authentification'], + operation_id='agent_login', + summary="Connexion agent (code agent)", + description="Connexion d'un agent de terrain via son `agent_code`. Retourne des tokens " + "JWT (access/refresh) et les infos de l'agent. Public.", + request=inline_serializer( + name='AgentCodeLoginRequest', + fields={'agent_code': drf_serializers.CharField()}, + ), + responses={ + 200: OpenApiResponse(description="Tokens JWT {access, refresh, user{...}}."), + 400: OpenApiResponse(description="agent_code manquant."), + 401: OpenApiResponse(description="Code agent invalide."), + 403: OpenApiResponse(description="Compte désactivé."), + }, + ), ) class AgentCodeLoginView(APIView): """POST /agent-login/ — login par agent_code, retourne des tokens JWT.""" @@ -613,9 +1136,24 @@ def post(self, request): }, status=status.HTTP_200_OK) -@extend_schema( - description="Basculer la visibilité publique d'un incident (is_public).", - responses={200: IncidentSerializer}, +@extend_schema_view( + post=extend_schema( + tags=['Incidents'], + operation_id='incidents_toggle_public', + summary="Basculer la visibilité d'un incident", + description="Bascule le drapeau `is_public` d'un incident. Réservé à l'admin/bureau de " + "l'organisation du reporter ou au staff. Sans corps de requête.", + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + request=None, + responses={ + 200: OpenApiResponse(description="{status, is_public, message}."), + 403: OpenApiResponse(description="Droits insuffisants."), + 404: OpenApiResponse(description="Incident non trouvé."), + }, + ), ) class ToggleIncidentPublicView(APIView): """POST /incidents//toggle-public/ — bascule is_public.""" @@ -636,7 +1174,7 @@ def post(self, request, incident_id): {"error": "Seul un admin ou agent de bureau peut modifier la visibilité."}, status=status.HTTP_403_FORBIDDEN, ) - elif not (user.is_staff or user.is_superuser): + elif not user.is_staff: return Response( {"error": "Vous n'avez pas les droits sur cet incident."}, status=status.HTTP_403_FORBIDDEN, @@ -652,94 +1190,104 @@ def post(self, request, incident_id): }, status=status.HTTP_200_OK) -@extend_schema( - description="Incidents résolus avec détails enrichis : prédiction, organisations impliquées, tâches groupées par organisation. " - "Le superadmin voit tout ; les autres voient uniquement les incidents liés à leur organisation.", - responses={200: IncidentResolvedSerializer(many=True)}, +@extend_schema_view( + get=extend_schema( + tags=['Référentiel & Statistiques'], + operation_id='incidents_resolved_list', + summary="Incidents résolus", + description="Liste paginée des incidents à l'état `resolved`. Public.", + parameters=[ + OpenApiParameter('page', OpenApiTypes.INT, OpenApiParameter.QUERY, + description="Numéro de page."), + OpenApiParameter('page_size', OpenApiTypes.INT, OpenApiParameter.QUERY, + description="Taille de page."), + ], + responses={200: IncidentGetSerializer(many=True)}, + ), + post=extend_schema(exclude=True), ) class IncidentResolvedAPIListView(generics.ListAPIView): + """GET /incidentResolved/ — incidents RÉSOLUS (resolved + resolved_definitive), + **sauf** ceux que mon organisation gère déjà : pris en charge en interne par mon + org OU signalés par mes agents de terrain (cf. ``org_own_work_q`` ; ce sont les + incidents résolus « des autres »). Authentification requise (filtre relatif à + l'org du demandeur). Paginé.""" permission_classes = [IsAuthenticated] - serializer_class = IncidentResolvedSerializer - pagination_class = CustomPageNumberPagination + serializer_class = IncidentGetSerializer + pagination_class = IncidentPagination def get_queryset(self): - user = self.request.user - qs = Incident.objects.filter(etat=RESOLVED) - - # Isolation par organisation. Le super admin voit tout. - if not user.is_superuser: - org = user.organisation_member - # Agents de terrain de TOUTES les organisations - all_field_agent_ids = User.objects.filter(org_role=ORG_ROLE_FIELD).values_list('id', flat=True) - # Incidents citoyens : déclarés par un user qui n'est agent de terrain d'aucune org - citizen_q = ~Q(user_id__in=all_field_agent_ids) - - if org: - agent_ids = org.members.filter(org_role=ORG_ROLE_FIELD).values_list('id', flat=True) - org_members = org.members.all() - # ses agents + citoyens + incidents pris en charge par l'org - qs = qs.filter( - Q(user_id__in=agent_ids) | citizen_q | Q(taken_by__in=org_members) - ) - # masque les incidents 'internal' appartenant à d'autres orgs - qs = qs.filter( - Q(take_in_charge_mode__isnull=True) | - ~Q(take_in_charge_mode__iexact='internal') | - Q(take_in_charge_mode__iexact='internal', taken_by__in=org_members) - ) - else: - # Utilisateur sans organisation : uniquement les incidents citoyens (publics) - qs = qs.filter(citizen_q) - - # Optimisation des requêtes pour le serializer enrichi - return qs.select_related( - 'user_id', 'category_id', 'taken_by__organisation_member' - ).prefetch_related( - 'collaboration_set__user__organisation_member', - 'tasks__created_by__organisation_member', - 'tasks__assigned_to', - ).order_by('-created_at') - -@extend_schema( - description="Endpoint allowing filtering retrieval incidents", - request=IncidentSerializer, - responses={200: IncidentSerializer, 404: "incident not found"}, + return ( + Incident.objects + .filter(etat__in=RESOLVED_ETATS) + .exclude(org_own_work_q(self.request.user)) + .exclude(is_deleted=True) + .select_related('user_id', 'category_id', 'taken_by__organisation_member') + .distinct() + .order_by('-created_at') + ) + +@extend_schema_view( + get=extend_schema( + tags=['Incidents'], + operation_id='incidents_filter_list', + summary="Liste filtrée pour la carte du dashboard", + description="Liste légère d'incidents pour la carte (IncidentMapSerializer, avec " + "`severity`). Le paramètre `scope` et le filtre de date `filter_type` se " + "combinent. `scope=mine` requiert l'authentification ; les autres scopes " + "sont publics.", + parameters=[ + OpenApiParameter( + 'scope', OpenApiTypes.STR, OpenApiParameter.QUERY, required=False, + enum=['all', 'mine', 'resolved', 'unresolved'], + description="all/tous (défaut) ; mine/interne = incidents que l'org du " + "demandeur traite (auth requise) ; resolved/resolu ; " + "unresolved/non_resolu.", + ), + OpenApiParameter( + 'filter_type', OpenApiTypes.STR, OpenApiParameter.QUERY, required=False, + enum=['today', 'yesterday', 'last_7_days', 'last_30_days', + 'this_month', 'last_month', 'custom_range'], + description="Fenêtre de date sur created_at. `custom_range` exige " + "`custom_start` et `custom_end`.", + ), + OpenApiParameter('custom_start', OpenApiTypes.DATE, OpenApiParameter.QUERY, + required=False, description="Début (YYYY-MM-DD) si filter_type=custom_range."), + OpenApiParameter('custom_end', OpenApiTypes.DATE, OpenApiParameter.QUERY, + required=False, description="Fin (YYYY-MM-DD) si filter_type=custom_range."), + ], + responses={200: IncidentMapSerializer(many=True)}, + ), ) class IncidentFilterView(APIView): - permission_classes = [IsAuthenticated] - def get(self, request, *args, **kwargs): filter_type = request.query_params.get('filter_type') custom_start = request.query_params.get('custom_start') custom_end = request.query_params.get('custom_end') + # scope : un seul URL pour la carte du dashboard (cf. #4). + # all/tous (défaut) | mine/interne | resolved/resolu | unresolved/non_resolu + scope = (request.query_params.get('scope') or 'all').lower() + + # Carte du dashboard : on ne tire que les colonnes scalaires utiles aux + # marqueurs (cf. IncidentMapSerializer) pour éviter le N+1 d'IncidentSerializer. + incidents = Incident.objects.only( + 'id', 'title', 'lattitude', 'longitude', 'etat', 'taken_by', + 'is_deleted', 'severity', 'created_at', + ) - incidents = Incident.objects.all() - - # Isolation par organisation. Le super admin voit tout. - user = request.user - if not user.is_superuser: - org = user.organisation_member - # Agents de terrain de TOUTES les organisations - all_field_agent_ids = User.objects.filter(org_role=ORG_ROLE_FIELD).values_list('id', flat=True) - # Incidents citoyens : déclarés par un user qui n'est agent de terrain d'aucune org - citizen_q = ~Q(user_id__in=all_field_agent_ids) - - if org: - agent_ids = org.members.filter(org_role=ORG_ROLE_FIELD).values_list('id', flat=True) - org_members = org.members.all() - # ses agents + citoyens + incidents pris en charge par l'org - incidents = incidents.filter( - Q(user_id__in=agent_ids) | citizen_q | Q(taken_by__in=org_members) - ) - # masque les incidents 'internal' appartenant à d'autres orgs - incidents = incidents.filter( - Q(take_in_charge_mode__isnull=True) | - ~Q(take_in_charge_mode__iexact='internal') | - Q(take_in_charge_mode__iexact='internal', taken_by__in=org_members) - ) + # --- Scope (orthogonal au filtre de date ci-dessous) --- + resolved_states = [RESOLVED, RESOLVED_DEFINITIVE, IN_VALIDATION] + if scope in ('mine', 'interne', 'internal'): + from ..services.incident_orgs import org_acting_q + if request.user and request.user.is_authenticated: + incidents = incidents.filter(org_acting_q(request.user)).distinct() else: - # Utilisateur sans organisation : uniquement les incidents citoyens (publics) - incidents = incidents.filter(citizen_q) + incidents = incidents.none() + elif scope in ('resolved', 'resolu', 'résolu'): + incidents = incidents.filter(etat__in=resolved_states) + elif scope in ('unresolved', 'non_resolu', 'non-resolu', 'active'): + incidents = incidents.exclude(etat__in=resolved_states) + # scope all/tous : aucun filtre if filter_type == 'today': incidents = incidents.filter(created_at__date=timezone.now().date()) @@ -757,31 +1305,143 @@ def get(self, request, *args, **kwargs): elif filter_type == 'custom_range' and custom_start and custom_end: incidents = incidents.filter(created_at__date__range=[custom_start, custom_end]) - # Serializer léger pour la carte : aucun champ relationnel imbriqué -> 1 seule requête - incidents = incidents.only( - 'id', 'title', 'zone', 'lattitude', 'longitude', - 'etat', 'category_id', 'created_at', 'progress', 'take_in_charge_mode', - ) serializer = IncidentMapSerializer(incidents, many=True) return Response(serializer.data, status=status.HTTP_200_OK) -@extend_schema( - description="Endpoint allowing retrieval an incident not resolved.", - request=IncidentSerializer, - responses={200: IncidentSerializer, 404: "Incident not found"}, + +@extend_schema_view( + get=extend_schema( + tags=['Référentiel & Statistiques'], + operation_id='incidents_dashboard_stats', + summary="Statistiques du dashboard", + description="Agrégats KPI calculés côté base pour le dashboard (authentification " + "requise) : totaux, répartition par localité, catégories, gravité et " + "activité récente. Ne renvoie jamais la liste complète des incidents.", + responses={200: OpenApiResponse(description=( + "{total_alerts, active_responses, resolved_incidents, by_zone[{name,count}], " + "by_category[{name,count,percentage}], by_severity{high/medium/low:{count," + "percentage}}, recent_activity[{id,title,etat,zone,created_at,taken_by}]}." + ))}, + ), ) -class IncidentNotResolvedAPIListView(generics.CreateAPIView): - permission_classes = () - queryset = Incident.objects.all() - serializer_class = IncidentSerializer +class IncidentDashboardStatsView(APIView): + """Statistiques agrégées pour le dashboard (cartes KPI + widgets Par Localité / + Top catégories / Gravité + activité récente). - def get(self, request, format=None): - items = Incident.objects.filter(etat="declared").select_related('user_id', 'category_id').order_by('pk') - paginator = CustomPageNumberPagination() - result_page = paginator.paginate_queryset(items, request) - serializer = IncidentGetSerializer(result_page, many=True) - return paginator.get_paginated_response(serializer.data) + Tout est calculé côté BDD via GROUP BY (quelques requêtes), on ne renvoie + jamais toute la liste d'incidents au client pour qu'il agrège lui-même. + """ + permission_classes = [IsAuthenticated] + def get(self, request, *args, **kwargs): + qs = Incident.objects.filter(is_deleted=False) + + total = qs.count() + resolved = qs.filter(etat__in=[RESOLVED, RESOLVED_DEFINITIVE]).count() + active = qs.filter( + etat__in=[TAKEN, RESOLUTION_PREPARED, IN_VALIDATION, 'in_progress'] + ).count() + + # Par localité : top 5 zones (zone normalisée/trim pour éviter les doublons + # « Bamako » vs « Bamako » avec espace en trop) + from django.db.models.functions import Trim + by_zone = [ + {'name': row['z'], 'count': row['count']} + for row in (qs.exclude(zone__isnull=True).exclude(zone='') + .values(z=Trim('zone')).annotate(count=Count('id')) + .order_by('-count')) + if row['z'] + ][:5] + + # Top catégories (M2M category_ids) : pourcentage du total des incidents + by_category = [ + {'name': row['category_ids__name'], 'count': row['count'], + 'percentage': round(row['count'] * 100 / total) if total else 0} + for row in (qs.filter(category_ids__isnull=False) + .values('category_ids__name').annotate(count=Count('id')) + .order_by('-count')[:5]) + ] + + # Gravité : répartition high/medium/low en pourcentage (somme ≈ 100) + sev = {row['severity']: row['count'] + for row in qs.values('severity').annotate(count=Count('id'))} + sev_total = sum(sev.get(l, 0) for l in ('high', 'medium', 'low')) or 1 + by_severity = { + level: {'count': sev.get(level, 0), + 'percentage': round(sev.get(level, 0) * 100 / sev_total)} + for level in ('high', 'medium', 'low') + } + + # Activité récente : 8 incidents les plus récents + recent_activity = list( + qs.order_by('-created_at')[:8] + .values('id', 'title', 'etat', 'zone', 'created_at', 'taken_by') + ) + + return Response({ + 'total_alerts': total, + 'active_responses': active, + 'resolved_incidents': resolved, + 'by_zone': by_zone, + 'by_category': by_category, + 'by_severity': by_severity, + 'recent_activity': recent_activity, + }, status=status.HTTP_200_OK) + + +@extend_schema_view( + get=extend_schema( + tags=['Référentiel & Statistiques'], + operation_id='incidents_not_resolved_list', + summary="Incidents non résolus", + description="Liste paginée des incidents à l'état `declared`. Public.", + parameters=[ + OpenApiParameter('page', OpenApiTypes.INT, OpenApiParameter.QUERY, + description="Numéro de page."), + OpenApiParameter('page_size', OpenApiTypes.INT, OpenApiParameter.QUERY, + description="Taille de page."), + ], + responses={200: IncidentGetSerializer(many=True)}, + ), + post=extend_schema(exclude=True), +) +class IncidentNotResolvedAPIListView(generics.ListAPIView): + """GET /incidentNotResolved/ — incidents NON RÉSOLUS (tout sauf resolved / + resolved_definitive : declared, taken_into_account, in_progress, in_validation — + auparavant la vue ne renvoyait QUE 'declared', d'où des incidents manquants), + **sauf** ceux que mon organisation gère déjà (pris en charge en interne par mon + org OU signalés par mes agents, cf. ``org_own_work_q``). Auth requise. Paginé.""" + permission_classes = [IsAuthenticated] + serializer_class = IncidentGetSerializer + pagination_class = IncidentPagination + + def get_queryset(self): + return ( + Incident.objects + .exclude(etat__in=RESOLVED_ETATS) + .exclude(org_own_work_q(self.request.user)) + .exclude(is_deleted=True) + .select_related('user_id', 'category_id', 'taken_by__organisation_member') + .distinct() + .order_by('-created_at') + ) + +@extend_schema_view( + get=extend_schema( + tags=['Référentiel & Statistiques'], + operation_id='incidents_by_month_list', + summary="Incidents par mois (année courante)", + description="Incidents de l'année courante, optionnellement filtrés sur un mois. Public.", + parameters=[ + OpenApiParameter('month', OpenApiTypes.INT, OpenApiParameter.QUERY, required=False, + description="Numéro de mois (1-12) ; toute l'année si omis."), + ], + responses={ + 200: OpenApiResponse(description="{status, message, data:[Incident...]}."), + 400: OpenApiResponse(description="Paramètre month invalide."), + }, + ), +) class IncidentByMonthAPIListView(generics.ListAPIView): permission_classes = () queryset = Incident.objects.all() @@ -807,10 +1467,21 @@ def list(self, request, *args, **kwargs): }, status=status.HTTP_200_OK) -@extend_schema( - description="Endpoint allowing retrieval of incident by month on zone.", - request=IncidentSerializer, - responses={200: IncidentSerializer, 404: "Incident not found"}, +@extend_schema_view( + get=extend_schema( + tags=['Référentiel & Statistiques'], + operation_id='incidents_by_month_zone_list', + summary="Incidents par mois pour une zone", + description="Répartition mensuelle (total/résolus/non résolus) des incidents " + "d'une zone sur l'année courante. Public.", + parameters=[ + OpenApiParameter('zone', OpenApiTypes.STR, OpenApiParameter.PATH, + description="Nom de la zone."), + ], + responses={200: OpenApiResponse( + description="{status, message, data:[{month,total,resolved,unresolved}]}.")}, + ), + post=extend_schema(exclude=True), ) class IncidentByMonthByZoneAPIView(generics.CreateAPIView): permission_classes = ( @@ -843,10 +1514,17 @@ def get(self, request, format=None, **kwargs): "data": listData }, status=status.HTTP_200_OK) -@extend_schema( - description="Endpoint allowing retrieval of incident on week.", - request=IncidentSerializer, - responses={200: IncidentSerializer, 404: "Incident not found"}, +@extend_schema_view( + get=extend_schema( + tags=['Référentiel & Statistiques'], + operation_id='incidents_on_week_list', + summary="Incidents de la semaine (par jour)", + description="Répartition jour par jour (total/résolus/non résolus) des incidents " + "de la dernière semaine. Public.", + responses={200: OpenApiResponse( + description="{status, message, data:[{day,total,resolved,unresolved}]}.")}, + ), + post=extend_schema(exclude=True), ) class IncidentOnWeekAPIListView(generics.CreateAPIView): permission_classes = ( @@ -878,10 +1556,21 @@ def get(self, request, format=None): "data": listData }, status=status.HTTP_200_OK) -@extend_schema( - description="Endpoint allowing retrieval of incident on week by zone.", - request=IncidentSerializer, - responses={200: IncidentSerializer, 404: "Incident not found"}, +@extend_schema_view( + get=extend_schema( + tags=['Référentiel & Statistiques'], + operation_id='incidents_on_week_zone_list', + summary="Incidents de la semaine pour une zone (par jour)", + description="Répartition jour par jour (total/résolus/non résolus) des incidents " + "d'une zone sur la dernière semaine. Public.", + parameters=[ + OpenApiParameter('zone', OpenApiTypes.STR, OpenApiParameter.PATH, + description="Nom de la zone."), + ], + responses={200: OpenApiResponse( + description="{status, message, data:[{day,total,resolved,unresolved}]}.")}, + ), + post=extend_schema(exclude=True), ) class IncidentByWeekByZoneAPIView(generics.CreateAPIView): permission_classes = ( @@ -914,15 +1603,22 @@ def get(self, request, format=None, **kwargs): "data": listData }, status=status.HTTP_200_OK) -@extend_schema( - description="Endpoint allowing retrieval, updating, and deletion of a category.", - request=CategorySerializer, - responses={200: CategorySerializer, 404: "category not found"}, -) - -@extend_schema( - description="Endpoint for search incidents", - responses={200: IncidentSerializer(many=True)}, +@extend_schema_view( + get=extend_schema( + tags=['Incidents'], + operation_id='incidents_search_list', + summary="Rechercher des incidents", + description="Recherche d'incidents dont le titre ou la description contient " + "`search_term`. Public.", + parameters=[ + OpenApiParameter('search_term', OpenApiTypes.STR, OpenApiParameter.QUERY, + required=True, description="Terme recherché (titre/description)."), + ], + responses={ + 200: IncidentSerializer(many=True), + 400: OpenApiResponse(description="Paramètre 'search_term' manquant."), + }, + ), ) class IncidentSearchView(generics.ListAPIView): def get(self, request): @@ -937,9 +1633,29 @@ def get(self, request): serializer = IncidentSerializer(results, many=True) return Response(serializer.data, status=status.HTTP_200_OK) -@extend_schema( - description="Endpoint to change incident status", - responses={200: UserActionSerializer()}, +@extend_schema_view( + post=extend_schema( + tags=['Incidents'], + operation_id='incidents_handle_status', + summary="Faire avancer le statut d'un incident (legacy)", + description="Avance l'état d'un incident dans l'ordre `declared` → " + "`taken_into_account` → `resolved`, et journalise une UserAction. " + "Authentification requise.", + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + request=inline_serializer( + name='IncidentHandleStatusRequest', + fields={'action': drf_serializers.ChoiceField( + choices=['taken_into_account', 'resolved'])}, + ), + responses={ + 200: OpenApiResponse(description="{status, message, user, action}."), + 400: OpenApiResponse(description="Action invalide ou ordre des états non respecté."), + 404: OpenApiResponse(description="Incident non trouvé."), + }, + ), ) class HandleIncidentView(APIView): permission_classes = [IsAuthenticated] @@ -966,10 +1682,10 @@ def post(self, request, incident_id, format=None): if action == "taken_into_account": incident.etat = "taken_into_account" incident.taken_by = user - action_message = f"took incident {incident_id} into account" + action_message = f"a pris en charge l'incident «{incident.title}»." elif action == "resolved": incident.etat = "resolved" - action_message = f"resolved incident {incident_id}" + action_message = f"a résolu l'incident «{incident.title}»." incident.save() @@ -983,9 +1699,22 @@ def post(self, request, incident_id, format=None): "action": action_data }, status=status.HTTP_200_OK) -@extend_schema( - description="Endpoint to get user who took incident into account", - responses={200: UserSerializer()}, +@extend_schema_view( + get=extend_schema( + tags=['Incidents'], + operation_id='incidents_taken_by_user', + summary="Utilisateur ayant pris en charge l'incident", + description="Renvoie l'utilisateur (`taken_by`) ayant pris l'incident en charge. " + "Authentification requise.", + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + responses={ + 200: OpenApiResponse(description="{status, user}."), + 404: OpenApiResponse(description="Incident non trouvé ou non pris en charge."), + }, + ), ) class IncidentUserView(APIView): permission_classes = [IsAuthenticated] @@ -1006,9 +1735,13 @@ def get(self, request, incident_id, format=None): }, status=status.HTTP_200_OK) -@extend_schema( +@extend_schema_view( + post=extend_schema( + tags=['Prise en charge & Collaboration'], + operation_id='incidents_take_in_charge', + summary="Prendre en charge un incident", description=( - "Prise en charge d'un incident.\n\n" + "Prise en charge d'un incident (org_admin requis).\n\n" "Body JSON :\n" "- mode = 'internal' : prise en charge interne (visible uniquement par les membres de l'organisation).\n" "- mode = 'collaborative' : prise en charge collaborative ouverte aux autres organisations. " @@ -1018,11 +1751,30 @@ def get(self, request, incident_id, format=None): "- contributor est auto-accepté tant qu'aucun leader n'est désigné ; sinon il passe en pending.\n" "- leader définit incident.taken_by et remet les contributors déjà acceptés en pending pour validation." ), - responses={200: IncidentSerializer}, + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + request=inline_serializer( + name='IncidentTakeInChargeRequest', + fields={ + 'mode': drf_serializers.ChoiceField(choices=['internal', 'collaborative']), + 'role': drf_serializers.ChoiceField( + choices=['leader', 'contributor', 'observer'], required=False), + }, + ), + responses={ + 200: OpenApiResponse(description="{status, message, mode, data:Incident, collaboration}."), + 400: OpenApiResponse(description="mode/role invalide, incident déjà pris en charge ou clôturé, " + "collaboration en doublon, leader déjà désigné."), + 403: OpenApiResponse(description="Droits insuffisants (admin d'organisation requis)."), + 404: OpenApiResponse(description="Incident non trouvé."), + }, + ), ) class TakeInChargeView(APIView): """POST /incidents//take_in_charge/""" - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, IsOrgAdmin] def post(self, request, incident_id): try: @@ -1059,19 +1811,22 @@ def post(self, request, incident_id): incident.taken_by = request.user incident.etat = 'taken_into_account' incident.take_in_charge_mode = 'internal' - incident.save(update_fields=['taken_by', 'etat', 'take_in_charge_mode']) - - collaboration, created = Collaboration.objects.get_or_create( + incident.taken_in_charge_at = timezone.now() + # Anti-gel (spec T3) : (re)prise en compte => réarmer les avertissements. + incident.antigel_warned_75 = False + incident.antigel_warned_90 = False + incident.save(update_fields=[ + 'taken_by', 'etat', 'take_in_charge_mode', 'taken_in_charge_at', + 'antigel_warned_75', 'antigel_warned_90', + ]) + + collaboration, _ = Collaboration.objects.get_or_create( incident=incident, user=request.user, defaults={'role': COLLAB_ROLE_LEADER, 'status': 'accepted'}, ) - if not created: - collaboration.role = COLLAB_ROLE_LEADER - collaboration.status = 'accepted' - collaboration.save(update_fields=['role', 'status']) - action_message = f"took incident {incident_id} into account in internal mode" + action_message = f"a pris en charge l'incident «{incident.title}» en mode interne." UserAction.objects.create(user=request.user, action=action_message) return Response({ @@ -1117,10 +1872,18 @@ def post(self, request, incident_id): ) incident.taken_by = request.user + update_fields = ['taken_by', 'etat', 'take_in_charge_mode'] if incident.etat == 'declared': incident.etat = 'taken_into_account' + incident.taken_in_charge_at = timezone.now() + # Anti-gel (spec T3) : (re)prise en compte => réarmer les avertissements. + incident.antigel_warned_75 = False + incident.antigel_warned_90 = False + update_fields += [ + 'taken_in_charge_at', 'antigel_warned_75', 'antigel_warned_90', + ] incident.take_in_charge_mode = 'collaborative' - incident.save(update_fields=['taken_by', 'etat', 'take_in_charge_mode']) + incident.save(update_fields=update_fields) collaboration = Collaboration.objects.create( incident=incident, @@ -1136,7 +1899,7 @@ def post(self, request, incident_id): status='accepted', ).exclude(user=request.user).update(status='pending') - action_message = f"took incident {incident_id} into account as leader (collaborative)" + action_message = f"a pris en charge l'incident «{incident.title}» en tant que leader (mode collaboratif)." UserAction.objects.create(user=request.user, action=action_message) return Response({ @@ -1166,7 +1929,8 @@ def post(self, request, incident_id): incident.take_in_charge_mode = 'collaborative' incident.save(update_fields=['take_in_charge_mode']) - action_message = f"joined incident {incident_id} as {role} ({collab_status})" + role_fr = {'contributor': 'contributeur', 'observer': 'observateur', 'leader': 'leader'}.get(role, role) + action_message = f"a rejoint l'incident «{incident.title}» en tant que {role_fr}." UserAction.objects.create(user=request.user, action=action_message) return Response({ @@ -1180,14 +1944,36 @@ def post(self, request, incident_id): }, status=status.HTTP_200_OK) -@extend_schema( - description="Clôturer un incident. Requiert resolution_start_date et resolution_end_date. " - "Toutes les tâches doivent être terminées (done ou failed).", - responses={200: IncidentSerializer}, +@extend_schema_view( + post=extend_schema( + tags=['Prise en charge & Collaboration'], + operation_id='incidents_close', + summary="Clôturer un incident", + description="Clôture un incident (état → `resolved`). Réservé à l'admin/leader de " + "l'incident. Requiert `resolution_start_date` et `resolution_end_date` ; " + "toutes les tâches doivent être terminées (done ou failed).", + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + request=inline_serializer( + name='IncidentCloseRequest', + fields={ + 'resolution_start_date': drf_serializers.DateField(), + 'resolution_end_date': drf_serializers.DateField(), + }, + ), + responses={ + 200: IncidentSerializer, + 400: OpenApiResponse(description="Dates manquantes, tâches non terminées ou incident déjà clôturé."), + 403: OpenApiResponse(description="Droits insuffisants."), + 404: OpenApiResponse(description="Incident non trouvé."), + }, + ), ) class CloseIncidentView(APIView): """POST /incidents//close/""" - permission_classes = [IsAuthenticated, IsIncidentLeader] + permission_classes = [IsAuthenticated, IsOrgAdmin, IsIncidentLeader] def post(self, request, incident_id): try: @@ -1228,6 +2014,647 @@ def post(self, request, incident_id): return Response(serializer.data, status=status.HTTP_200_OK) +# ============================================================================ +# Phase 4 — flux de résolution (additif, ne touche pas la voie legacy `close/`) +# taken_into_account ──prepare-resolution──▶ resolution_prepared +# resolution_prepared ──return-for-completion──▶ taken_into_account +# taken_into_account / resolution_prepared ──declare-resolved──▶ in_validation +# in_validation ──validate-resolution──▶ resolved_definitive +# in_validation ──reject-resolution(motif)──▶ taken_into_account +# ============================================================================ + +@extend_schema_view( + post=extend_schema( + description=( + "Préparer une résolution (Agent de bureau / Admin « monte le dossier »). " + "Exige l'état 'taken_into_account'. Passe l'incident en 'resolution_prepared'." + ), + tags=['Prise en charge & Collaboration'], + operation_id='incidents_prepare_resolution', + summary="Préparer une résolution", + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + request=None, + responses={ + 200: IncidentGetSerializer, + 400: OpenApiResponse(description="État invalide (doit être « Pris en compte »)."), + 404: OpenApiResponse(description="Incident non trouvé."), + }, + ), +) +class PrepareResolutionView(APIView): + """POST /incidents//prepare-resolution/ — org_admin OU bureau_agent.""" + permission_classes = [IsAuthenticated, IsOrgOperative] + + def post(self, request, incident_id): + try: + incident = Incident.objects.get(pk=incident_id) + except Incident.DoesNotExist: + return Response({"error": "Incident non trouvé."}, status=status.HTTP_404_NOT_FOUND) + + if incident.etat != TAKEN: + return Response( + {"error": "Une résolution ne peut être préparée que pour un incident « Pris en compte »."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + incident.etat = RESOLUTION_PREPARED + incident.resolution_submitted_by = request.user + incident.resolution_submitted_at = timezone.now() + incident.save(update_fields=['etat', 'resolution_submitted_by', 'resolution_submitted_at']) + + return Response(IncidentGetSerializer(incident).data, status=status.HTTP_200_OK) + + +@extend_schema_view( + post=extend_schema( + description=( + "Renvoyer un dossier de résolution pour complément (Admin). " + "Exige l'état 'resolution_prepared'. Repasse l'incident en 'taken_into_account'." + ), + tags=['Prise en charge & Collaboration'], + operation_id='incidents_return_for_completion', + summary="Renvoyer pour complément", + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + request=None, + responses={ + 200: IncidentGetSerializer, + 400: OpenApiResponse(description="État invalide (doit être « Résolution préparée »)."), + 404: OpenApiResponse(description="Incident non trouvé."), + }, + ), +) +class ReturnForCompletionView(APIView): + """POST /incidents//return-for-completion/ — org_admin uniquement.""" + permission_classes = [IsAuthenticated, IsOrgAdmin] + + def post(self, request, incident_id): + try: + incident = Incident.objects.get(pk=incident_id) + except Incident.DoesNotExist: + return Response({"error": "Incident non trouvé."}, status=status.HTTP_404_NOT_FOUND) + + if incident.etat != RESOLUTION_PREPARED: + return Response( + {"error": "Seul un dossier « Résolution préparée » peut être renvoyé pour complément."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + incident.etat = TAKEN + incident.save(update_fields=['etat']) + + return Response(IncidentGetSerializer(incident).data, status=status.HTTP_200_OK) + + +@extend_schema_view( + post=extend_schema( + description=( + "Déclarer un incident résolu (Leader / Admin). Déclenche le contrôle Super Admin. " + "Exige l'état 'taken_into_account' ou 'resolution_prepared'. " + "Passe l'incident en 'in_validation' et fixe une échéance de validation à 72h." + ), + tags=['Prise en charge & Collaboration'], + operation_id='incidents_declare_resolved', + summary="Déclarer résolu", + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + request=None, + responses={ + 200: IncidentGetSerializer, + 400: OpenApiResponse(description="État invalide (doit être « Pris en compte » ou « Résolution préparée »)."), + 404: OpenApiResponse(description="Incident non trouvé."), + }, + ), +) +class DeclareResolvedView(APIView): + """POST /incidents//declare-resolved/ — org_admin uniquement.""" + permission_classes = [IsAuthenticated, IsOrgAdmin] + + def post(self, request, incident_id): + try: + incident = Incident.objects.get(pk=incident_id) + except Incident.DoesNotExist: + return Response({"error": "Incident non trouvé."}, status=status.HTTP_404_NOT_FOUND) + + if incident.etat not in (TAKEN, RESOLUTION_PREPARED): + return Response( + {"error": "Un incident ne peut être déclaré résolu que s'il est « Pris en compte » " + "ou « Résolution préparée »."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + incident.etat = IN_VALIDATION + incident.validation_deadline = timezone.now() + timedelta(hours=72) + incident.save(update_fields=['etat', 'validation_deadline']) + + return Response(IncidentGetSerializer(incident).data, status=status.HTTP_200_OK) + + +@extend_schema_view( + post=extend_schema( + tags=['Prise en charge & Collaboration'], + operation_id='incidents_disengage', + summary="Se désengager d'un incident", + description="Le leader (Admin d'organisation) se désengage d'un incident « Pris en " + "compte ». Seul → l'incident repasse « Déclaré » ; sinon le leadership est " + "proposé aux contributeurs (role=leader, status=pending).", + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + request=None, + responses={ + 200: OpenApiResponse(description="{message, incident:IncidentGetSerializer}."), + 400: OpenApiResponse(description="État invalide (doit être « Pris en compte »)."), + 403: OpenApiResponse(description="Réservé au leader / admin d'organisation."), + 404: OpenApiResponse(description="Incident non trouvé."), + }, + ), +) +class DisengageIncidentView(APIView): + """POST /incidents//disengage/ — le leader (Admin d'organisation) + se désengage de l'incident (spec §3, §6). + + - S'il était seul (aucun contributeur accepté) : l'incident repasse « Déclaré ». + - Sinon : le leadership est proposé aux contributeurs (leur collaboration passe + role=leader / status=pending) ; l'incident reste « Pris en compte ». + """ + permission_classes = [IsAuthenticated, IsOrgAdmin, IsIncidentLeader] + + def post(self, request, incident_id): + try: + incident = Incident.objects.get(pk=incident_id) + except Incident.DoesNotExist: + return Response({"error": "Incident non trouvé."}, status=status.HTTP_404_NOT_FOUND) + + if incident.etat != TAKEN: + return Response( + {"error": "On ne peut se désengager que d'un incident « Pris en compte »."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Contributeurs acceptés (hors le leader qui se désengage). + contributors = Collaboration.objects.filter( + incident=incident, + role=COLLAB_ROLE_CONTRIBUTOR, + status=COLLAB_STATUS_ACCEPTED, + ).exclude(user=request.user) + + # Le leader sortant : on clôt sa propre collaboration et on libère la prise en charge. + Collaboration.objects.filter(incident=incident, user=request.user).update( + status=COLLAB_STATUS_TERMINATED + ) + incident.taken_by = None + incident.take_in_charge_mode = None + incident.taken_in_charge_at = None + + if contributors.exists(): + # Leadership proposé aux contributeurs (ils doivent l'accepter). + contributors.update(role=COLLAB_ROLE_LEADER, status=COLLAB_STATUS_PENDING) + incident.save(update_fields=['taken_by', 'take_in_charge_mode', 'taken_in_charge_at']) + message = "Désengagement effectué : le leadership est proposé aux contributeurs." + else: + # Seul : l'incident redevient disponible ; les observateurs restants sont clôturés. + Collaboration.objects.filter( + incident=incident, status=COLLAB_STATUS_ACCEPTED, + ).update(status=COLLAB_STATUS_TERMINATED) + incident.etat = DECLARED + incident.save(update_fields=['etat', 'taken_by', 'take_in_charge_mode', 'taken_in_charge_at']) + message = "Désengagement effectué : l'incident repasse « Déclaré »." + + return Response( + {"message": message, "incident": IncidentGetSerializer(incident).data}, + status=status.HTTP_200_OK, + ) + + +@extend_schema_view( + post=extend_schema( + description=( + "Valider une résolution (Super Admin). " + "Exige l'état 'in_validation'. Passe l'incident en 'resolved_definitive'." + ), + tags=['Prise en charge & Collaboration'], + operation_id='incidents_validate_resolution', + summary="Valider une résolution", + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + request=None, + responses={ + 200: IncidentGetSerializer, + 400: OpenApiResponse(description="État invalide (doit être « en validation »)."), + 404: OpenApiResponse(description="Incident non trouvé."), + }, + ), +) +class ValidateResolutionView(APIView): + """POST /incidents//validate-resolution/ — super_admin uniquement.""" + permission_classes = [IsAuthenticated, IsSuperAdminRole] + + def post(self, request, incident_id): + try: + incident = Incident.objects.get(pk=incident_id) + except Incident.DoesNotExist: + return Response({"error": "Incident non trouvé."}, status=status.HTTP_404_NOT_FOUND) + + if incident.etat != IN_VALIDATION: + return Response( + {"error": "Seule une résolution « en validation » peut être validée."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + incident.etat = RESOLVED_DEFINITIVE + incident.save(update_fields=['etat']) + + # Spec §5 : à la résolution définitive, les collaborations encore actives + # passent en « Terminée ». Idempotent. + terminate_active_collaborations(incident) + + return Response(IncidentGetSerializer(incident).data, status=status.HTTP_200_OK) + + +@extend_schema_view( + post=extend_schema( + description=( + "Refuser une résolution (Super Admin). Motif obligatoire. " + "Exige l'état 'in_validation'. Repasse l'incident en 'taken_into_account' " + "et enregistre le motif dans rejection_reason." + ), + tags=['Prise en charge & Collaboration'], + operation_id='incidents_reject_resolution', + summary="Refuser une résolution", + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + request=inline_serializer( + name='IncidentRejectResolutionRequest', + fields={'motif': drf_serializers.CharField()}, + ), + responses={ + 200: IncidentGetSerializer, + 400: OpenApiResponse(description="Motif manquant ou état invalide (doit être « en validation »)."), + 404: OpenApiResponse(description="Incident non trouvé."), + }, + ), +) +class RejectResolutionView(APIView): + """POST /incidents//reject-resolution/ — super_admin uniquement.""" + permission_classes = [IsAuthenticated, IsSuperAdminRole] + + def post(self, request, incident_id): + try: + incident = Incident.objects.get(pk=incident_id) + except Incident.DoesNotExist: + return Response({"error": "Incident non trouvé."}, status=status.HTTP_404_NOT_FOUND) + + if incident.etat != IN_VALIDATION: + return Response( + {"error": "Seule une résolution « en validation » peut être refusée."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + motif = (request.data.get('motif') or '').strip() + if not motif: + return Response( + {"error": "Le motif de refus est obligatoire."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + incident.etat = TAKEN + incident.rejection_reason = motif + incident.save(update_fields=['etat', 'rejection_reason']) + + return Response(IncidentGetSerializer(incident).data, status=status.HTTP_200_OK) + + +@extend_schema_view( + post=extend_schema( + description=( + "« Signaler à mon Admin » (spec §4). Un agent de bureau recommande un " + "incident repéré à l'Admin de son organisation. Notifie chaque org_admin " + "de l'organisation du demandeur, avec un commentaire et un lien vers l'incident." + ), + tags=['Prise en charge & Collaboration'], + operation_id='incidents_report_to_admin', + summary="Signaler un incident à mon Admin", + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + request=inline_serializer( + name='IncidentReportToAdminRequest', + fields={'comment': drf_serializers.CharField(required=False)}, + ), + responses={ + 200: OpenApiResponse(description="{status, message, notified_admins, incident_id, link}."), + 400: OpenApiResponse(description="Vous n'êtes rattaché à aucune organisation."), + 404: OpenApiResponse(description="Incident non trouvé."), + }, + ), +) +class ReportToAdminView(APIView): + """POST /incidents//report-to-admin/ — bureau_agent uniquement.""" + permission_classes = [IsAuthenticated, IsAgentBureau] + + def post(self, request, incident_id): + try: + incident = Incident.objects.get(pk=incident_id) + except Incident.DoesNotExist: + return Response({"error": "Incident non trouvé."}, status=status.HTTP_404_NOT_FOUND) + + org = getattr(request.user, 'organisation_member', None) + if org is None: + return Response( + {"error": "Vous n'êtes rattaché à aucune organisation."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + comment = (request.data.get('comment') or '').strip() + admins = list(org.members.filter(org_role=ORG_ROLE_ADMIN)) + + # Lien direct vers la fiche incident (référence dans le message). + link = f"/incidents/{incident.id}" + reporter_name = request.user.get_full_name() or request.user.email + base_msg = ( + f"{reporter_name} vous signale l'incident " + f"« {incident.title or incident.zone} »." + ) + if comment: + base_msg = f"{base_msg} Commentaire : {comment}" + # Inclure le lien tout en respectant la limite de 255 caractères du champ. + message = f"{base_msg} ({link})"[:255] + + created = 0 + for admin in admins: + Notification.objects.create( + user=admin, + notif_type='incident_report', + message=message, + colaboration=None, + incident=incident, + ) + created += 1 + + return Response( + { + "status": "success", + "message": "Admin(s) notifié(s).", + "notified_admins": created, + "incident_id": incident.id, + "link": link, + }, + status=status.HTTP_200_OK, + ) + + +# ============================================================================ +# Phase 4 — assignation d'un incident à une ORGANISATION (Super Admin, spec §2/§3, T5) +# POST assign-to-organisation/ (Super Admin) → IncidentOrgAssignment pending, 72 h +# POST /accept/ (Admin de l'org cible) → accepted + engage l'incident +# POST /decline/ (Admin de l'org cible) → declined + motif +# Tacite (sans réponse à 72 h) → tasks.auto_accept_overdue_assignments (D4) +# ============================================================================ + +@extend_schema_view( + post=extend_schema( + description=( + "Assigner un incident à une ORGANISATION (Super Admin, spec §2). " + "Body {\"organisation_id\": ...}. Crée une IncidentOrgAssignment 'pending' " + "avec une échéance de 72 h, et notifie les Admins de l'organisation cible. " + "L'incident est assigné à l'organisation, jamais directement à un agent (T5)." + ), + tags=['Prise en charge & Collaboration'], + operation_id='incidents_assign_to_organisation', + summary="Assigner un incident à une organisation", + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + request=inline_serializer( + name='IncidentAssignToOrgRequest', + fields={'organisation_id': drf_serializers.UUIDField()}, + ), + responses={ + 201: IncidentOrgAssignmentSerializer, + 400: OpenApiResponse(description="organisation_id manquant/introuvable ou assignation déjà en attente."), + 403: OpenApiResponse(description="Réservé au Super Admin."), + 404: OpenApiResponse(description="Incident non trouvé."), + }, + ), +) +class AssignIncidentToOrganisationView(APIView): + """POST /incidents//assign-to-organisation/ — super_admin uniquement.""" + permission_classes = [IsAuthenticated, IsSuperAdminRole] + + def post(self, request, incident_id): + try: + incident = Incident.objects.get(pk=incident_id) + except Incident.DoesNotExist: + return Response({"error": "Incident non trouvé."}, status=status.HTTP_404_NOT_FOUND) + + organisation_id = request.data.get('organisation_id') + if not organisation_id: + return Response( + {"error": "organisation_id est obligatoire."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + organisation = Organisation.objects.get(pk=organisation_id) + except (Organisation.DoesNotExist, ValueError, TypeError): + return Response( + {"error": "Organisation introuvable."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Une seule assignation en attente à la fois pour un incident donné. + if IncidentOrgAssignment.objects.filter( + incident=incident, status=ORG_ASSIGNMENT_PENDING + ).exists(): + return Response( + {"error": "Une assignation est déjà en attente pour cet incident."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + assignment = IncidentOrgAssignment.objects.create( + incident=incident, + organisation=organisation, + assigned_by=request.user, + status=ORG_ASSIGNMENT_PENDING, + deadline=timezone.now() + timedelta(hours=72), + ) + + # Notifier les Admins de l'organisation cible (colaboration nullable). + admins = organisation.members.filter(org_role=ORG_ROLE_ADMIN) + message = ( + f"Le Super Admin vous a assigné l'incident " + f"« {incident.title or incident.zone} ». À accepter ou décliner sous 72 h." + )[:255] + for admin in admins: + Notification.objects.create(user=admin, notif_type='incident_assignment', + message=message, colaboration=None, incident=incident) + + return Response( + IncidentOrgAssignmentSerializer(assignment).data, + status=status.HTTP_201_CREATED, + ) + + +@extend_schema_view( + post=extend_schema( + description=( + "Accepter une assignation Super Admin (Admin de l'organisation cible, spec §3). " + "Exige status='pending' et que le demandeur soit Admin de l'organisation " + "assignée. Passe à 'accepted', fixe responded_at, et engage l'incident " + "('declared' → 'taken_into_account', taken_by + taken_in_charge_at)." + ), + tags=['Prise en charge & Collaboration'], + operation_id='incidents_org_assignment_accept', + summary="Accepter une assignation d'organisation", + parameters=[ + OpenApiParameter('pk', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'IncidentOrgAssignment."), + ], + request=None, + responses={ + 200: IncidentOrgAssignmentSerializer, + 400: OpenApiResponse(description="État invalide (doit être « en attente »)."), + 403: OpenApiResponse(description="Vous n'êtes pas Admin de l'organisation assignée."), + 404: OpenApiResponse(description="Assignation non trouvée."), + }, + ), +) +class AcceptOrgAssignmentView(APIView): + """POST /incident-org-assignments//accept/ — org_admin de l'org cible.""" + permission_classes = [IsAuthenticated, IsOrgAdmin] + + def post(self, request, pk): + try: + assignment = IncidentOrgAssignment.objects.select_related( + 'incident', 'organisation' + ).get(pk=pk) + except IncidentOrgAssignment.DoesNotExist: + return Response({"error": "Assignation non trouvée."}, status=status.HTTP_404_NOT_FOUND) + + if getattr(request.user, 'organisation_member', None) != assignment.organisation: + return Response( + {"error": "Vous n'êtes pas administrateur de l'organisation assignée."}, + status=status.HTTP_403_FORBIDDEN, + ) + + if assignment.status != ORG_ASSIGNMENT_PENDING: + return Response( + {"error": "Seule une assignation « en attente » peut être acceptée."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + assignment.status = ORG_ASSIGNMENT_ACCEPTED + assignment.responded_at = timezone.now() + assignment.save(update_fields=['status', 'responded_at']) + + # Engager l'incident (l'organisation en devient responsable / « Pris en compte »). + engage_incident(assignment.incident, request.user) + + return Response( + IncidentOrgAssignmentSerializer(assignment).data, + status=status.HTTP_200_OK, + ) + + +@extend_schema_view( + post=extend_schema( + description=( + "Décliner une assignation Super Admin (Admin de l'organisation cible, spec §3). " + "Body {\"motif\"/\"reason\"}. Exige status='pending' et que le demandeur soit " + "Admin de l'organisation assignée. Passe à 'declined', stocke decline_reason, " + "fixe responded_at." + ), + tags=['Prise en charge & Collaboration'], + operation_id='incidents_org_assignment_decline', + summary="Décliner une assignation d'organisation", + parameters=[ + OpenApiParameter('pk', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'IncidentOrgAssignment."), + ], + request=inline_serializer( + name='IncidentOrgAssignmentDeclineRequest', + fields={ + 'motif': drf_serializers.CharField(required=False), + 'reason': drf_serializers.CharField(required=False), + }, + ), + responses={ + 200: IncidentOrgAssignmentSerializer, + 400: OpenApiResponse(description="État invalide (doit être « en attente »)."), + 403: OpenApiResponse(description="Vous n'êtes pas Admin de l'organisation assignée."), + 404: OpenApiResponse(description="Assignation non trouvée."), + }, + ), +) +class DeclineOrgAssignmentView(APIView): + """POST /incident-org-assignments//decline/ — org_admin de l'org cible.""" + permission_classes = [IsAuthenticated, IsOrgAdmin] + + def post(self, request, pk): + try: + assignment = IncidentOrgAssignment.objects.select_related( + 'incident', 'organisation' + ).get(pk=pk) + except IncidentOrgAssignment.DoesNotExist: + return Response({"error": "Assignation non trouvée."}, status=status.HTTP_404_NOT_FOUND) + + if getattr(request.user, 'organisation_member', None) != assignment.organisation: + return Response( + {"error": "Vous n'êtes pas administrateur de l'organisation assignée."}, + status=status.HTTP_403_FORBIDDEN, + ) + + if assignment.status != ORG_ASSIGNMENT_PENDING: + return Response( + {"error": "Seule une assignation « en attente » peut être déclinée."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + reason = (request.data.get('motif') or request.data.get('reason') or '').strip() + + assignment.status = ORG_ASSIGNMENT_DECLINED + assignment.decline_reason = reason or None + assignment.responded_at = timezone.now() + assignment.save(update_fields=['status', 'decline_reason', 'responded_at']) + + return Response( + IncidentOrgAssignmentSerializer(assignment).data, + status=status.HTTP_200_OK, + ) + + +@extend_schema_view( + post=extend_schema( + tags=['Incidents'], + operation_id='incidents_bulk_delete', + summary="Suppression en masse (corbeille)", + description="Suppression logique de plusieurs incidents. Vérifie la propriété par " + "incident (Super Admin ou organisation propriétaire).", + request=inline_serializer( + name='IncidentBulkDeleteRequest', + fields={'incident_ids': drf_serializers.ListField(child=drf_serializers.UUIDField())}, + ), + responses={ + 200: OpenApiResponse(description="{deleted_ids, unauthorized_ids, not_found_ids, message}."), + 400: OpenApiResponse(description="incident_ids doit être une liste non vide."), + }, + ), +) class BulkDeleteIncidentsView(APIView): permission_classes = [IsAuthenticated] @@ -1254,7 +2681,8 @@ def post(self, request): if not incident.is_deleted: incident.is_deleted = True - incident.save(update_fields=['is_deleted']) + incident.deleted_at = timezone.now() + incident.save(update_fields=['is_deleted', 'deleted_at']) deleted_ids.append(incident_id) return Response({ @@ -1265,6 +2693,22 @@ def post(self, request): }, status=status.HTTP_200_OK) +@extend_schema_view( + post=extend_schema( + tags=['Incidents'], + operation_id='incidents_bulk_restore', + summary="Restauration en masse (corbeille)", + description="Restaure plusieurs incidents supprimés (is_deleted=False). Super Admin uniquement.", + request=inline_serializer( + name='IncidentBulkRestoreRequest', + fields={'incident_ids': drf_serializers.ListField(child=drf_serializers.UUIDField())}, + ), + responses={ + 200: OpenApiResponse(description="{restored_ids, not_found_ids, message}."), + 400: OpenApiResponse(description="incident_ids doit être une liste non vide."), + }, + ), +) class BulkRestoreIncidentsView(APIView): permission_classes = [IsAuthenticated, IsSuperAdmin] @@ -1294,6 +2738,23 @@ def post(self, request): }, status=status.HTTP_200_OK) +@extend_schema_view( + post=extend_schema( + tags=['Incidents'], + operation_id='incidents_bulk_force_delete', + summary="Suppression définitive en masse", + description="Suppression DÉFINITIVE (hard-delete, irréversible) de plusieurs incidents " + "déjà en corbeille. Super Admin uniquement.", + request=inline_serializer( + name='IncidentBulkForceDeleteRequest', + fields={'incident_ids': drf_serializers.ListField(child=drf_serializers.UUIDField())}, + ), + responses={ + 200: OpenApiResponse(description="{deleted_ids, not_found_ids, message}."), + 400: OpenApiResponse(description="incident_ids doit être une liste non vide."), + }, + ), +) class BulkForceDeleteIncidentsView(APIView): permission_classes = [IsAuthenticated, IsSuperAdmin] @@ -1322,9 +2783,14 @@ def post(self, request): }, status=status.HTTP_200_OK) -@extend_schema( - description="Lister les incidents supprimés (corbeille). Super Admin uniquement.", +@extend_schema_view( + get=extend_schema( + tags=['Incidents'], + operation_id='incidents_trash_list', + summary="Corbeille des incidents", + description="Liste les incidents supprimés (is_deleted=True). Super Admin uniquement.", responses={200: IncidentGetSerializer(many=True)}, + ), ) class TrashIncidentsView(generics.ListAPIView): """GET /incidents/trash/ — liste des incidents supprimés (is_deleted=True).""" @@ -1335,9 +2801,22 @@ def get_queryset(self): return Incident.objects.filter(is_deleted=True).select_related('user_id', 'category_id').order_by('-created_at') -@extend_schema( - description="Restaurer un incident supprimé (corbeille). Super Admin uniquement.", - responses={200: IncidentGetSerializer}, +@extend_schema_view( + post=extend_schema( + tags=['Incidents'], + operation_id='incidents_restore', + summary="Restaurer un incident", + description="Restaure un incident supprimé (is_deleted=False). Super Admin uniquement.", + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + request=None, + responses={ + 200: IncidentGetSerializer, + 404: OpenApiResponse(description="Incident non trouvé dans la corbeille."), + }, + ), ) class RestoreIncidentView(APIView): """POST /incidents//restore/ — restaurer un incident supprimé.""" @@ -1355,9 +2834,22 @@ def post(self, request, incident_id): return Response(serializer.data, status=status.HTTP_200_OK) -@extend_schema( - description="Récupérer l'analyse AI (Prediction) d'un incident.", - responses={200: PredictionSerializer, 404: "Pas de prédiction"}, +@extend_schema_view( + get=extend_schema( + tags=['Prédiction & IA'], + operation_id='predictions_incident_retrieve', + summary="Analyse IA d'un incident", + description="Récupère l'analyse IA (Prediction) d'un incident : statut et résultat. " + "Authentification requise.", + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + responses={ + 200: PredictionSerializer, + 404: OpenApiResponse(description="Incident non trouvé ou aucune prédiction."), + }, + ), ) class IncidentPredictionView(APIView): """GET /MapApi/incidents//prediction/ — état + résultat de l'analyse.""" @@ -1378,9 +2870,24 @@ def get(self, request, incident_id): return Response(PredictionSerializer(prediction).data, status=status.HTTP_200_OK) -@extend_schema( - description="Relancer l'analyse AI d'un incident (Super Admin uniquement).", - responses={202: PredictionSerializer, 404: "Incident non trouvé"}, +@extend_schema_view( + post=extend_schema( + tags=['Prédiction & IA'], + operation_id='predictions_incident_retry', + summary="Relancer l'analyse IA", + description="Relance la tâche Celery d'analyse IA d'un incident (remet la Prediction " + "à `pending`). Super Admin uniquement ; nécessite une photo.", + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + request=None, + responses={ + 202: PredictionSerializer, + 400: OpenApiResponse(description="L'incident n'a pas de photo."), + 404: OpenApiResponse(description="Incident non trouvé."), + }, + ), ) class RetryIncidentPredictionView(APIView): """POST /MapApi/incidents//prediction/retry/ — relance la task Celery.""" @@ -1410,39 +2917,139 @@ def post(self, request, incident_id): return Response(PredictionSerializer(prediction).data, status=status.HTTP_202_ACCEPTED) -@extend_schema( - description=( - "Chat LLM par incident. GET: récupère l'historique. " - "POST: envoie une question, le serveur appelle le model-deploy /chat " - "avec le contexte de la Prediction et stocke les deux messages (user, assistant)." +@extend_schema_view( + get=extend_schema( + tags=['Prédiction & IA'], + operation_id='incident_chat_history', + summary="Historique du chat IA d'un incident", + description=( + "Récupère l'historique des messages (user/assistant) du chat IA de " + "l'incident, propre à l'utilisateur connecté. Authentification requise.\n\n" + "Pagination curseur (chargement progressif) : sans `limit`, renvoie tout " + "l'historique en ordre chronologique. Avec `limit`, renvoie les N messages " + "les plus récents (ordre croissant) + `has_more` + `next_before`. Pour " + "charger les messages plus anciens (scroll vers le haut), rappeler avec " + "`?before=`." + ), + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + OpenApiParameter('limit', OpenApiTypes.INT, OpenApiParameter.QUERY, + description="Nombre de messages les plus récents à renvoyer (1–100). " + "Absent = tout l'historique."), + OpenApiParameter('before', OpenApiTypes.UUID, OpenApiParameter.QUERY, + description="Curseur : renvoie les messages ANTÉRIEURS à ce message " + "(son id). À utiliser avec `limit` pour le scroll vers le haut."), + ], + responses={ + 200: OpenApiResponse( + description="{history:[{id,role,content,created_at,user_id}], has_more, next_before} " + "(has_more/next_before présents uniquement quand `limit` est fourni)."), + 400: OpenApiResponse(description="limit invalide, ou 'before' invalide/introuvable."), + 404: OpenApiResponse(description="Incident non trouvé."), + }, + ), + post=extend_schema( + tags=['Prédiction & IA'], + operation_id='incident_chat_send', + summary="Envoyer un message au chat IA", + description="Envoie une question : le serveur appelle le service model-deploy `/chat` " + "avec le contexte de la Prediction, puis stocke les messages user et " + "assistant. Authentification requise.", + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + request=inline_serializer( + name='IncidentChatSendRequest', + fields={'message': drf_serializers.CharField()}, + ), + responses={ + 200: OpenApiResponse(description="{message, history}."), + 400: OpenApiResponse(description="message manquant ou prédiction absente/incomplète."), + 404: OpenApiResponse(description="Incident non trouvé."), + 502: OpenApiResponse(description="Erreur du service de chat IA."), + }, ), ) class IncidentChatView(APIView): """GET/POST /MapApi/incidents//chat/.""" permission_classes = [IsAuthenticated] + @staticmethod + def _serialize_message(m): + return { + "id": m.id, + "role": m.role, + "content": m.content, + "created_at": m.created_at, + "user_id": m.user_id, + } + def get(self, request, incident_id): try: incident = Incident.objects.get(pk=incident_id) except Incident.DoesNotExist: return Response({"error": "Incident non trouvé."}, status=status.HTTP_404_NOT_FOUND) - history = ( - incident.chat_messages - .filter(role__in=[CHAT_ROLE_USER, CHAT_ROLE_ASSISTANT]) - .order_by('created_at', 'id') + # Historique privé : limité à l'utilisateur connecté (chaque (user, incident) + # a sa propre conversation avec l'assistant IA). + base = incident.chat_messages.filter( + user=request.user, role__in=[CHAT_ROLE_USER, CHAT_ROLE_ASSISTANT] ) + + # --- Pagination curseur (chargement progressif du chat) --- + # Sans `limit` : historique complet, dans l'ordre chronologique (rétro-compatible). + # Avec `limit` : on renvoie les N messages les PLUS RÉCENTS (ordre chronologique + # croissant pour l'affichage), + `has_more` et `next_before`. Pour charger les + # messages plus anciens (scroll vers le haut), rappeler avec + # `?before=`. + limit_param = request.query_params.get('limit') + if limit_param is None: + history = base.order_by('created_at', 'id') + return Response( + {"history": [self._serialize_message(m) for m in history]}, + status=status.HTTP_200_OK, + ) + + try: + limit = int(limit_param) + except (TypeError, ValueError): + return Response({"detail": "limit doit être un entier."}, status=status.HTTP_400_BAD_REQUEST) + limit = max(1, min(limit, 100)) + + # On pagine du plus récent vers le plus ancien (keyset sur created_at + id). + qs = base.order_by('-created_at', '-id') + + before = request.query_params.get('before') + if before: + try: + cursor = base.filter(pk=before).values('created_at', 'id').first() + except (ValueError, ValidationError): + cursor = None + if not cursor: + return Response( + {"detail": "Paramètre 'before' invalide ou introuvable."}, + status=status.HTTP_400_BAD_REQUEST, + ) + qs = qs.filter( + Q(created_at__lt=cursor['created_at']) + | Q(created_at=cursor['created_at'], id__lt=cursor['id']) + ) + + # limit+1 pour savoir s'il reste des messages plus anciens. + rows = list(qs[:limit + 1]) + has_more = len(rows) > limit + rows = rows[:limit] + # rows est en ordre décroissant (récent -> ancien) : le plus ancien de la + # page est le curseur pour la page suivante (plus ancienne). + next_before = str(rows[-1].id) if (rows and has_more) else None + rows.reverse() # ordre chronologique croissant pour l'affichage return Response( { - "history": [ - { - "role": m.role, - "content": m.content, - "created_at": m.created_at, - "user_id": m.user_id, - } - for m in history - ] + "history": [self._serialize_message(m) for m in rows], + "has_more": has_more, + "next_before": next_before, }, status=status.HTTP_200_OK, ) @@ -1472,9 +3079,10 @@ def post(self, request, incident_id): status=status.HTTP_400_BAD_REQUEST, ) - # Build the conversation payload from the persisted history. + # Build the conversation payload from the persisted history — scoped to + # the current user so the LLM only sees this user's own thread. history_qs = incident.chat_messages.filter( - role__in=[CHAT_ROLE_USER, CHAT_ROLE_ASSISTANT] + user=request.user, role__in=[CHAT_ROLE_USER, CHAT_ROLE_ASSISTANT] ).order_by('created_at', 'id') messages = [{"role": item.role, "content": item.content} for item in history_qs] messages.append({"role": CHAT_ROLE_USER, "content": user_message}) @@ -1501,7 +3109,7 @@ def post(self, request, incident_id): ChatHistory.objects.create( incident=incident, - user=None, # assistant message has no user + user=request.user, # tie the reply to the asking user → private per-user thread role=CHAT_ROLE_ASSISTANT, content=assistant_response, ) @@ -1518,10 +3126,26 @@ def post(self, request, incident_id): ) -@extend_schema( - description="Connexion d'un agent de terrain via téléphone + PIN 4 chiffres.", - request={'application/json': {'type': 'object', 'properties': {'phone': {'type': 'string'}, 'pin': {'type': 'string'}}}}, - responses={200: 'Tokens JWT + must_change_pin flag'}, +@extend_schema_view( + post=extend_schema( + tags=['Authentification'], + operation_id='agent_pin_login', + summary="Connexion agent (téléphone + PIN)", + description="Connexion d'un agent de terrain via téléphone + PIN 4 chiffres. Retourne " + "des tokens JWT et `must_change_pin`. Public.", + request=inline_serializer( + name='AgentPinLoginRequest', + fields={ + 'phone': drf_serializers.CharField(), + 'pin': drf_serializers.CharField(), + }, + ), + responses={ + 200: OpenApiResponse(description="Tokens JWT + user{... must_change_pin}."), + 400: OpenApiResponse(description="phone/pin manquant ou aucun PIN configuré."), + 401: OpenApiResponse(description="Téléphone ou PIN invalide."), + }, + ), ) class AgentPinLoginView(APIView): """POST /agent-pin-login/ — login par téléphone + PIN, retourne tokens JWT.""" @@ -1577,10 +3201,27 @@ def post(self, request): ) -@extend_schema( - description="Changer le PIN de l'agent connecté. Requiert l'ancien PIN.", - request={'application/json': {'type': 'object', 'properties': {'old_pin': {'type': 'string'}, 'new_pin': {'type': 'string'}}}}, - responses={200: 'PIN changé avec succès'}, +@extend_schema_view( + post=extend_schema( + tags=['Authentification'], + operation_id='agent_change_pin', + summary="Changer son PIN (agent)", + description="Change le PIN de l'agent de terrain connecté. Requiert l'ancien PIN ; le " + "nouveau doit faire 4 chiffres et ne pas être trivial.", + request=inline_serializer( + name='AgentChangePinRequest', + fields={ + 'old_pin': drf_serializers.CharField(), + 'new_pin': drf_serializers.CharField(), + }, + ), + responses={ + 200: OpenApiResponse(description="PIN changé avec succès."), + 400: OpenApiResponse(description="Champs manquants, aucun PIN configuré ou nouveau PIN invalide/trivial."), + 401: OpenApiResponse(description="Ancien PIN invalide."), + 403: OpenApiResponse(description="Réservé aux agents de terrain."), + }, + ), ) class AgentChangePinView(APIView): """POST /agent/change-pin/ — changer son PIN (authentifié).""" @@ -1639,20 +3280,3 @@ def post(self, request): {"message": "PIN changé avec succès."}, status=status.HTTP_200_OK, ) - - -class GlobalImpactAPIView(APIView): - """ - GET /impact/global/ - Retourne les statistiques d'impact global basées sur les incidents valides et leurs prédictions. - Supporte les filtres : ?period=30d|90d|year & scope=resolved|ongoing & category=text & search=text - """ - permission_classes = [IsAuthenticated] - - def get(self, request, *args, **kwargs): - from ..services.impact_service import ImpactCalculatorService - - service = ImpactCalculatorService(user=request.user, query_params=request.query_params) - data = service.compute() - - return Response(data, status=status.HTTP_200_OK) diff --git a/Mapapi/views/indicateur.py b/Mapapi/views/indicateur.py index e666f8c2..77a29d19 100644 --- a/Mapapi/views/indicateur.py +++ b/Mapapi/views/indicateur.py @@ -2,15 +2,53 @@ from rest_framework import status, generics from rest_framework.response import Response -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import ( + extend_schema, extend_schema_view, OpenApiParameter, OpenApiResponse, + inline_serializer, +) +from drf_spectacular.types import OpenApiTypes +from rest_framework import serializers from ..serializer import * from .common import CustomPageNumberPagination -@extend_schema( - description="Endpoint allowing retrival, updating, and deletion of an indicator", - responses={200: IndicateurSerializer, 404: "indicator not found"} +@extend_schema_view( + get=extend_schema( + tags=['Catégories & Indicateurs'], + operation_id='indicators_retrieve', + summary="Détail d'un indicateur", + description="Retourne un indicateur par son identifiant. Accès public.", + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant de l'indicateur")], + request=None, + responses={200: IndicateurSerializer, 404: OpenApiResponse(description="Indicateur introuvable")}, + ), + put=extend_schema( + tags=['Catégories & Indicateurs'], + operation_id='indicators_update', + summary="Mettre à jour un indicateur", + description="Met à jour un indicateur existant. Accès public.", + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant de l'indicateur")], + request=IndicateurSerializer, + responses={ + 200: IndicateurSerializer, + 400: OpenApiResponse(description="Données invalides"), + 404: OpenApiResponse(description="Indicateur introuvable"), + }, + ), + delete=extend_schema( + tags=['Catégories & Indicateurs'], + operation_id='indicators_destroy', + summary="Supprimer un indicateur", + description="Supprime un indicateur. Accès public.", + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant de l'indicateur")], + request=None, + responses={ + 204: OpenApiResponse(description="Indicateur supprimé"), + 404: OpenApiResponse(description="Indicateur introuvable"), + }, + ), + post=extend_schema(exclude=True), ) class IndicateurAPIView(generics.CreateAPIView): permission_classes = ( @@ -45,9 +83,23 @@ def delete(self, request, id, format=None): item.delete() return Response(status=204) -@extend_schema( - description="Endpoint allowing retrival and creating of indicator", - responses={201: IndicateurSerializer, 400: "serializer error"} +@extend_schema_view( + get=extend_schema( + tags=['Catégories & Indicateurs'], + operation_id='indicators_list', + summary="Lister les indicateurs", + description="Retourne la liste paginée des indicateurs. Accès public.", + request=None, + responses={200: IndicateurSerializer(many=True)}, + ), + post=extend_schema( + tags=['Catégories & Indicateurs'], + operation_id='indicators_create', + summary="Créer un indicateur", + description="Crée un nouvel indicateur. Accès public.", + request=IndicateurSerializer, + responses={201: IndicateurSerializer, 400: OpenApiResponse(description="Données invalides")}, + ), ) class IndicateurAPIListView(generics.CreateAPIView): permission_classes = ( @@ -69,9 +121,31 @@ def post(self, request, format=None): return Response(serializer.data, status=201) return Response(serializer.errors, status=400) -@extend_schema( - description="Endpoint for retrieving statistics on incidents based on indicators.", - responses={200: "Statistics on incidents retrieved successfully."}, +@extend_schema_view( + get=extend_schema( + tags=['Catégories & Indicateurs'], + operation_id='indicators_incident_stats', + summary="Statistiques incidents par indicateur", + description="Retourne la répartition (nombre et pourcentage) des incidents par indicateur, sur l'ensemble des incidents. Accès public.", + request=None, + responses={200: inline_serializer( + name='IndicatorsIncidentStatsResponse', + fields={ + 'status': serializers.CharField(), + 'message': serializers.CharField(), + 'data': inline_serializer( + name='IndicatorsIncidentStatItem', + fields={ + 'indicateur': serializers.CharField(), + 'number': serializers.IntegerField(), + 'pourcentage': serializers.FloatField(), + }, + many=True, + ), + }, + )}, + ), + post=extend_schema(exclude=True), ) class IndicateurOnIncidentAPIListView(generics.CreateAPIView): permission_classes = ( @@ -99,9 +173,32 @@ def get(self, request, format=None): "data": listData }, status=status.HTTP_200_OK) -@extend_schema( - description="Endpoint for retrieving statistics on incidents based on indicators by zone.", - responses={200: "Statistics on incidents retrieved successfully."}, +@extend_schema_view( + get=extend_schema( + tags=['Catégories & Indicateurs'], + operation_id='indicators_incident_stats_by_zone', + summary="Statistiques incidents par indicateur (zone)", + description="Retourne la répartition des incidents par indicateur, restreinte à une zone. Accès public.", + parameters=[OpenApiParameter('zone', OpenApiTypes.STR, OpenApiParameter.PATH, description="Nom de la zone")], + request=None, + responses={200: inline_serializer( + name='IndicatorsIncidentStatsByZoneResponse', + fields={ + 'status': serializers.CharField(), + 'message': serializers.CharField(), + 'data': inline_serializer( + name='IndicatorsIncidentStatsByZoneItem', + fields={ + 'indicateur': serializers.CharField(), + 'number': serializers.IntegerField(), + 'pourcentage': serializers.FloatField(), + }, + many=True, + ), + }, + )}, + ), + post=extend_schema(exclude=True), ) class IndicateurOnIncidentByZoneAPIView(generics.CreateAPIView): permission_classes = ( @@ -130,9 +227,32 @@ def get(self, request, format=None, **kwargs): "data": listData }, status=status.HTTP_200_OK) -@extend_schema( - description="Endpoint for retrieving statistics on incidents based on indicators for a elu (organisation) user.", - responses={200: "Statistics on incidents for the user retrieved successfully."}, +@extend_schema_view( + get=extend_schema( + tags=['Catégories & Indicateurs'], + operation_id='indicators_incident_stats_by_elu', + summary="Statistiques incidents par indicateur (élu)", + description="Retourne la répartition des incidents par indicateur pour les incidents signalés par un utilisateur (élu/organisation). Accès public.", + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant de l'utilisateur (élu/organisation)")], + request=None, + responses={200: inline_serializer( + name='IndicatorsIncidentStatsByEluResponse', + fields={ + 'status': serializers.CharField(), + 'message': serializers.CharField(), + 'data': inline_serializer( + name='IndicatorsIncidentStatsByEluItem', + fields={ + 'indicateur': serializers.CharField(), + 'number': serializers.IntegerField(), + 'pourcentage': serializers.FloatField(), + }, + many=True, + ), + }, + )}, + ), + post=extend_schema(exclude=True), ) class IndicateurOnIncidentByEluAPIView(generics.CreateAPIView): permission_classes = ( diff --git a/Mapapi/views/message.py b/Mapapi/views/message.py index 9cda8a5e..71256622 100644 --- a/Mapapi/views/message.py +++ b/Mapapi/views/message.py @@ -1,5 +1,6 @@ """Message & response message endpoints + collaboration discussion messages.""" from django.conf import settings +from django.core.exceptions import ValidationError as DjangoValidationError from django.core.mail import EmailMultiAlternatives from django.db.models import Q from django.template.loader import render_to_string @@ -11,16 +12,54 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import ( + extend_schema, + extend_schema_view, + OpenApiParameter, + OpenApiResponse, + OpenApiExample, +) +from drf_spectacular.types import OpenApiTypes from ..serializer import * from ..models import Collaboration, Incident, COLLAB_ROLE_LEADER from .common import CustomPageNumberPagination -@extend_schema( - description="Endpoint allowing retrival, updating and deletion of Message.", - responses={200: MessageSerializer, 404: "message not found"}, +@extend_schema_view( + get=extend_schema( + tags=['Messages & Communauté'], + operation_id='messages_retrieve', + summary="Détail d'un message", + description="Retourne un message (objet, contenu, zone, communauté, élu destinataire) par son identifiant. Endpoint public.", + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant UUID du message.")], + responses={200: MessageGetSerializer, 404: OpenApiResponse(description="Message introuvable.")}, + ), + put=extend_schema( + tags=['Messages & Communauté'], + operation_id='messages_update', + summary="Modifier un message", + description="Met à jour un message existant. Endpoint public.", + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant UUID du message.")], + request=MessageSerializer, + responses={ + 200: MessageSerializer, + 400: OpenApiResponse(description="Données invalides."), + 404: OpenApiResponse(description="Message introuvable."), + }, + ), + delete=extend_schema( + tags=['Messages & Communauté'], + operation_id='messages_destroy', + summary="Supprimer un message", + description="Supprime définitivement un message. Endpoint public.", + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant UUID du message.")], + responses={ + 204: OpenApiResponse(description="Message supprimé."), + 404: OpenApiResponse(description="Message introuvable."), + }, + ), + post=extend_schema(exclude=True), ) class MessageAPIView(generics.CreateAPIView): permission_classes = ( @@ -55,9 +94,38 @@ def delete(self, request, id, format=None): item.delete() return Response(status=204) -@extend_schema( - description="Endpoint allowing retrieval and creating of message.", - responses={201: MessageSerializer, 400: "serializer error"}, +@extend_schema_view( + get=extend_schema( + tags=['Messages & Communauté'], + operation_id='messages_list', + summary="Lister les messages", + description="Retourne la liste paginée de tous les messages (questions adressées aux élus), triés par identifiant. Endpoint public.", + responses={200: MessageGetSerializer(many=True)}, + ), + post=extend_schema( + tags=['Messages & Communauté'], + operation_id='messages_create', + summary="Créer un message", + description="Crée un nouveau message. Si `user_id` (élu destinataire) est fourni, un e-mail de notification lui est envoyé. Endpoint public.", + request=MessageSerializer, + responses={ + 201: MessageSerializer, + 400: OpenApiResponse(description="Données invalides."), + }, + examples=[ + OpenApiExample( + 'Message à un élu', + value={ + 'objet': 'Problème de salubrité', + 'message': "Les déchets ne sont pas ramassés depuis deux semaines.", + 'zone': '3fa85f64-5717-4562-b3fc-2c963f66afa6', + 'communaute': '3fa85f64-5717-4562-b3fc-2c963f66afa6', + 'user_id': '3fa85f64-5717-4562-b3fc-2c963f66afa6', + }, + request_only=True, + ), + ], + ), ) class MessageAPIListView(generics.CreateAPIView): permission_classes = ( @@ -90,9 +158,18 @@ def post(self, request, format=None): return Response(serializer.data, status=201) return Response(serializer.errors, status=400) -@extend_schema( - description="Endpoint allowing retrivial of message by community.", - responses={200: MessageSerializer, 404: "message not found"}, +@extend_schema_view( + get=extend_schema( + tags=['Messages & Communauté'], + operation_id='messages_by_communaute', + summary="Messages d'une communauté", + description="Retourne les messages rattachés à une communauté. Endpoint public. Note : cette route partage le chemin `/message/` et est masquée à l'exécution par la liste des messages.", + responses={ + 200: MessageSerializer(many=True), + 404: OpenApiResponse(description="Aucun message."), + }, + ), + post=extend_schema(exclude=True), ) class MessageByComAPIView(generics.CreateAPIView): permission_classes = ( @@ -108,9 +185,19 @@ def get(self, request, id, format=None, **kwargs): except Message.DoesNotExist: return Response(status=404) -@extend_schema( - description="Endpoint allowing retrivial of message by zone.", - responses={200: MessageSerializer, 404: "message not found"}, +@extend_schema_view( + get=extend_schema( + tags=['Messages & Communauté'], + operation_id='messages_by_zone', + summary="Messages par zone", + description="Retourne les messages d'une zone, filtrés par le nom de la zone (`zone__name`). Endpoint public.", + parameters=[OpenApiParameter('zone', OpenApiTypes.STR, OpenApiParameter.PATH, description="Nom de la zone.")], + responses={ + 200: MessageByZoneSerializer(many=True), + 404: OpenApiResponse(description="Aucun message."), + }, + ), + post=extend_schema(exclude=True), ) class MessageByZoneAPIView(generics.CreateAPIView): permission_classes = ( @@ -128,10 +215,40 @@ def get(self, request, format=None, **kwargs): return Response(status=404) -@extend_schema( - description="Endpoint for managing response messages.", - request=ResponseMessageSerializer, - responses={201: ResponseMessageSerializer, 400: "Bad Request"}, +@extend_schema_view( + get=extend_schema( + tags=['Messages & Communauté'], + operation_id='response_msg_retrieve', + summary="Détail d'une réponse", + description="Retourne une réponse à un message (réponse d'un élu) par son identifiant. Endpoint public.", + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant UUID de la réponse.")], + responses={200: ResponseMessageSerializer, 404: OpenApiResponse(description="Réponse introuvable.")}, + ), + put=extend_schema( + tags=['Messages & Communauté'], + operation_id='response_msg_update', + summary="Modifier une réponse", + description="Met à jour une réponse existante. Endpoint public.", + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant UUID de la réponse.")], + request=ResponseMessageSerializer, + responses={ + 200: ResponseMessageSerializer, + 400: OpenApiResponse(description="Données invalides."), + 404: OpenApiResponse(description="Réponse introuvable."), + }, + ), + delete=extend_schema( + tags=['Messages & Communauté'], + operation_id='response_msg_destroy', + summary="Supprimer une réponse", + description="Supprime définitivement une réponse. Endpoint public.", + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant UUID de la réponse.")], + responses={ + 204: OpenApiResponse(description="Réponse supprimée."), + 404: OpenApiResponse(description="Réponse introuvable."), + }, + ), + post=extend_schema(exclude=True), ) class ResponseMessageAPIView(generics.CreateAPIView): permission_classes = ( @@ -166,10 +283,25 @@ def delete(self, request, id, format=None): item.delete() return Response(status=204) -@extend_schema( - description="Endpoint for managing response messages.", - request=ResponseMessageSerializer, - responses={201: ResponseMessageSerializer, 400: "Bad Request"}, +@extend_schema_view( + get=extend_schema( + tags=['Messages & Communauté'], + operation_id='response_msg_list', + summary="Lister les réponses", + description="Retourne la liste paginée de toutes les réponses aux messages, triées par identifiant. Endpoint public.", + responses={200: ResponseMessageSerializer(many=True)}, + ), + post=extend_schema( + tags=['Messages & Communauté'], + operation_id='response_msg_create', + summary="Créer une réponse", + description="Crée une réponse à un message. Endpoint public.", + request=ResponseMessageSerializer, + responses={ + 201: ResponseMessageSerializer, + 400: OpenApiResponse(description="Données invalides."), + }, + ), ) class ResponseMessageAPIListView(generics.CreateAPIView): permission_classes = ( @@ -209,9 +341,19 @@ def get(self, request, id, format=None): except ResponseMessage.DoesNotExist: return Response(status=404) -@extend_schema( - description="Endpoint for retrieving messages by user ID.", - responses={200: MessageGetSerializer(many=True), 404: "Not Found"}, +@extend_schema_view( + get=extend_schema( + tags=['Messages & Communauté'], + operation_id='messages_by_user', + summary="Messages d'un élu", + description="Retourne les messages adressés à un utilisateur (élu) donné. Endpoint public.", + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant UUID de l'utilisateur (élu).")], + responses={ + 200: MessageGetSerializer(many=True), + 404: OpenApiResponse(description="Aucun message."), + }, + ), + post=extend_schema(exclude=True), ) class MessageByUserAPIView(generics.CreateAPIView): permission_classes = ( @@ -228,6 +370,50 @@ def get(self, request, id, format=None): return Response(status=404) +@extend_schema_view( + get=extend_schema( + tags=['Messages & Communauté'], + operation_id='messages_discussion_list', + summary="Discussion d'un incident", + description=( + "Messages du chat de groupe d'un incident, triés par date. Réservé aux " + "collaborateurs acceptés (et au leader) ; en mode interne, réservé aux " + "membres de l'organisation propriétaire.\n\n" + "Pagination curseur (chargement progressif) : sans `limit`, renvoie tous " + "les messages (tableau, ordre chronologique). Avec `limit`, renvoie un " + "objet `{messages, has_more, next_before}` avec les N messages les plus " + "récents (ordre chronologique). Pour charger les plus anciens (scroll vers " + "le haut), rappeler avec `?before=`." + ), + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant UUID de l'incident."), + OpenApiParameter('limit', OpenApiTypes.INT, OpenApiParameter.QUERY, + description="Nombre de messages les plus récents à renvoyer (1–100). Absent = tous."), + OpenApiParameter('before', OpenApiTypes.UUID, OpenApiParameter.QUERY, + description="Curseur : messages ANTÉRIEURS à ce message (son id). À utiliser avec `limit`."), + ], + responses={ + 200: DiscussionMessageSerializer(many=True), + 400: OpenApiResponse(description="limit invalide, ou 'before' invalide/introuvable."), + 401: OpenApiResponse(description="Authentification requise."), + 404: OpenApiResponse(description="Incident introuvable ou accès non autorisé."), + }, + ), + post=extend_schema( + tags=['Messages & Communauté'], + operation_id='messages_discussion_create', + summary="Envoyer un message de discussion", + description="Publie un message dans le chat de groupe d'un incident (texte, audio et/ou pièce jointe en multipart). Réservé aux collaborateurs acceptés ; bloqué si l'incident est résolu. `recipient` est optionnel.", + parameters=[OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant UUID de l'incident.")], + request=DiscussionMessageSerializer, + responses={ + 201: DiscussionMessageSerializer, + 400: OpenApiResponse(description="Message vide ou incident résolu."), + 401: OpenApiResponse(description="Authentification requise."), + 404: OpenApiResponse(description="Incident introuvable ou accès non autorisé."), + }, + ), +) class DiscussionMessageView(generics.ListCreateAPIView): """ Espace de discussion (chat de groupe) d'un incident. @@ -312,7 +498,59 @@ def get_queryset(self): return DiscussionMessage.objects.filter( incident__id=incident_id ).order_by('created_at') - + + def list(self, request, *args, **kwargs): + # get_queryset() applique le contrôle d'accès (collaborateur accepté) et + # renvoie les messages de l'incident triés par date croissante. + base = self.get_queryset() + + # --- Pagination curseur (chargement progressif, identique au chat IA) --- + # Sans `limit` : tous les messages (tableau, rétro-compatible). + # Avec `limit` : les N messages les PLUS RÉCENTS (ordre chronologique croissant + # pour l'affichage) + `has_more` + `next_before`. Pour charger les plus anciens + # (scroll vers le haut), rappeler avec `?before=`. + limit_param = request.query_params.get('limit') + if limit_param is None: + serializer = self.get_serializer(base, many=True) + return Response(serializer.data) + + try: + limit = int(limit_param) + except (TypeError, ValueError): + return Response({"detail": "limit doit être un entier."}, status=status.HTTP_400_BAD_REQUEST) + limit = max(1, min(limit, 100)) + + # Du plus récent au plus ancien (keyset sur created_at + id). + qs = base.order_by('-created_at', '-id') + + before = request.query_params.get('before') + if before: + try: + cursor = base.filter(pk=before).values('created_at', 'id').first() + except (ValueError, DjangoValidationError): + cursor = None + if not cursor: + return Response( + {"detail": "Paramètre 'before' invalide ou introuvable."}, + status=status.HTTP_400_BAD_REQUEST, + ) + qs = qs.filter( + Q(created_at__lt=cursor['created_at']) + | Q(created_at=cursor['created_at'], id__lt=cursor['id']) + ) + + rows = list(qs[:limit + 1]) + has_more = len(rows) > limit + rows = rows[:limit] + next_before = str(rows[-1].id) if (rows and has_more) else None + rows.reverse() # ordre chronologique croissant pour l'affichage + serializer = self.get_serializer(rows, many=True) + return Response({ + "messages": serializer.data, + "has_more": has_more, + "next_before": next_before, + }) + def perform_create(self, serializer): incident_id = self.kwargs.get('incident_id') user = self.request.user diff --git a/Mapapi/views/notification.py b/Mapapi/views/notification.py index 82d9d62d..e6850170 100644 --- a/Mapapi/views/notification.py +++ b/Mapapi/views/notification.py @@ -1,30 +1,101 @@ """Notification & user-action endpoints.""" -from rest_framework import status, viewsets, generics +from rest_framework import viewsets, generics, status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import extend_schema, extend_schema_view + +from drf_spectacular.utils import OpenApiParameter +from drf_spectacular.types import OpenApiTypes from ..serializer import * -from .common import CustomPageNumberPagination +from .common import CustomPageNumberPagination, NotificationPagination -@extend_schema( - description="Endpoint for filtering notifications by user ", - responses={200: NotificationSerializer()}, +@extend_schema_view( + list=extend_schema( + tags=['Notifications'], + operation_id='notifications_list', + summary='Mes notifications', + description=( + "Toutes les notifications de l'utilisateur connecté, **plus récentes " + "d'abord**, paginées (20/page, `?page=&page_size=`). Chaque notification " + "porte un champ `link` (cible de redirection au clic). Filtre `?read=true|false` " + "(ex. `?read=false` pour les non lues → `count` = nombre de non lues). " + "Authentification requise." + ), + parameters=[ + OpenApiParameter('read', OpenApiTypes.BOOL, OpenApiParameter.QUERY, + description="Filtre lues/non lues (true|false)."), + ], + responses={200: NotificationSerializer(many=True)}, + ) ) - class NotificationViewSet(viewsets.ModelViewSet): queryset = Notification.objects.all() serializer_class = NotificationSerializer permission_classes = [IsAuthenticated] + pagination_class = NotificationPagination def get_queryset(self): - user = self.request.user - return Notification.objects.filter(user=user, colaboration__status='pending') -@extend_schema( - description="Endpoint for retrieving user action", - responses={200: UserActionSerializer()}, + qs = Notification.objects.filter(user=self.request.user).order_by('-created_at') + read = self.request.query_params.get('read') + if read is not None: + qs = qs.filter(read=(str(read).lower() in ('1', 'true', 'yes'))) + return qs + + @staticmethod + def _as_bool(val, default=True): + if isinstance(val, bool): + return val + if val is None: + return default + return str(val).lower() in ('1', 'true', 'yes') + + @extend_schema( + tags=['Notifications'], + operation_id='notifications_mark_read', + summary='Marquer une notification comme lue', + description="Met à jour le statut de lecture d'UNE notification (au clic). " + "Seul le champ `read` est modifiable (les autres sont ignorés) ; " + "défaut `read=true`. Limité aux notifications de l'utilisateur connecté.", + request=None, + responses={200: NotificationSerializer}, + ) + def partial_update(self, request, *args, **kwargs): + """PATCH /notifications// — bascule `read` (true par défaut). + + ``get_object`` s'appuie sur ``get_queryset`` (filtré par utilisateur) : + impossible de toucher la notification d'un autre (→ 404). + """ + instance = self.get_object() + instance.read = self._as_bool(request.data.get('read', True)) + instance.save(update_fields=['read']) + return Response(self.get_serializer(instance).data, status=status.HTTP_200_OK) + + @extend_schema( + tags=['Notifications'], + operation_id='notifications_mark_all_read', + summary='Tout marquer comme lu', + description="Marque TOUTES les notifications non lues de l'utilisateur connecté " + "comme lues. Renvoie le nombre de notifications mises à jour.", + request=None, + responses={200: OpenApiTypes.OBJECT}, + ) + def mark_all_read(self, request, *args, **kwargs): + """POST /notifications/mark-all-read/ — marque toutes mes notifs comme lues.""" + updated = Notification.objects.filter(user=request.user, read=False).update(read=True) + return Response({'marked_read': updated}, status=status.HTTP_200_OK) + + +@extend_schema_view( + list=extend_schema( + tags=['Notifications'], + operation_id='user_actions_list', + summary='Mes actions (journal)', + description="Journal des actions de l'utilisateur connecté. Authentification requise.", + responses={200: UserActionSerializer(many=True)}, + ) ) class UserActionView(viewsets.ModelViewSet): queryset = UserAction.objects.all() @@ -36,3 +107,27 @@ def get_queryset(self): def perform_create(self, serializer): serializer.save(user=self.request.user) + + +@extend_schema( + tags=['Notifications'], + operation_id='activity_feed', + summary="Flux d'activité (autres organisations)", + description="Activité récente de la plateforme **en dehors de l'organisation de " + "l'utilisateur connecté** (prises en charge / résolutions d'incidents, " + "etc.), plus récente d'abord, paginée (20/page). Chaque élément expose " + "`action`, `user_name`, `organisation_name`, `created_at`. Auth requise.", + responses={200: ActivityFeedSerializer(many=True)}, +) +class ActivityFeedView(generics.ListAPIView): + """GET /activity-feed/ — activité de la plateforme hors organisation connectée.""" + permission_classes = [IsAuthenticated] + serializer_class = ActivityFeedSerializer + pagination_class = NotificationPagination + + def get_queryset(self): + qs = UserAction.objects.select_related('user', 'user__organisation_member') + org_id = getattr(self.request.user, 'organisation_member_id', None) + if org_id: + qs = qs.exclude(user__organisation_member_id=org_id) + return qs # tri par défaut depuis Meta (-created_at, -timeStamp) diff --git a/Mapapi/views/organisation.py b/Mapapi/views/organisation.py index 352c9f26..6476ed7b 100644 --- a/Mapapi/views/organisation.py +++ b/Mapapi/views/organisation.py @@ -4,23 +4,121 @@ import logging from Mapapi.views.common import CustomPageNumberPagination -from django.db.models import Count +from django.db.models import Count, Q logger = logging.getLogger(__name__) -from rest_framework import status, generics, permissions +from rest_framework import status, generics, permissions, serializers from rest_framework.permissions import IsAuthenticated from rest_framework.views import APIView from rest_framework.response import Response -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import ( + extend_schema, + extend_schema_view, + OpenApiParameter, + OpenApiResponse, + OpenApiExample, + inline_serializer, +) +from drf_spectacular.types import OpenApiTypes -from ..models import Organisation, User, Incident, ORG_ROLE_ADMIN, ORG_ROLE_BUREAU, ORG_ROLE_FIELD +from ..models import Organisation, User, Incident, ORG_ROLE_ADMIN, ORG_ROLE_BUREAU, ORG_ROLE_FIELD, PARTNER_STATUS_ACTIVE from ..serializer import OrganisationSerializer, OrganisationMemberSerializer +from ..permissions import IsSuperAdminRole +from ..roles import is_super_admin, is_org_admin, is_bureau_agent from ..Send_mails import send_email -from .user import send_sms +def _active_admin_count(org, exclude_user_id=None): + """Nombre d'admins (org_admin) actifs d'une organisation, hors `exclude_user_id`.""" + qs = User.objects.filter( + organisation_member=org, + org_role=ORG_ROLE_ADMIN, + is_active=True, + ) + if exclude_user_id is not None: + qs = qs.exclude(pk=exclude_user_id) + return qs.count() + + +@extend_schema_view( + list=extend_schema( + tags=['Organisations & Membres'], + operation_id='organisations_list', + summary="Lister les organisations", + description="Renvoie la liste paginée des organisations. Accès public (aucune authentification requise).", + parameters=[ + OpenApiParameter('page', OpenApiTypes.INT, OpenApiParameter.QUERY, description="Numéro de page."), + OpenApiParameter('page_size', OpenApiTypes.INT, OpenApiParameter.QUERY, description="Taille de page (max 1000, défaut 100)."), + ], + responses={200: OrganisationSerializer(many=True)}, + ), + create=extend_schema( + tags=['Organisations & Membres'], + operation_id='organisations_create', + summary="Créer une organisation", + description="Crée une organisation. Réservé au Super Admin (authentification requise).", + request=OrganisationSerializer, + responses={ + 201: OrganisationSerializer, + 400: OpenApiResponse(description="Données invalides."), + 401: OpenApiResponse(description="Authentification requise."), + 403: OpenApiResponse(description="Réservé au Super Admin."), + }, + ), + retrieve=extend_schema( + tags=['Organisations & Membres'], + operation_id='organisations_retrieve', + summary="Détail d'une organisation", + description="Renvoie une organisation par son identifiant. Accès public.", + parameters=[OpenApiParameter('pk', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant de l'organisation.")], + responses={200: OrganisationSerializer, 404: OpenApiResponse(description="Organisation non trouvée.")}, + ), + update=extend_schema( + tags=['Organisations & Membres'], + operation_id='organisations_update', + summary="Modifier une organisation (complet)", + description="Met à jour le profil d'une organisation. Réservé au Super Admin ou à l'Admin de CETTE organisation.", + parameters=[OpenApiParameter('pk', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant de l'organisation.")], + request=OrganisationSerializer, + responses={ + 200: OrganisationSerializer, + 400: OpenApiResponse(description="Données invalides."), + 401: OpenApiResponse(description="Authentification requise."), + 403: OpenApiResponse(description="Seul un administrateur de cette organisation (ou Super Admin) peut modifier son profil."), + 404: OpenApiResponse(description="Organisation non trouvée."), + }, + ), + partial_update=extend_schema( + tags=['Organisations & Membres'], + operation_id='organisations_partial_update', + summary="Modifier une organisation (partiel)", + description="Met à jour partiellement le profil d'une organisation. Réservé au Super Admin ou à l'Admin de CETTE organisation.", + parameters=[OpenApiParameter('pk', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant de l'organisation.")], + request=OrganisationSerializer, + responses={ + 200: OrganisationSerializer, + 400: OpenApiResponse(description="Données invalides."), + 401: OpenApiResponse(description="Authentification requise."), + 403: OpenApiResponse(description="Seul un administrateur de cette organisation (ou Super Admin) peut modifier son profil."), + 404: OpenApiResponse(description="Organisation non trouvée."), + }, + ), + destroy=extend_schema( + tags=['Organisations & Membres'], + operation_id='organisations_destroy', + summary="Supprimer une organisation", + description="Supprime une organisation. Réservé au Super Admin (authentification requise).", + parameters=[OpenApiParameter('pk', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant de l'organisation.")], + responses={ + 204: OpenApiResponse(description="Organisation supprimée."), + 401: OpenApiResponse(description="Authentification requise."), + 403: OpenApiResponse(description="Réservé au Super Admin."), + 404: OpenApiResponse(description="Organisation non trouvée."), + }, + ), +) class OrganisationViewSet(generics.ListCreateAPIView, generics.RetrieveUpdateDestroyAPIView): queryset = Organisation.objects.all() serializer_class = OrganisationSerializer @@ -28,13 +126,103 @@ class OrganisationViewSet(generics.ListCreateAPIView, generics.RetrieveUpdateDes pagination_class = CustomPageNumberPagination # Liste d'orgs sans pagination (généralement peu d'orgs) def get_queryset(self): - return Organisation.objects.all() + # Plus récentes d'abord, ordre stable (corrige le saut de position après édition). + qs = Organisation.objects.all().order_by('-created_at') + p = self.request.query_params + search = (p.get('search') or '').strip() + if search: + qs = qs.filter( + Q(name__icontains=search) | Q(acronym__icontains=search) + | Q(subdomain__icontains=search) | Q(intervention_country__icontains=search) + ) + sector = p.get('activity_sector') or p.get('sector') + if sector: + qs = qs.filter(activity_sector=sector) + statut = p.get('partner_status') or p.get('status') + if statut: + qs = qs.filter(partner_status=statut) + org_type = p.get('organisation_type') or p.get('type') + if org_type: + qs = qs.filter(organisation_type=org_type) + return qs + + def get_permissions(self): + # Spec §6 : + # - création d'organisation → Super Admin uniquement + # - modification du profil d'une org → Admin de CETTE org (vérifié sur l'objet) ou Super Admin + # - suppression d'une organisation → Super Admin uniquement + # - lecture (GET) → public (inchangé) + if self.request.method == 'POST': + return [IsAuthenticated(), IsSuperAdminRole()] + if self.request.method in ('PUT', 'PATCH'): + return [IsAuthenticated()] + if self.request.method == 'DELETE': + return [IsAuthenticated(), IsSuperAdminRole()] + return [] + + def update(self, request, *args, **kwargs): + org = self.get_object() + # Modifier le profil d'une organisation : Super Admin, ou Admin de CETTE organisation. + if not ( + is_super_admin(request.user) + or (is_org_admin(request.user) and request.user.organisation_member_id == org.pk) + ): + return Response( + {"error": "Seul un administrateur de cette organisation peut modifier son profil."}, + status=status.HTTP_403_FORBIDDEN, + ) + return super().update(request, *args, **kwargs) @extend_schema( - description="Détail enrichi d'une organisation avec statistiques (membres, incidents, etc.).", - responses={200: OrganisationSerializer}, + tags=['Organisations & Membres'], + operation_id='organisations_stats', + summary="Stats du dashboard organisations", + description="Cartes du dashboard organisations : total, actives, inactives, et nombre " + "total d'incidents pris en compte par des organisations. Accès public.", + responses={200: inline_serializer(name='OrganisationStats', fields={ + 'total': serializers.IntegerField(), + 'active': serializers.IntegerField(), + 'inactive': serializers.IntegerField(), + 'incidents_taken_total': serializers.IntegerField(), + })}, ) +class OrganisationStatsView(APIView): + """GET /organisations/stats/ — compteurs pour les cartes du dashboard orgs.""" + permission_classes = [] + + def get(self, request): + total = Organisation.objects.count() + active = Organisation.objects.filter(partner_status=PARTNER_STATUS_ACTIVE).count() + incidents_taken = Incident.objects.filter( + taken_by__organisation_member__isnull=False, is_deleted=False + ).count() + return Response({ + 'total': total, + 'active': active, + 'inactive': total - active, + 'incidents_taken_total': incidents_taken, + }) + + +@extend_schema_view(get=extend_schema( + tags=['Organisations & Membres'], + operation_id='organisations_detail_enriched', + summary="Détail enrichi d'une organisation", + description=( + "Renvoie l'organisation enrichie d'un objet `stats`. Accès public. " + "`stats` contient : member_count, field_agents_count, bureau_agents_count, " + "admins_count, incident_count, resolved_incident_count." + ), + parameters=[OpenApiParameter('pk', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant de l'organisation.")], + responses={ + 200: OpenApiResponse( + response=OrganisationSerializer, + description="Organisation + objet `stats` (compteurs membres/incidents).", + ), + 404: OpenApiResponse(description="Organisation non trouvée."), + }, +)) class OrganisationDetailView(APIView): """GET /organisations//detail/ — détail enrichi avec stats.""" permission_classes = [] @@ -76,6 +264,31 @@ def get(self, request, pk): return Response(data, status=status.HTTP_200_OK) +@extend_schema_view(get=extend_schema( + tags=['Organisations & Membres'], + operation_id='organisations_tenant_config', + summary="Configuration de tenant (thème par sous-domaine)", + description=( + "Renvoie la configuration de thème de l'organisation résolue à partir du " + "sous-domaine de la requête. Accès public. 404 si aucune organisation ne " + "correspond au sous-domaine." + ), + responses={ + 200: inline_serializer( + name='TenantConfigResponse', + fields={ + 'name': serializers.CharField(), + 'subdomain': serializers.CharField(), + 'logo_url': serializers.CharField(allow_null=True), + 'primary_color': serializers.CharField(allow_null=True), + 'secondary_color': serializers.CharField(allow_null=True), + 'background_color': serializers.CharField(allow_null=True), + 'is_premium': serializers.BooleanField(), + }, + ), + 404: OpenApiResponse(description="Organisation introuvable pour ce sous-domaine."), + }, +)) class TenantConfigView(APIView): permission_classes = [] @@ -101,10 +314,21 @@ def get(self, request, format=None): return Response(data) -@extend_schema( - description="Liste les membres d'une organisation. Réservé aux admins et agents de bureau de l'org.", - responses={200: OrganisationMemberSerializer(many=True)}, -) +@extend_schema_view(list=extend_schema( + tags=['Organisations & Membres'], + operation_id='organisations_members_list', + summary="Lister les membres d'une organisation", + description=( + "Liste les membres d'une organisation. Authentification requise ; réservé " + "au staff (is_staff) ou aux org_admin / bureau_agent de CETTE organisation." + ), + parameters=[OpenApiParameter('pk', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant de l'organisation.")], + responses={ + 200: OrganisationMemberSerializer(many=True), + 401: OpenApiResponse(description="Authentification requise."), + 403: OpenApiResponse(description="Droits insuffisants pour voir les membres de cette organisation."), + }, +)) class OrganisationMemberListView(generics.ListAPIView): """GET /organisations//members/ — liste des membres de l'organisation.""" serializer_class = OrganisationMemberSerializer @@ -118,7 +342,7 @@ def list(self, request, *args, **kwargs): # Vérifier que l'utilisateur est admin ou agent de bureau de cette org org_id = self.kwargs['pk'] user = request.user - if not (user.is_staff or user.is_superuser or ( + if not (user.is_staff or ( user.organisation_member_id == org_id and user.org_role in [ORG_ROLE_ADMIN, ORG_ROLE_BUREAU] )): @@ -129,24 +353,61 @@ def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) -@extend_schema( - description="Ajouter un membre à une organisation. Génère un agent_code si le rôle est field_agent.", - request=OrganisationMemberSerializer, - responses={201: OrganisationMemberSerializer}, -) +@extend_schema_view(post=extend_schema( + tags=['Organisations & Membres'], + operation_id='organisations_members_add', + summary="Ajouter un membre à une organisation", + description=( + "Affecte un utilisateur EXISTANT (`user_id`) à l'organisation avec le rôle " + "`org_role`. Authentification requise ; réservé au Super Admin ou à l'Admin " + "de CETTE organisation. Si le rôle est `field_agent` : génère `agent_code`, " + "un `pin_code` initial (renvoyé une seule fois dans `initial_pin`) et envoie " + "un email avec les identifiants." + ), + parameters=[OpenApiParameter('pk', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant de l'organisation.")], + request=inline_serializer( + name='OrganisationMemberAddRequest', + fields={ + 'user_id': serializers.UUIDField(), + 'org_role': serializers.ChoiceField(choices=[ORG_ROLE_ADMIN, ORG_ROLE_BUREAU, ORG_ROLE_FIELD]), + }, + ), + examples=[ + OpenApiExample( + 'Ajout agent de terrain', + value={'user_id': '00000000-0000-0000-0000-000000000000', 'org_role': 'field_agent'}, + request_only=True, + ), + ], + responses={ + 201: OpenApiResponse( + response=OrganisationMemberSerializer, + description=( + "Membre affecté. Pour un `field_agent` nouvellement doté d'un PIN, la " + "réponse ajoute `initial_pin` (PIN en clair, une seule fois), " + "`must_change_pin` (bool) et `email_sent` (bool ; `email_error` si l'envoi a échoué)." + ), + ), + 400: OpenApiResponse(description="user_id et org_role requis, ou rôle invalide."), + 401: OpenApiResponse(description="Authentification requise."), + 403: OpenApiResponse(description="Seul un administrateur d'organisation peut gérer les membres."), + 404: OpenApiResponse(description="Organisation ou utilisateur non trouvé."), + }, +)) class OrganisationMemberCreateView(APIView): """POST /organisations//members/ — ajouter un membre.""" permission_classes = [IsAuthenticated] def post(self, request, pk): user = request.user - # Seul un admin org ou agent de bureau de cette org peut ajouter - if not (user.is_staff or user.is_superuser or ( + # Spec §6 : créer/gérer les utilisateurs = Admin d'organisation uniquement + # (un agent de bureau ne gère plus les membres). Super Admin reste autorisé. + if not (is_super_admin(user) or ( user.organisation_member_id == pk - and user.org_role in [ORG_ROLE_ADMIN, ORG_ROLE_BUREAU] + and is_org_admin(user) )): return Response( - {"error": "Vous n'avez pas les droits pour gérer les membres."}, + {"error": "Seul un administrateur d'organisation peut gérer les membres."}, status=status.HTTP_403_FORBIDDEN, ) @@ -226,26 +487,53 @@ def post(self, request, pk): return Response(response_data, status=status.HTTP_201_CREATED) -@extend_schema( +@extend_schema_view(post=extend_schema( + tags=['Organisations & Membres'], + operation_id='organisations_agents_create', + summary="Créer un agent de terrain (tout-en-un)", description=( - "Endpoint tout-en-un : crée un utilisateur ET l'affecte comme agent de terrain " - "dans l'organisation. Génère agent_code + PIN initial + envoie email automatiquement." + "Crée un utilisateur ET l'affecte comme agent de terrain (`field_agent`) de " + "l'organisation en un seul appel : génère `agent_code` + `pin_code` initial " + "et envoie l'email d'identifiants. Authentification requise ; réservé au " + "Super Admin ou à l'Admin de CETTE organisation." ), - request={ - 'application/json': { - 'type': 'object', - 'required': ['first_name', 'last_name', 'email', 'phone'], - 'properties': { - 'first_name': {'type': 'string'}, - 'last_name': {'type': 'string'}, - 'email': {'type': 'string', 'format': 'email'}, - 'phone': {'type': 'string', 'example': '+221771234567'}, - 'address': {'type': 'string'}, + parameters=[OpenApiParameter('pk', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant de l'organisation.")], + request=inline_serializer( + name='FieldAgentCreateRequest', + fields={ + 'first_name': serializers.CharField(), + 'last_name': serializers.CharField(), + 'email': serializers.EmailField(), + 'phone': serializers.CharField(), + 'address': serializers.CharField(required=False), + }, + ), + examples=[ + OpenApiExample( + 'Création agent de terrain', + value={ + 'first_name': 'Awa', 'last_name': 'Diallo', + 'email': 'awa.diallo@example.com', 'phone': '+221771234567', + 'address': 'Dakar', }, - } + request_only=True, + ), + ], + responses={ + 201: OpenApiResponse( + response=OrganisationMemberSerializer, + description=( + "Agent créé. La réponse ajoute toujours `initial_pin` (PIN en clair, " + "une seule fois), `must_change_pin` (bool) et `email_sent` (bool ; " + "`email_error` si l'envoi de l'email a échoué)." + ), + ), + 400: OpenApiResponse(description="Champs requis manquants, email déjà utilisé, ou téléphone déjà pris par un agent de terrain."), + 401: OpenApiResponse(description="Authentification requise."), + 403: OpenApiResponse(description="Seul un administrateur d'organisation peut créer un agent."), + 404: OpenApiResponse(description="Organisation non trouvée."), }, - responses={201: OrganisationMemberSerializer}, -) +)) class FieldAgentCreateView(APIView): """POST /organisations//agents/create/ @@ -258,13 +546,16 @@ class FieldAgentCreateView(APIView): def post(self, request, pk): user = request.user - # Permissions : org_admin/bureau_agent de cette org ou superadmin - if not (user.is_staff or user.is_superuser or ( + # Créer un agent de TERRAIN = Admin d'org OU agent de bureau de CETTE org + # (ou Super Admin). Un agent de bureau gère les agents de terrain de son org + # (création comme suppression) ; la création de staff (admin/bureau) reste, + # elle, réservée à l'admin d'org via StaffAccountCreateView. + if not (is_super_admin(user) or ( user.organisation_member_id == pk - and user.org_role in [ORG_ROLE_ADMIN, ORG_ROLE_BUREAU] + and (is_org_admin(user) or is_bureau_agent(user)) )): return Response( - {"error": "Vous n'avez pas les droits pour créer un agent dans cette organisation."}, + {"error": "Seul un administrateur d'organisation ou un agent de bureau de cette organisation peut créer un agent de terrain."}, status=status.HTTP_403_FORBIDDEN, ) @@ -287,22 +578,28 @@ def post(self, request, pk): 'phone': phone, }.items() if not v] if missing: + # `fields` = liste des champs en erreur (le front peut les surligner) ; + # `errors` = message par champ ; `error` = résumé lisible (rétro-compat). return Response( - {"error": f"Champs requis manquants : {', '.join(missing)}."}, + {"error": f"Champs requis manquants : {', '.join(missing)}.", + "fields": missing, + "errors": {f: "Ce champ est requis." for f in missing}}, status=status.HTTP_400_BAD_REQUEST, ) # Email unique if User.objects.filter(email=email).exists(): + msg = "Un utilisateur avec cet email existe déjà. Utilise l'endpoint /members/add/ pour l'affecter." return Response( - {"error": "Un utilisateur avec cet email existe déjà. Utilise l'endpoint /members/add/ pour l'affecter."}, + {"error": msg, "field": "email", "errors": {"email": msg}}, status=status.HTTP_400_BAD_REQUEST, ) # Téléphone unique parmi les agents de terrain (sinon login PIN ambigu) if User.objects.filter(phone=phone, org_role=ORG_ROLE_FIELD).exists(): + msg = "Un agent de terrain avec ce numéro de téléphone existe déjà." return Response( - {"error": "Un agent de terrain avec ce numéro de téléphone existe déjà."}, + {"error": msg, "field": "phone", "errors": {"phone": msg}}, status=status.HTTP_400_BAD_REQUEST, ) @@ -350,34 +647,76 @@ def post(self, request, pk): email_error = str(e) logger.error(f"Erreur envoi email PIN à {member.email}: {e}", exc_info=True) - # Envoi du SMS avec le PIN - sms_sent = False - try: - sms_message = f"Bienvenue sur Map Action. Votre code PIN de connexion est : {initial_pin}" - sms_sent = send_sms(member.phone, sms_message) - if sms_sent: - logger.info(f"SMS PIN envoyé à {member.phone} pour l'agent {member.id}") - else: - logger.error(f"Echec envoi SMS PIN à {member.phone} pour l'agent {member.id}") - except Exception as e: - logger.error(f"Erreur exception envoi SMS PIN à {member.phone}: {e}", exc_info=True) - serializer = OrganisationMemberSerializer(member) response_data = serializer.data response_data.update({ 'initial_pin': initial_pin, 'must_change_pin': member.must_change_pin, 'email_sent': email_sent, - 'sms_sent': sms_sent, }) if email_error: response_data['email_error'] = email_error return Response(response_data, status=status.HTTP_201_CREATED) -@extend_schema( - description="Modifier le rôle ou retirer un membre d'une organisation.", - responses={200: OrganisationMemberSerializer}, +@extend_schema_view( + patch=extend_schema( + tags=['Organisations & Membres'], + operation_id='organisations_members_update', + summary="Modifier un membre d'une organisation", + description=( + "Met à jour un membre (email, first_name, last_name, phone, org_role) — " + "tous les champs sont optionnels. Authentification requise ; réservé au " + "Super Admin ou à l'Admin de CETTE organisation. Règle anti-verrouillage : " + "impossible de rétrograder le dernier admin actif de l'organisation." + ), + parameters=[ + OpenApiParameter('pk', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant de l'organisation."), + OpenApiParameter('user_id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant du membre."), + ], + request=inline_serializer( + name='OrganisationMemberUpdateRequest', + fields={ + 'email': serializers.EmailField(required=False), + 'first_name': serializers.CharField(required=False), + 'last_name': serializers.CharField(required=False), + 'phone': serializers.CharField(required=False), + 'org_role': serializers.ChoiceField( + choices=[ORG_ROLE_ADMIN, ORG_ROLE_BUREAU, ORG_ROLE_FIELD], required=False, + ), + }, + ), + responses={ + 200: OrganisationMemberSerializer, + 400: OpenApiResponse(description="Email déjà utilisé, téléphone déjà pris, rôle invalide, ou dernier admin actif."), + 401: OpenApiResponse(description="Authentification requise."), + 403: OpenApiResponse(description="Droits insuffisants."), + 404: OpenApiResponse(description="Membre non trouvé dans cette organisation."), + }, + ), + delete=extend_schema( + tags=['Organisations & Membres'], + operation_id='organisations_members_remove', + summary="Retirer un membre d'une organisation", + description=( + "Supprime physiquement un membre de l'organisation. Authentification " + "requise ; réservé au Super Admin ou à l'Admin de CETTE organisation. " + "Règle anti-verrouillage : impossible de retirer le dernier admin actif. " + "Renvoie 200 avec un message de confirmation." + ), + parameters=[ + OpenApiParameter('pk', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant de l'organisation."), + OpenApiParameter('user_id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant du membre."), + ], + request=None, + responses={ + 200: OpenApiResponse(description="Agent supprimé avec succès de l'organisation (objet `{message}`)."), + 400: OpenApiResponse(description="Impossible de retirer le dernier administrateur actif de l'organisation."), + 401: OpenApiResponse(description="Authentification requise."), + 403: OpenApiResponse(description="Droits insuffisants."), + 404: OpenApiResponse(description="Membre non trouvé dans cette organisation."), + }, + ), ) class OrganisationMemberDetailView(APIView): """ @@ -388,15 +727,21 @@ class OrganisationMemberDetailView(APIView): def _check_permission(self, request, pk): user = request.user - if user.is_staff or user.is_superuser: + # Spec §6 : promouvoir / changer de rôle / désactiver un membre = Admin d'organisation + # uniquement (ou Super Admin). Un agent de bureau ne gère plus les membres. + if is_super_admin(user): return True return ( user.organisation_member_id == pk - and user.org_role in [ORG_ROLE_ADMIN, ORG_ROLE_BUREAU] + and is_org_admin(user) ) def patch(self, request, pk, user_id): - if not self._check_permission(request, pk): + # Admin d'org / Super Admin gèrent tous les membres ; en plus, un agent de + # BUREAU de cette org peut modifier un agent de TERRAIN (et seulement lui). + is_admin_level = self._check_permission(request, pk) + is_bureau_same_org = is_bureau_agent(request.user) and request.user.organisation_member_id == pk + if not is_admin_level and not is_bureau_same_org: return Response( {"error": "Droits insuffisants."}, status=status.HTTP_403_FORBIDDEN, @@ -407,12 +752,29 @@ def patch(self, request, pk, user_id): except User.DoesNotExist: return Response({"error": "Membre non trouvé dans cette organisation."}, status=status.HTTP_404_NOT_FOUND) + # Garde-fous agent de bureau : seulement un agent de terrain, et pas de + # changement de rôle (pas de promotion vers bureau/admin). + if is_bureau_same_org and not is_admin_level: + if member.org_role != ORG_ROLE_FIELD: + return Response( + {"error": "Un agent de bureau ne peut modifier qu'un agent de terrain."}, + status=status.HTTP_403_FORBIDDEN, + ) + req_role = request.data.get('org_role') + if req_role and req_role != ORG_ROLE_FIELD: + return Response( + {"error": "Un agent de bureau ne peut pas changer le rôle d'un agent.", + "field": "org_role", "errors": {"org_role": "Modification du rôle non autorisée."}}, + status=status.HTTP_403_FORBIDDEN, + ) + # Modification des informations de l'agent email = request.data.get('email') if email: email = email.strip().lower() if User.objects.filter(email=email).exclude(pk=user_id).exists(): - return Response({"error": "Cet email est déjà utilisé par un autre utilisateur."}, status=status.HTTP_400_BAD_REQUEST) + msg = "Cet email est déjà utilisé par un autre utilisateur." + return Response({"error": msg, "field": "email", "errors": {"email": msg}}, status=status.HTTP_400_BAD_REQUEST) member.email = email first_name = request.data.get('first_name') @@ -428,13 +790,28 @@ def patch(self, request, pk, user_id): phone = phone.strip() # Si c'est un agent de terrain, s'assurer que le téléphone est unique parmi les agents de terrain if phone and User.objects.filter(phone=phone, org_role=ORG_ROLE_FIELD).exclude(pk=user_id).exists(): - return Response({"error": "Un agent de terrain avec ce numéro de téléphone existe déjà."}, status=status.HTTP_400_BAD_REQUEST) + msg = "Un agent de terrain avec ce numéro de téléphone existe déjà." + return Response({"error": msg, "field": "phone", "errors": {"phone": msg}}, status=status.HTTP_400_BAD_REQUEST) member.phone = phone new_role = request.data.get('org_role') if new_role: if new_role not in [ORG_ROLE_ADMIN, ORG_ROLE_BUREAU, ORG_ROLE_FIELD]: - return Response({"error": "Rôle invalide."}, status=status.HTTP_400_BAD_REQUEST) + return Response({"error": "Rôle invalide.", "field": "org_role", "errors": {"org_role": "Rôle invalide."}}, status=status.HTTP_400_BAD_REQUEST) + # Règle anti-verrouillage : on ne peut pas rétrograder le DERNIER admin actif de l'org. + # Le Super Admin peut l'outrepasser (gestion plateforme). + if ( + not is_super_admin(request.user) + and member.org_role == ORG_ROLE_ADMIN + and new_role != ORG_ROLE_ADMIN + and member.is_active + and _active_admin_count(member.organisation_member, exclude_user_id=member.pk) == 0 + ): + return Response( + {"error": "Impossible de rétrograder le dernier administrateur actif de l'organisation. " + "Promouvez d'abord un autre membre en administrateur."}, + status=status.HTTP_400_BAD_REQUEST, + ) member.org_role = new_role # Générer code agent si nouveau rôle terrain if new_role == ORG_ROLE_FIELD and not member.agent_code: @@ -446,7 +823,11 @@ def patch(self, request, pk, user_id): return Response(serializer.data, status=status.HTTP_200_OK) def delete(self, request, pk, user_id): - if not self._check_permission(request, pk): + # Admin d'org / Super Admin gèrent tous les membres ; en plus, un agent de + # BUREAU de cette org peut supprimer un agent de TERRAIN (et uniquement lui). + is_admin_level = self._check_permission(request, pk) + is_bureau_same_org = is_bureau_agent(request.user) and request.user.organisation_member_id == pk + if not is_admin_level and not is_bureau_same_org: return Response( {"error": "Droits insuffisants."}, status=status.HTTP_403_FORBIDDEN, @@ -457,6 +838,28 @@ def delete(self, request, pk, user_id): except User.DoesNotExist: return Response({"error": "Membre non trouvé dans cette organisation."}, status=status.HTTP_404_NOT_FOUND) + # Un agent de bureau ne peut supprimer qu'un agent de TERRAIN (jamais un + # admin ni un autre agent de bureau). + if is_bureau_same_org and not is_admin_level and member.org_role != ORG_ROLE_FIELD: + return Response( + {"error": "Un agent de bureau ne peut supprimer qu'un agent de terrain."}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Règle anti-verrouillage : on ne peut pas retirer le DERNIER admin actif de l'org. + # Le Super Admin peut l'outrepasser (gestion plateforme : il pourra ré-affecter un admin). + if ( + not is_super_admin(request.user) + and member.org_role == ORG_ROLE_ADMIN + and member.is_active + and _active_admin_count(member.organisation_member, exclude_user_id=member.pk) == 0 + ): + return Response( + {"error": "Impossible de retirer le dernier administrateur actif de l'organisation. " + "Promouvez d'abord un autre membre en administrateur."}, + status=status.HTTP_400_BAD_REQUEST, + ) + # Supprimer physiquement l'agent member.delete() @@ -470,29 +873,55 @@ def delete(self, request, pk, user_id): } -@extend_schema( +@extend_schema_view(post=extend_schema( + tags=['Organisations & Membres'], + operation_id='organisations_staff_create', + summary="Créer un compte staff (admin / agent de bureau)", description=( - "Crée un utilisateur staff (admin ou agent de bureau) pour l'organisation. " - "Génère un mot de passe temporaire et envoie un email avec les identifiants, " - "le rôle et l'organisation. Le frontend force le changement de mot de passe à " - "la première connexion." + "Crée un utilisateur staff (`org_admin` ou `bureau_agent`) pour l'organisation : " + "génère un mot de passe temporaire (envoyé par email, NON renvoyé dans la " + "réponse) avec le rôle et l'organisation. Authentification requise ; réservé au " + "Super Admin (is_staff) ou à l'Admin de CETTE organisation. Le frontend force le " + "changement de mot de passe à la première connexion (`must_change_password`)." + ), + parameters=[OpenApiParameter('pk', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant de l'organisation.")], + request=inline_serializer( + name='StaffAccountCreateRequest', + fields={ + 'first_name': serializers.CharField(), + 'last_name': serializers.CharField(), + 'email': serializers.EmailField(), + 'org_role': serializers.ChoiceField(choices=[ORG_ROLE_ADMIN, ORG_ROLE_BUREAU]), + 'phone': serializers.CharField(required=False), + 'address': serializers.CharField(required=False), + }, ), - request={ - 'application/json': { - 'type': 'object', - 'required': ['first_name', 'last_name', 'email', 'org_role'], - 'properties': { - 'first_name': {'type': 'string'}, - 'last_name': {'type': 'string'}, - 'email': {'type': 'string', 'format': 'email'}, - 'phone': {'type': 'string'}, - 'address': {'type': 'string'}, - 'org_role': {'type': 'string', 'enum': [ORG_ROLE_ADMIN, ORG_ROLE_BUREAU]}, + examples=[ + OpenApiExample( + 'Création agent de bureau', + value={ + 'first_name': 'Moussa', 'last_name': 'Traoré', + 'email': 'moussa.traore@example.com', 'org_role': 'bureau_agent', + 'phone': '+221770000000', }, - } + request_only=True, + ), + ], + responses={ + 201: OpenApiResponse( + response=OrganisationMemberSerializer, + description=( + "Compte staff créé. La réponse ajoute `must_change_password` (toujours " + "`true`) et `email_sent` (bool ; `email_error` si l'envoi a échoué). Le " + "mot de passe temporaire n'est PAS renvoyé (uniquement envoyé par email)." + ), + ), + 400: OpenApiResponse(description="Champs requis manquants, org_role invalide, ou email déjà utilisé."), + 401: OpenApiResponse(description="Authentification requise."), + 403: OpenApiResponse(description="Seul un administrateur d'organisation peut créer un compte staff."), + 404: OpenApiResponse(description="Organisation non trouvée."), }, - responses={201: OrganisationMemberSerializer}, -) +)) class StaffAccountCreateView(APIView): """POST /organisations//staff/create/ @@ -505,7 +934,7 @@ def post(self, request, pk): user = request.user # Permissions : admin de l'org ou superadmin (un bureau_agent ne crée pas d'admin) - if not (user.is_staff or user.is_superuser or ( + if not (user.is_staff or ( user.organisation_member_id == pk and user.org_role == ORG_ROLE_ADMIN )): @@ -596,9 +1025,97 @@ def post(self, request, pk): response_data = OrganisationMemberSerializer(member).data response_data.update({ + # Mot de passe temporaire renvoyé une seule fois (comme `initial_pin` pour les + # agents de terrain) : l'admin peut le communiquer même si l'email n'arrive pas. + 'temp_password': temp_password, 'email_sent': email_sent, 'must_change_password': True, }) if email_error: response_data['email_error'] = email_error return Response(response_data, status=status.HTTP_201_CREATED) + + +_AGENT_ROLES = [ORG_ROLE_ADMIN, ORG_ROLE_BUREAU, ORG_ROLE_FIELD] + + +@extend_schema( + tags=['Organisations & Membres'], + operation_id='agents_list', + summary="Lister les agents (global)", + description="Tous les agents (membres d'organisation : org_admin, bureau_agent, " + "field_agent), **plus récents d'abord**, paginés. Filtres : `?search=` " + "(nom/email/organisation), `?role=org_admin|bureau_agent|field_agent`, " + "`?status=active|inactive`. Authentification requise.", + parameters=[ + OpenApiParameter('search', OpenApiTypes.STR, OpenApiParameter.QUERY, description="Nom, email ou organisation."), + OpenApiParameter('role', OpenApiTypes.STR, OpenApiParameter.QUERY, description="org_admin|bureau_agent|field_agent."), + OpenApiParameter('status', OpenApiTypes.STR, OpenApiParameter.QUERY, description="active|inactive."), + ], + responses={200: OrganisationMemberSerializer(many=True)}, +) +class AgentListView(generics.ListAPIView): + """GET /agents/ — liste globale des agents (membres d'organisation avec rôle).""" + permission_classes = [IsAuthenticated] + serializer_class = OrganisationMemberSerializer + pagination_class = CustomPageNumberPagination + + def get_queryset(self): + user = self.request.user + qs = (User.objects + .filter(org_role__in=_AGENT_ROLES) + .select_related('organisation_member') + .order_by('-date_joined')) + # Portée : le Super Admin voit TOUS les agents ; un admin/membre d'org ne voit + # que les agents de SON organisation (pas de fuite cross-org). + if not is_super_admin(user): + org_id = getattr(user, 'organisation_member_id', None) + qs = qs.filter(organisation_member_id=org_id) if org_id else qs.none() + p = self.request.query_params + search = (p.get('search') or '').strip() + if search: + qs = qs.filter( + Q(first_name__icontains=search) | Q(last_name__icontains=search) + | Q(email__icontains=search) | Q(organisation_member__name__icontains=search) + ) + role = p.get('role') + if role: + qs = qs.filter(org_role=role) + statut = p.get('status') + if statut: + qs = qs.filter(is_active=(str(statut).lower() in ('active', 'actif', 'true', '1'))) + return qs + + +@extend_schema( + tags=['Organisations & Membres'], + operation_id='agents_stats', + summary="Stats du dashboard agents", + description="Cartes du dashboard agents : total, actifs, admins, agents de terrain. " + "Authentification requise.", + responses={200: inline_serializer(name='AgentStats', fields={ + 'total': serializers.IntegerField(), + 'active': serializers.IntegerField(), + 'admins': serializers.IntegerField(), + 'bureau_agents': serializers.IntegerField(), + 'field_agents': serializers.IntegerField(), + })}, +) +class AgentStatsView(APIView): + """GET /agents/stats/ — compteurs pour les cartes du dashboard agents.""" + permission_classes = [IsAuthenticated] + + def get(self, request): + agents = User.objects.filter(org_role__in=_AGENT_ROLES) + # Même portée que /agents/ : le Super Admin compte tous les agents ; un + # admin/membre d'org ne compte que ceux de SON organisation. + if not is_super_admin(request.user): + org_id = getattr(request.user, 'organisation_member_id', None) + agents = agents.filter(organisation_member_id=org_id) if org_id else agents.none() + return Response({ + 'total': agents.count(), + 'active': agents.filter(is_active=True).count(), + 'admins': agents.filter(org_role=ORG_ROLE_ADMIN).count(), + 'bureau_agents': agents.filter(org_role=ORG_ROLE_BUREAU).count(), + 'field_agents': agents.filter(org_role=ORG_ROLE_FIELD).count(), + }) diff --git a/Mapapi/views/overpass.py b/Mapapi/views/overpass.py index 199bb9dc..16b888c8 100644 --- a/Mapapi/views/overpass.py +++ b/Mapapi/views/overpass.py @@ -8,11 +8,12 @@ from django.core.cache import cache from django.http import JsonResponse -from rest_framework import status, generics +from rest_framework import status, generics, serializers from rest_framework.exceptions import ValidationError from rest_framework.response import Response -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiResponse, inline_serializer +from drf_spectacular.types import OpenApiTypes from ..serializer import * from .common import CustomPageNumberPagination @@ -23,12 +24,26 @@ class OverpassApiIntegration(generics.CreateAPIView): queryset = Incident.objects.all() serializer_class = IncidentSerializer @extend_schema( - description="This endpoint retrieves the locality information of incidents based on their geographic coordinates." - "It accepts latitude and longitude parameters to specify the location around which to search for incidents." - " The endpoint queries amenities such as pharmacies, mosques, schools, restaurants, bars, prisons, rivers, and marigots" - " within a 500-meter radius of the specified coordinates. It then returns a list of incidents found in the vicinity, " - "including details such as the type of amenity and its name.", - responses={200: IncidentSerializer(many=True), 404: "Not Found"}, + tags=['Référentiel & Statistiques'], + operation_id='overpass_amenities_nearby', + summary="Lister les commodités proches (Overpass/OSM)", + description="Interroge l'API Overpass (OpenStreetMap) pour les commodités (pharmacie, mosquée, école, " + "restaurant, bar, prison, rivière, marigot, clinique) dans un rayon de 500 m autour des coordonnées " + "fournies, puis renvoie une liste d'objets `{amenity, name}`. Les résultats sont mis en cache. Endpoint public.", + request=None, + parameters=[ + OpenApiParameter('latitude', OpenApiTypes.NUMBER, OpenApiParameter.QUERY, required=True, description="Latitude du point de recherche."), + OpenApiParameter('longitude', OpenApiTypes.NUMBER, OpenApiParameter.QUERY, required=True, description="Longitude du point de recherche."), + ], + responses={ + 200: inline_serializer( + name='OverpassAmenity', + fields={'amenity': serializers.CharField(), 'name': serializers.CharField()}, + many=True, + ), + 400: OpenApiResponse(description="Paramètres latitude/longitude manquants ou non numériques."), + 503: OpenApiResponse(description="Service Overpass indisponible : {\"detail\": \"...\", \"error\": \"...\"}."), + }, ) def get(self, request, *args, **kwargs): lat = request.GET.get("latitude") diff --git a/Mapapi/views/participate.py b/Mapapi/views/participate.py index 3746910c..946b347b 100644 --- a/Mapapi/views/participate.py +++ b/Mapapi/views/participate.py @@ -3,16 +3,68 @@ from rest_framework.pagination import PageNumberPagination from rest_framework.response import Response -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiResponse +from drf_spectacular.types import OpenApiTypes from ..serializer import * from .common import CustomPageNumberPagination -@extend_schema( - description="Endpoint allowing retrieval, updating, and deletion of participation.", - request=ParticipateSerializer, - responses={200: ParticipateSerializer, 404: "Participation not found"}, +@extend_schema_view( + get=extend_schema( + tags=['Événements & Participation'], + operation_id='participations_retrieve', + summary="Détails d'une participation", + description="Retourne une participation par son id. Endpoint public.", + parameters=[ + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="id de la participation"), + ], + responses={200: ParticipateSerializer, 404: OpenApiResponse(description="Participation introuvable")}, + ), + put=extend_schema( + tags=['Événements & Participation'], + operation_id='participations_update', + summary="Mettre à jour une participation", + description="Met à jour entièrement une participation existante. Endpoint public.", + parameters=[ + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="id de la participation"), + ], + request=ParticipateSerializer, + responses={ + 200: ParticipateSerializer, + 400: OpenApiResponse(description="Erreurs de validation"), + 404: OpenApiResponse(description="Participation introuvable"), + }, + ), + delete=extend_schema( + tags=['Événements & Participation'], + operation_id='participations_destroy', + summary="Supprimer une participation", + description="Supprime une participation par son id. Endpoint public.", + parameters=[ + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="id de la participation"), + ], + responses={ + 204: OpenApiResponse(description="Participation supprimée"), + 404: OpenApiResponse(description="Participation introuvable"), + }, + ), + post=extend_schema( + tags=['Événements & Participation'], + operation_id='participations_create_at_id', + summary="Créer une participation (route /participate/)", + description="Hérité de `CreateAPIView` : crée une nouvelle participation (l'id du " + "chemin est ignoré). Préférer `POST /participate/`. Endpoint public.", + parameters=[ + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="ignoré"), + ], + request=ParticipateSerializer, + responses={201: ParticipateSerializer, 400: OpenApiResponse(description="Erreurs de validation")}, + ), ) class ParticipateAPIView(generics.CreateAPIView): permission_classes = ( @@ -47,10 +99,23 @@ def delete(self, request, id, format=None): item.delete() return Response(status=204) -@extend_schema( - description="Endpoint allowing retrieval and creating of participation.", - request=ParticipateSerializer, - responses={201: ParticipateSerializer, 400: "serializer error"}, +@extend_schema_view( + get=extend_schema( + tags=['Événements & Participation'], + operation_id='participations_list', + summary="Lister les participations", + description="Retourne la liste paginée des participations (10 par page). Endpoint public.", + responses={200: ParticipateSerializer(many=True)}, + ), + post=extend_schema( + tags=['Événements & Participation'], + operation_id='participations_create', + summary="Créer une participation", + description="Crée une participation à un événement. Effet de bord : +1 point pour " + "l'utilisateur référencé par `user_id`. Endpoint public.", + request=ParticipateSerializer, + responses={201: ParticipateSerializer, 400: OpenApiResponse(description="Erreurs de validation")}, + ), ) class ParticipateAPIListView(generics.ListCreateAPIView): permission_classes = () diff --git a/Mapapi/views/partner_suggestion.py b/Mapapi/views/partner_suggestion.py index a7403932..2e6da346 100644 --- a/Mapapi/views/partner_suggestion.py +++ b/Mapapi/views/partner_suggestion.py @@ -4,7 +4,12 @@ from rest_framework.response import Response from rest_framework.views import APIView -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import ( + extend_schema, extend_schema_view, OpenApiParameter, OpenApiResponse, + OpenApiExample, inline_serializer, +) +from drf_spectacular.types import OpenApiTypes +from rest_framework import serializers from ..models import ( Incident, PartnerSuggestion, Collaboration, @@ -17,6 +22,21 @@ ) +@extend_schema_view(get=extend_schema( + tags=['Suggestions de partenaires'], + operation_id='suggestions_received_list', + summary="Mes suggestions reçues", + description=( + "Liste les suggestions de partenariat où l'utilisateur connecté est le partenaire " + "proposé, triées par date décroissante. Authentification requise." + ), + parameters=[ + OpenApiParameter('status', OpenApiTypes.STR, OpenApiParameter.QUERY, required=False, + enum=['pending', 'accepted', 'rejected'], + description="Filtre optionnel sur le statut de la suggestion."), + ], + responses={200: PartnerSuggestionSerializer(many=True)}, +)) class MyReceivedSuggestionsView(generics.ListAPIView): """ GET /my-suggestions/received/ — Liste les suggestions où JE suis le partenaire proposé. @@ -39,6 +59,21 @@ def get_queryset(self): return qs +@extend_schema_view(get=extend_schema( + tags=['Suggestions de partenaires'], + operation_id='suggestions_sent_list', + summary="Mes suggestions envoyées", + description=( + "Liste les suggestions de partenariat créées par l'utilisateur connecté, triées par " + "date décroissante. Authentification requise." + ), + parameters=[ + OpenApiParameter('status', OpenApiTypes.STR, OpenApiParameter.QUERY, required=False, + enum=['pending', 'accepted', 'rejected'], + description="Filtre optionnel sur le statut de la suggestion."), + ], + responses={200: PartnerSuggestionSerializer(many=True)}, +)) class MySentSuggestionsView(generics.ListAPIView): """ GET /my-suggestions/sent/ — Liste les suggestions QUE J'AI faites. @@ -58,6 +93,59 @@ def get_queryset(self): return qs +@extend_schema_view( + get=extend_schema( + tags=['Suggestions de partenaires'], + operation_id='suggestions_list', + summary="Lister les suggestions d'un incident", + description=( + "Liste les suggestions de partenariat de l'incident. Réservé au leader ou à un " + "contributeur (`IsIncidentLeaderOrContributor`)." + ), + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + OpenApiParameter('status', OpenApiTypes.STR, OpenApiParameter.QUERY, required=False, + enum=['pending', 'accepted', 'rejected'], + description="Filtre optionnel sur le statut de la suggestion."), + ], + responses={ + 200: PartnerSuggestionSerializer(many=True), + 403: OpenApiResponse(description="Ni leader ni contributeur de l'incident."), + }, + ), + post=extend_schema( + tags=['Suggestions de partenaires'], + operation_id='suggestions_create', + summary="Créer une suggestion de partenaire", + description=( + "Crée une suggestion de partenariat sur l'incident. Réservé au leader ou à un " + "contributeur (`IsIncidentLeaderOrContributor`). `suggested_by` est renseigné " + "automatiquement. Envoyer soit `suggested_partner` (User), soit " + "`suggested_organisation` (org résolue vers son admin/bureau). Refusé si l'incident " + "est clôturé." + ), + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + request=PartnerSuggestionSerializer, + responses={ + 201: PartnerSuggestionSerializer, + 400: OpenApiResponse(description="Données invalides ou incident clôturé."), + 403: OpenApiResponse(description="Ni leader ni contributeur de l'incident."), + }, + examples=[ + OpenApiExample( + 'Suggestion via organisation', + value={'suggested_organisation': '3fa85f64-5717-4562-b3fc-2c963f66afa6', + 'suggested_role': 'contributor', + 'justification': "Expertise locale pertinente"}, + request_only=True, + ), + ], + ), +) class PartnerSuggestionListCreateView(generics.ListCreateAPIView): """ GET /incidents//suggestions/ — liste (tous collaborateurs) @@ -88,6 +176,25 @@ def perform_create(self, serializer): ) +@extend_schema_view(get=extend_schema( + tags=['Suggestions de partenaires'], + operation_id='suggestions_retrieve', + summary="Détail d'une suggestion", + description=( + "Détail d'une suggestion de partenariat. Accessible à tout collaborateur de " + "l'incident (`IsIncidentCollaborator`)." + ), + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de la suggestion."), + ], + responses={ + 200: PartnerSuggestionSerializer, + 404: OpenApiResponse(description="Suggestion non trouvée."), + }, +)) class PartnerSuggestionDetailView(generics.RetrieveAPIView): """ GET /incidents//suggestions// — détail @@ -101,10 +208,28 @@ def get_queryset(self): ) -@extend_schema( - description="Accepter une suggestion de partenaire. Crée une Collaboration accepted avec le rôle suggéré.", - responses={200: PartnerSuggestionSerializer}, -) +@extend_schema_view(post=extend_schema( + tags=['Suggestions de partenaires'], + operation_id='suggestions_accept', + summary="Accepter une suggestion", + description=( + "Accepte une suggestion en attente : crée (ou met à jour) une `Collaboration` " + "`accepted` pour le partenaire avec le rôle suggéré, et passe la suggestion à " + "`accepted`. Réservé au leader (`IsIncidentLeader`)." + ), + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de la suggestion."), + ], + request=None, + responses={ + 200: PartnerSuggestionSerializer, + 400: OpenApiResponse(description="La suggestion n'est pas en attente (déjà traitée)."), + 404: OpenApiResponse(description="Suggestion non trouvée."), + }, +)) class PartnerSuggestionAcceptView(APIView): """POST /incidents//suggestions//accept/""" permission_classes = [IsAuthenticated, IsIncidentLeader] @@ -148,10 +273,27 @@ def post(self, request, incident_id, pk): return Response(serializer.data, status=status.HTTP_200_OK) -@extend_schema( - description="Rejeter une suggestion de partenaire.", - responses={200: PartnerSuggestionSerializer}, -) +@extend_schema_view(post=extend_schema( + tags=['Suggestions de partenaires'], + operation_id='suggestions_reject', + summary="Rejeter une suggestion", + description=( + "Rejette une suggestion en attente : la passe à `rejected`. Réservé au leader " + "(`IsIncidentLeader`)." + ), + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de la suggestion."), + ], + request=None, + responses={ + 200: PartnerSuggestionSerializer, + 400: OpenApiResponse(description="La suggestion n'est pas en attente (déjà traitée)."), + 404: OpenApiResponse(description="Suggestion non trouvée."), + }, +)) class PartnerSuggestionRejectView(APIView): """POST /incidents//suggestions//reject/""" permission_classes = [IsAuthenticated, IsIncidentLeader] diff --git a/Mapapi/views/prediction.py b/Mapapi/views/prediction.py index f1ebdee6..a449259b 100644 --- a/Mapapi/views/prediction.py +++ b/Mapapi/views/prediction.py @@ -1,69 +1,69 @@ -"""Prediction & chat history endpoints.""" -import json +"""Prediction endpoints. -from django.http import JsonResponse, HttpResponse -from django.views.decorators.csrf import csrf_exempt +The legacy session-keyed chat-history endpoints (`history_list`, `add_history`, +`ChatHistoryViewByIncident`) were removed — they were unauthenticated, leaked all +chat rows, and used a spoofable client `session_id`. The incident AI chat is now +served by `IncidentChatView` (`incidents//chat/`), scoped to the user. +""" +from rest_framework import generics -from rest_framework import status, generics -from rest_framework.response import Response - -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter +from drf_spectacular.types import OpenApiTypes from ..serializer import * -from .common import CustomPageNumberPagination -@extend_schema( - description="Endpoint for retrieving all predictions", +@extend_schema_view(get=extend_schema( + tags=['Prédiction & IA'], + operation_id='predictions_list', + summary="Lister toutes les prédictions", + description="Retourne la liste de toutes les prédictions IA (`Prediction`). " + "Endpoint public (aucune authentification requise).", responses={200: PredictionSerializer(many=True)}, -) +)) class PredictionView(generics.ListAPIView): permission_classes = () queryset = Prediction.objects.all() serializer_class = PredictionSerializer -def history_list(request): - histories = ChatHistory.objects.all() # Retrieve all history records - data = {"histories": list(histories.values("session_id", "question", "answer"))} - return JsonResponse(data) -@csrf_exempt # Disable CSRF token for this view for simplicity -def add_history(request): - if request.method == "POST": - try: - data = json.loads(request.body) - history = ChatHistory( - user_id=data['session_id'], - question=data['question'], - answer=data['answer'] - ) - history.save() - return JsonResponse({"message": "History added successfully!"}, status=201) - except (KeyError, TypeError) as e: - return JsonResponse({"error": str(e)}, status=400) - else: - return HttpResponse(status=405) # Method Not Allowed +@extend_schema_view(get=extend_schema( + tags=['Prédiction & IA'], + operation_id='predictions_retrieve', + summary="Prédiction(s) par prediction_id", + description="Retourne la/les prédiction(s) dont le champ `prediction_id` correspond " + "à l'identifiant fourni (résultat sous forme de liste). Endpoint public.", + parameters=[ + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="prediction_id de la prédiction"), + ], + responses={200: PredictionSerializer(many=True)}, +)) class PredictionViewByID(generics.ListAPIView): permission_classes = () serializer_class = PredictionSerializer def get_queryset(self): prediction_id = self.kwargs['id'] - queryset = Prediction.objects.filter(prediction_id=prediction_id) - return queryset + return Prediction.objects.filter(prediction_id=prediction_id) + + +@extend_schema_view(get=extend_schema( + tags=['Prédiction & IA'], + operation_id='predictions_by_incident', + summary="Prédictions d'un incident", + description="Retourne la/les prédiction(s) liée(s) à l'incident dont l'id est fourni " + "(consommé par le frontend `getIncidentPredictionService`). Endpoint public.", + parameters=[ + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="id de l'incident"), + ], + responses={200: PredictionSerializer(many=True)}, +)) class PredictionViewByIncidentID(generics.ListAPIView): permission_classes = () serializer_class = PredictionSerializer def get_queryset(self): incident_id = self.kwargs['id'] - queryset = Prediction.objects.filter(incident_id=incident_id) - return queryset -class ChatHistoryViewByIncident(generics.ListAPIView): - permission_classes = () - serializer_class = ChatHistorySerializer - - def get_queryset(self): - session_id = self.kwargs['id'] - queryset = ChatHistory.objects.filter(session_id=session_id) - return queryset + return Prediction.objects.filter(incident_id=incident_id) diff --git a/Mapapi/views/rapport.py b/Mapapi/views/rapport.py index b0ab74e5..65218dfb 100644 --- a/Mapapi/views/rapport.py +++ b/Mapapi/views/rapport.py @@ -8,16 +8,53 @@ from rest_framework.pagination import PageNumberPagination from rest_framework.response import Response -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import ( + extend_schema, extend_schema_view, OpenApiParameter, OpenApiResponse, + OpenApiExample, inline_serializer, +) +from drf_spectacular.types import OpenApiTypes +from rest_framework import serializers from ..serializer import * from .common import CustomPageNumberPagination -@extend_schema( - description="Endpoint allowing retrieval, updating, and deletion of a rapport.", - request=RapportSerializer, - responses={200: RapportSerializer, 404: "rapport not found"}, +@extend_schema_view( + get=extend_schema( + tags=['Rapports'], + operation_id='rapports_retrieve', + summary="Détail d'un rapport", + description="Retourne un rapport par son identifiant. Accès public (aucune permission requise).", + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant du rapport")], + request=None, + responses={200: RapportSerializer, 404: OpenApiResponse(description="Rapport introuvable")}, + ), + put=extend_schema( + tags=['Rapports'], + operation_id='rapports_update', + summary="Mettre à jour un rapport", + description="Met à jour partiellement un rapport. Si 'disponible' est vrai ou si un 'file' est fourni, le rapport est marqué disponible et un e-mail est envoyé au demandeur. Accès public.", + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant du rapport")], + request=RapportSerializer, + responses={ + 200: RapportSerializer, + 400: OpenApiResponse(description="Données invalides"), + 404: OpenApiResponse(description="Rapport introuvable"), + }, + ), + delete=extend_schema( + tags=['Rapports'], + operation_id='rapports_destroy', + summary="Supprimer un rapport", + description="Supprime définitivement un rapport. Accès public (aucune permission requise).", + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant du rapport")], + request=None, + responses={ + 204: OpenApiResponse(description="Rapport supprimé"), + 404: OpenApiResponse(description="Rapport introuvable"), + }, + ), + post=extend_schema(exclude=True), ) class RapportAPIView(generics.CreateAPIView): queryset = Rapport.objects.all() @@ -76,10 +113,23 @@ def delete(self, request, id, format=None): item.delete() return Response(status=204) -@extend_schema( - description="Endpoint allowing retrieval and creating of a rapport.", - request=RapportSerializer, - responses={201: RapportSerializer, 400: "Error"}, +@extend_schema_view( + get=extend_schema( + tags=['Rapports'], + operation_id='rapports_list', + summary="Lister les rapports", + description="Retourne la liste paginée de tous les rapports (l'utilisateur demandeur est imbriqué). Accès public.", + request=None, + responses={200: RapportGetSerializer(many=True)}, + ), + post=extend_schema( + tags=['Rapports'], + operation_id='rapports_create', + summary="Créer une commande de rapport", + description="Crée un rapport et notifie les administrateurs par e-mail. Accès public.", + request=RapportSerializer, + responses={201: RapportSerializer, 400: OpenApiResponse(description="Données invalides")}, + ), ) class RapportAPIListView(generics.CreateAPIView): queryset = Rapport.objects.all() @@ -109,10 +159,17 @@ def post(self, request, format=None): return Response(serializer.data, status=201) return Response(serializer.errors, status=400) -@extend_schema( - description="Endpoint allowing retrieval a rapport by user.", - request=RapportSerializer, - responses={200: RapportSerializer, 404: "rapport not found"}, +@extend_schema_view( + get=extend_schema( + tags=['Rapports'], + operation_id='rapports_by_user', + summary="Rapports d'un utilisateur", + description="Retourne tous les rapports commandés par l'utilisateur donné. Accès public.", + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, description="Identifiant de l'utilisateur demandeur")], + request=None, + responses={200: RapportGetSerializer(many=True)}, + ), + post=extend_schema(exclude=True), ) class RapportByUserAPIView(generics.CreateAPIView): permission_classes = ( @@ -128,10 +185,41 @@ def get(self, request, id, format=None, **kwargs): except Rapport.DoesNotExist: return Response(status=404) -@extend_schema( - description="Endpoint allowing retrieval and creating a rapport on zone.", - request=RapportSerializer, - responses={200: RapportSerializer, 404: "rapport not found"}, +@extend_schema_view( + get=extend_schema( + tags=['Rapports'], + operation_id='rapports_zone_list', + summary="Lister les rapports de zone", + description="Retourne la liste paginée des rapports de type 'zone'. Accès public.", + request=None, + responses={200: RapportGetSerializer(many=True)}, + ), + post=extend_schema( + tags=['Rapports'], + operation_id='rapports_zone_create', + summary="Créer un rapport de zone", + description="Crée un rapport de type 'zone' et y rattache automatiquement tous les incidents de la zone, puis notifie les administrateurs. Requiert type='zone' et le champ 'zone'. Accès public.", + request=RapportSerializer, + responses={ + 200: inline_serializer( + name='RapportsZoneCreateResponse', + fields={ + 'status': serializers.CharField(), + 'message': serializers.CharField(), + 'data': RapportSerializer(), + }, + ), + 400: OpenApiResponse(description="Données invalides"), + 404: OpenApiResponse(description="type différent de 'zone' ou champ 'zone' manquant"), + }, + examples=[ + OpenApiExample( + 'Rapport de zone', + value={'type': 'zone', 'zone': 'Bamako', 'details': 'Synthèse de la zone'}, + request_only=True, + ), + ], + ), ) class RapportOnZoneAPIView(generics.CreateAPIView): queryset = Rapport.objects.all() diff --git a/Mapapi/views/task.py b/Mapapi/views/task.py index cfc3a885..8aadb3c8 100644 --- a/Mapapi/views/task.py +++ b/Mapapi/views/task.py @@ -4,13 +4,57 @@ from rest_framework.response import Response from rest_framework.views import APIView -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import ( + extend_schema, extend_schema_view, OpenApiParameter, OpenApiResponse, + OpenApiExample, inline_serializer, +) +from drf_spectacular.types import OpenApiTypes +from rest_framework import serializers -from ..models import Incident, IncidentTask, TASK_DONE, TASK_FAILED, Collaboration +from ..models import Incident, IncidentTask, TASK_PENDING, TASK_DONE, TASK_FAILED, Collaboration from ..serializer import IncidentTaskSerializer -from ..permissions import IsIncidentLeaderOrReadOnlyCollaborator, IsIncidentLeader, IsIncidentCollaborator, IsIncidentLeaderOrContributor +from ..permissions import IsIncidentLeader, IsIncidentLeaderOrContributor +@extend_schema_view( + get=extend_schema( + tags=['Tâches'], + operation_id='tasks_list', + summary="Lister les tâches d'un incident", + description=( + "Liste les tâches de l'incident, triées par date de début. Réservé au " + "leader ou à un collaborateur accepté (`IsIncidentLeaderOrContributor`)." + ), + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + responses={ + 200: IncidentTaskSerializer(many=True), + 403: OpenApiResponse(description="Ni leader ni contributeur de l'incident."), + }, + ), + post=extend_schema( + tags=['Tâches'], + operation_id='tasks_create', + summary="Créer une tâche", + description=( + "Crée une tâche sur l'incident. Réservé au leader ou à un contributeur " + "(`IsIncidentLeaderOrContributor`). `created_by` est renseigné automatiquement ; " + "une tâche créée par le leader est auto-confirmée. Refusé si l'incident est clôturé." + ), + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + ], + request=IncidentTaskSerializer, + responses={ + 201: IncidentTaskSerializer, + 400: OpenApiResponse(description="Données invalides ou incident clôturé."), + 403: OpenApiResponse(description="Ni leader ni contributeur de l'incident."), + }, + ), +) class IncidentTaskListCreateView(generics.ListCreateAPIView): """ GET /incidents//tasks/ — liste des tâches (collaborateurs acceptés) @@ -35,10 +79,27 @@ def perform_create(self, serializer): ) -@extend_schema( - description="Confirmer une tâche créée par un contributeur (Leader uniquement).", - responses={200: IncidentTaskSerializer}, -) +@extend_schema_view(post=extend_schema( + tags=['Tâches'], + operation_id='tasks_confirm', + summary="Confirmer une tâche (leader)", + description=( + "Confirme une tâche créée par un contributeur (`is_confirmed=True`). Seules les " + "tâches confirmées comptent dans la progression de l'incident. Réservé au leader " + "(`IsIncidentLeader`)." + ), + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de la tâche."), + ], + request=None, + responses={ + 200: IncidentTaskSerializer, + 404: OpenApiResponse(description="Tâche non trouvée."), + }, +)) class IncidentTaskConfirmView(APIView): """POST /incidents//tasks//confirm/""" permission_classes = [IsAuthenticated, IsIncidentLeader] @@ -56,15 +117,98 @@ def post(self, request, incident_id, pk): return Response(serializer.data, status=status.HTTP_200_OK) +@extend_schema_view( + get=extend_schema( + tags=['Tâches'], + operation_id='tasks_retrieve', + summary="Détail d'une tâche", + description="Détail d'une tâche. Accessible à tout collaborateur accepté de l'incident.", + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de la tâche."), + ], + responses={ + 200: IncidentTaskSerializer, + 404: OpenApiResponse(description="Tâche non trouvée."), + }, + ), + put=extend_schema( + tags=['Tâches'], + operation_id='tasks_update', + summary="Modifier une tâche", + description=( + "Remplace une tâche (PUT). Accessible au leader OU à un contributeur accepté " + "(`IsIncidentLeaderOrContributor`) ; lecture seule pour les observateurs. Refusé " + "si l'incident est clôturé." + ), + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de la tâche."), + ], + request=IncidentTaskSerializer, + responses={ + 200: IncidentTaskSerializer, + 400: OpenApiResponse(description="Données invalides ou incident clôturé."), + 403: OpenApiResponse(description="Permission refusée (non leader)."), + 404: OpenApiResponse(description="Tâche non trouvée."), + }, + ), + patch=extend_schema( + tags=['Tâches'], + operation_id='tasks_partial_update', + summary="Modifier partiellement une tâche", + description=( + "Modification partielle d'une tâche (PATCH). Accessible au leader OU à un " + "contributeur accepté (`IsIncidentLeaderOrContributor`) ; lecture seule pour " + "les observateurs." + ), + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de la tâche."), + ], + request=IncidentTaskSerializer, + responses={ + 200: IncidentTaskSerializer, + 400: OpenApiResponse(description="Données invalides ou incident clôturé."), + 403: OpenApiResponse(description="Permission refusée (non leader)."), + 404: OpenApiResponse(description="Tâche non trouvée."), + }, + ), + delete=extend_schema( + tags=['Tâches'], + operation_id='tasks_destroy', + summary="Supprimer une tâche", + description="Supprime une tâche. Accessible au leader OU à un contributeur accepté (`IsIncidentLeaderOrContributor`).", + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de la tâche."), + ], + responses={ + 204: OpenApiResponse(description="Tâche supprimée."), + 403: OpenApiResponse(description="Permission refusée (non leader)."), + 404: OpenApiResponse(description="Tâche non trouvée."), + }, + ), +) class IncidentTaskDetailView(generics.RetrieveUpdateDestroyAPIView): """ - GET /incidents//tasks// — détail - PUT /incidents//tasks// — modifier (leader) - PATCH /incidents//tasks// — modifier partiel (leader) - DELETE /incidents//tasks// — supprimer (leader) + GET /incidents//tasks// — détail (tout collaborateur accepté) + PUT /incidents//tasks// — modifier (leader ou contributeur) + PATCH /incidents//tasks// — modifier partiel (leader ou contributeur) + DELETE /incidents//tasks// — supprimer (leader ou contributeur) """ serializer_class = IncidentTaskSerializer - permission_classes = [IsAuthenticated, IsIncidentLeaderOrReadOnlyCollaborator] + # Leader OU contributeur accepté peut modifier/supprimer (cohérent avec la création + # et la complétion). Les observateurs gardent un accès en lecture seule. + permission_classes = [IsAuthenticated, IsIncidentLeaderOrContributor] def get_queryset(self): return IncidentTask.objects.filter( @@ -72,15 +216,40 @@ def get_queryset(self): ) -@extend_schema( - description="Marquer une tâche comme terminée (done). Requiert proof_image ou proof_video.", - responses={200: IncidentTaskSerializer}, -) +@extend_schema_view(post=extend_schema( + tags=['Tâches'], + operation_id='tasks_complete', + summary="Marquer une tâche terminée", + description=( + "Passe la tâche à l'état `done`. Au moins une preuve est requise " + "(`proof_image` ou `proof_video`), envoyée en `multipart/form-data`. Accessible au " + "leader OU à un contributeur accepté (`IsIncidentLeaderOrContributor`) — celui qui " + "fait le travail peut fournir la preuve. Déclenche le recalcul de la progression." + ), + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de la tâche."), + ], + request=inline_serializer( + name='TaskCompleteRequest', + fields={ + 'proof_image': serializers.ImageField(required=False), + 'proof_video': serializers.FileField(required=False), + }, + ), + responses={ + 200: IncidentTaskSerializer, + 400: OpenApiResponse(description="Aucune preuve fournie (proof_image ou proof_video requis)."), + 404: OpenApiResponse(description="Tâche non trouvée."), + }, +)) class IncidentTaskCompleteView(APIView): - """POST /incidents//tasks//complete/ - - Le leader OU un contributeur accepté peut marquer une tâche comme terminée. - """ + """POST /incidents//tasks//complete/""" + # Leader OU contributeur accepté : un collaborateur qui fait le travail peut + # marquer la tâche terminée en uploadant une preuve. Le leader garde la main via + # la confirmation (`is_confirmed`) qui gouverne la progression de l'incident. permission_classes = [IsAuthenticated, IsIncidentLeaderOrContributor] def post(self, request, incident_id, pk): @@ -110,13 +279,40 @@ def post(self, request, incident_id, pk): return Response(serializer.data, status=status.HTTP_200_OK) -@extend_schema( - description="Marquer une tâche comme échouée (failed). Requiert failure_reason.", - responses={200: IncidentTaskSerializer}, -) +@extend_schema_view(post=extend_schema( + tags=['Tâches'], + operation_id='tasks_fail', + summary="Marquer une tâche échouée", + description=( + "Passe la tâche à l'état `failed`. Un motif `failure_reason` est requis. Accessible " + "au leader OU à un contributeur accepté (`IsIncidentLeaderOrContributor`). " + "Déclenche le recalcul de la progression de l'incident." + ), + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de la tâche."), + ], + request=inline_serializer( + name='TaskFailRequest', + fields={'failure_reason': serializers.CharField()}, + ), + responses={ + 200: IncidentTaskSerializer, + 400: OpenApiResponse(description="Motif d'échec (failure_reason) manquant."), + 404: OpenApiResponse(description="Tâche non trouvée."), + }, + examples=[ + OpenApiExample('Motif', value={'failure_reason': "Accès au site impossible"}, + request_only=True), + ], +)) class IncidentTaskFailView(APIView): """POST /incidents//tasks//fail/""" - permission_classes = [IsAuthenticated, IsIncidentLeader] + # Leader OU contributeur accepté (idem complete : celui qui fait le travail peut + # signaler l'échec avec un motif). + permission_classes = [IsAuthenticated, IsIncidentLeaderOrContributor] def post(self, request, incident_id, pk): try: @@ -137,3 +333,49 @@ def post(self, request, incident_id, pk): serializer = IncidentTaskSerializer(task) return Response(serializer.data, status=status.HTTP_200_OK) + + +@extend_schema_view(post=extend_schema( + tags=['Tâches'], + operation_id='tasks_relaunch', + summary="Relancer une tâche échouée", + description=( + "Relance une tâche en état `failed` (spec D11) : la repasse en `pending` (À faire) " + "tout en CONSERVANT le motif d'échec (`failure_reason`). Réservé au leader " + "(`IsIncidentLeader`). Déclenche le recalcul de la progression de l'incident." + ), + parameters=[ + OpenApiParameter('incident_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'incident."), + OpenApiParameter('task_id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de la tâche."), + ], + request=None, + responses={ + 200: IncidentTaskSerializer, + 400: OpenApiResponse(description="La tâche n'est pas en échec (seul `failed` est relançable)."), + 404: OpenApiResponse(description="Tâche non trouvée."), + }, +)) +class IncidentTaskRelaunchView(APIView): + """POST /incidents//tasks//relaunch/""" + permission_classes = [IsAuthenticated, IsIncidentLeader] + + def post(self, request, incident_id, task_id): + try: + task = IncidentTask.objects.get(pk=task_id, incident_id=incident_id) + except IncidentTask.DoesNotExist: + return Response({"error": "Tâche non trouvée."}, status=status.HTTP_404_NOT_FOUND) + + if task.state != TASK_FAILED: + return Response( + {"error": "Seule une tâche échouée (failed) peut être relancée."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Repasse en 'pending' ; le motif d'échec est volontairement CONSERVÉ (spec D11). + task.state = TASK_PENDING + task.save() # le save() déclenche incident.update_progress() + + serializer = IncidentTaskSerializer(task) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/Mapapi/views/user.py b/Mapapi/views/user.py index b2ec28f4..8c405d54 100644 --- a/Mapapi/views/user.py +++ b/Mapapi/views/user.py @@ -2,7 +2,6 @@ import os import time import requests -from twilio.rest import Client from datetime import timedelta import pyotp @@ -17,7 +16,9 @@ from django.views.decorators.csrf import csrf_exempt from rest_framework import status, generics, permissions -from rest_framework.decorators import api_view +from rest_framework.decorators import api_view, permission_classes +from ..roles import is_super_admin, is_org_admin +from ..models import ORG_ROLE_BUREAU, ORG_ROLE_FIELD from rest_framework.exceptions import ValidationError, NotFound from rest_framework.pagination import PageNumberPagination from rest_framework.permissions import IsAuthenticated @@ -25,23 +26,46 @@ from rest_framework.views import APIView from rest_framework_simplejwt.tokens import RefreshToken, AccessToken -from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiExample +from drf_spectacular.utils import ( + extend_schema, extend_schema_view, OpenApiParameter, OpenApiResponse, + OpenApiExample, inline_serializer, +) +from drf_spectacular.types import OpenApiTypes from ..serializer import * +from rest_framework import serializers from ..Send_mails import send_email from .common import CustomPageNumberPagination, get_random, logger -@extend_schema( - description="Endpoint for retrieval token by email", - request=UserSerializer, - responses={201: UserSerializer, 400: "Bad request"} -) class GetTokenByMailView(generics.CreateAPIView): permission_classes = () queryset = User.objects.all() serializer_class = UserSerializer - + + @extend_schema( + tags=['Authentification'], + operation_id='auth_get_token_by_mail', + summary="Obtenir un token par email", + description="Émet un token d'accès JWT à partir d'un email seul, sans " + "vérification du mot de passe (faiblesse de sécurité connue). " + "Endpoint public.", + request=inline_serializer( + name='GetTokenByMailRequest', + fields={'email': serializers.EmailField()}, + ), + responses={ + 201: inline_serializer( + name='GetTokenByMailResponse', + fields={ + 'status': serializers.CharField(), + 'message': serializers.CharField(), + 'token': serializers.CharField(), + }, + ), + 404: OpenApiResponse(description="Aucun utilisateur avec cet email."), + }, + ) def post(self, request, *args, **kwargs): try: item = User.objects.get(email=request.data['email']) @@ -82,19 +106,39 @@ def login_view(request): else: return Response({'error': 'Invalid credentials'}, status=status.HTTP_401_UNAUTHORIZED) -@api_view(['GET', 'POST']) @extend_schema( - description="Endpoint allowing retrieval and creation of users. Retrieves all users from the database and register a new user", - request=UserSerializer, - responses={201: UserSerializer, 400: "Bad request"}, - parameters=[ - OpenApiParameter(name='first_name', description='First name of the user', required=True, type=str), - OpenApiParameter(name='last_name', description='Last name of the user', required=True, type=str), - OpenApiParameter(name='phone', description='Phone number of the user', required=False, type=str), - OpenApiParameter(name='address', description='Address of the user', required=False, type=str), - OpenApiParameter(name='email', description='Email of the user', required=True, type=str), - OpenApiParameter(name='password', description='Password of the user', required=True, type=str), - ], + methods=['GET'], + tags=['Utilisateurs & Profil'], + operation_id='users_register_list', + summary="Lister tous les utilisateurs", + description="Retourne la liste complète des utilisateurs. Endpoint public.", + request=None, + responses={200: UserSerializer(many=True)}, +) +@extend_schema( + methods=['POST'], + tags=['Authentification'], + operation_id='auth_register', + summary="Inscription d'un utilisateur", + description="Crée un compte utilisateur et retourne l'utilisateur créé avec " + "un couple de tokens JWT (connexion automatique). Endpoint public.", + request=UserRegisterSerializer, + responses={ + 201: inline_serializer( + name='RegisterResponse', + fields={ + 'user': UserRegisterSerializer(), + 'token': inline_serializer( + name='RegisterTokenPair', + fields={ + 'refresh': serializers.CharField(), + 'access': serializers.CharField(), + }, + ), + }, + ), + 400: OpenApiResponse(description="Données invalides."), + }, examples=[ OpenApiExample(name='User', value={ 'first_name': 'Annoura', @@ -104,8 +148,9 @@ def login_view(request): 'email': 'john@example.com', 'password': 'secret_password' }) - ] + ], ) +@api_view(['GET', 'POST']) def UserRegisterView(request): if request.method == 'GET': users = User.objects.all() @@ -126,12 +171,86 @@ def UserRegisterView(request): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) -@api_view(['GET', 'PUT', 'DELETE']) @extend_schema( - description="Endpoint allowing retrieval, updating, and deletion of an user.", - request=UserSerializer, - responses={200: UserSerializer, 404: "user not found"}, + methods=['GET'], + tags=['Utilisateurs & Profil'], + operation_id='users_retrieve', + summary="Récupérer un utilisateur", + description="Retourne un utilisateur par son identifiant. Endpoint public.", + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'utilisateur")], + request=None, + responses={200: UserSerializer, 404: OpenApiResponse(description="Utilisateur introuvable.")}, +) +@extend_schema( + methods=['PUT'], + tags=['Utilisateurs & Profil'], + operation_id='users_update', + summary="Mettre à jour un utilisateur", + description="Met à jour partiellement un utilisateur. Si 'password' est " + "fourni, il est haché avant enregistrement. Endpoint public.", + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'utilisateur")], + request=UserPutSerializer, + responses={ + 200: UserPutSerializer, + 400: OpenApiResponse(description="Données invalides."), + 404: OpenApiResponse(description="Utilisateur introuvable."), + }, ) +@extend_schema( + methods=['DELETE'], + tags=['Utilisateurs & Profil'], + operation_id='users_delete', + summary="Supprimer un utilisateur", + description="Supprime définitivement un utilisateur. Endpoint public.", + parameters=[OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Identifiant de l'utilisateur")], + request=None, + responses={ + 204: OpenApiResponse(description="Utilisateur supprimé."), + 404: OpenApiResponse(description="Utilisateur introuvable."), + }, +) +def _can_edit_user(actor, target): + """Modification autorisée : soi-même, l'admin de SON organisation, ou le Super Admin.""" + if is_super_admin(actor): + return True + if actor.id == target.id: + return True + actor_org = getattr(actor, 'organisation_member_id', None) + return bool( + actor_org + and actor_org == getattr(target, 'organisation_member_id', None) + and is_org_admin(actor) + and not getattr(target, 'is_superuser', False) + ) + + +def _can_delete_user(actor, target): + """Suppression autorisée : + - Super Admin : n'importe quel utilisateur. + - Admin d'organisation : les membres de SON organisation (jamais un Super Admin). + - Agent de bureau : uniquement les agents de TERRAIN de son organisation (jamais un admin). + - Autres rôles : personne. + """ + if is_super_admin(actor): + return True + if getattr(target, 'is_superuser', False): + return False # seul un Super Admin peut supprimer un Super Admin + actor_org = getattr(actor, 'organisation_member_id', None) + same_org = bool(actor_org and actor_org == getattr(target, 'organisation_member_id', None)) + if not same_org: + return False + if is_org_admin(actor): + return True + if getattr(actor, 'org_role', None) == ORG_ROLE_BUREAU: + return target.org_role == ORG_ROLE_FIELD + return False + + +@api_view(['GET', 'PUT', 'DELETE']) +@permission_classes([IsAuthenticated]) def user_api_view(request, id): if request.method == 'GET': try: @@ -146,6 +265,9 @@ def user_api_view(request, id): item = User.objects.get(pk=id) except User.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) + if not _can_edit_user(request.user, item): + return Response({'error': "Vous n'avez pas les droits pour modifier cet utilisateur."}, + status=status.HTTP_403_FORBIDDEN) data = request.data.copy() if "password" in request.data: item.set_password(request.data['password']) @@ -162,16 +284,31 @@ def user_api_view(request, id): item = User.objects.get(pk=id) except User.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) + if not _can_delete_user(request.user, item): + return Response({'error': "Vous n'avez pas les droits pour supprimer cet utilisateur."}, + status=status.HTTP_403_FORBIDDEN) item.delete() return Response(status=status.HTTP_204_NO_CONTENT) -@extend_schema( - description="Endpoint allowing retrieval and creation of users. Retrieves all users from the database, " - "sorts them by primary identifier, paginates the results, and serializes them before returning " - "the paginated response to the client. For creation, deserializes the request data and saves it " - "to the database. Additionally, sends emails to users based on the type of account created.", - request=UserSerializer, - responses={201: UserSerializer, 400: "Bad request"}, +@extend_schema_view( + get=extend_schema( + tags=['Utilisateurs & Profil'], + operation_id='users_list', + summary="Lister les utilisateurs (paginé)", + description="Retourne la liste paginée des utilisateurs triés par " + "identifiant. Endpoint public.", + responses={200: UserSerializer(many=True)}, + ), + post=extend_schema( + tags=['Utilisateurs & Profil'], + operation_id='users_create', + summary="Créer un utilisateur", + description="Crée un utilisateur. Accepte une liste optionnelle 'zones' " + "(ids) et envoie un email de bienvenue lorsque 'user_type' " + "est fourni. Endpoint public.", + request=UserSerializer, + responses={201: UserSerializer, 400: OpenApiResponse(description="Données invalides.")}, + ), ) class UserAPIListView(generics.CreateAPIView): permission_classes = () @@ -223,10 +360,14 @@ def post(self, request, format=None): return Response(serializer.errors, status=400) -@extend_schema( - description="Endpoint allowing retrivial of citizen.", - responses={200: UserSerializer, 404: "Citizen not found"}, -) +@extend_schema_view(get=extend_schema( + tags=['Utilisateurs & Profil'], + operation_id='users_citizens_list', + summary="Lister les citoyens", + description="Retourne la liste paginée des utilisateurs de type 'citizen' " + "(10 par page). Endpoint public.", + responses={200: UserSerializer(many=True)}, +)) class CitizenAPIListView(generics.ListAPIView): permission_classes = () queryset = User.objects.filter(user_type='citizen').order_by('pk') @@ -237,10 +378,26 @@ def get(self, request, *args, **kwargs): self.pagination_class.page_size = 10 # Modifier ici pour définir la taille de la page return self.list(request, *args, **kwargs) -@extend_schema( - description="Endpoint allowing retrival of user.", - responses={200: UserSerializer, 404: "User not found"}, -) +@extend_schema_view(get=extend_schema( + tags=['Utilisateurs & Profil'], + operation_id='users_me', + summary="Utilisateur courant", + description="Retourne l'utilisateur authentifié, enveloppé dans " + "{status, message, data}. Les champs sensibles (mot de passe, " + "otp, pin, token de vérification) ne sont jamais exposés. " + "Authentification requise.", + responses={ + 200: inline_serializer( + name='CurrentUserResponse', + fields={ + 'status': serializers.CharField(), + 'message': serializers.CharField(), + 'data': UserSerializer(), + }, + ), + 400: OpenApiResponse(description="Utilisateur introuvable."), + }, +)) class UserRetrieveView(generics.RetrieveAPIView): queryset = User.objects.all() serializer_class = UserSerializer @@ -265,9 +422,39 @@ def get(self, request, *args, **kwargs): "data": data }, status=status.HTTP_200_OK) -@extend_schema( - description="Endpoint for changing password", - responses={200: ChangePasswordSerializer, 400: "bad request"} +@extend_schema_view( + put=extend_schema( + tags=['Authentification'], + operation_id='auth_change_password', + summary="Changer le mot de passe", + description="Change le mot de passe de l'utilisateur authentifié après " + "vérification de l'ancien mot de passe. Authentification requise.", + request=ChangePasswordSerializer, + responses={ + 200: inline_serializer( + name='ChangePasswordResponse', + fields={ + 'status': serializers.CharField(), + 'code': serializers.IntegerField(), + 'message': serializers.CharField(), + 'data': serializers.JSONField(), + }, + ), + 400: OpenApiResponse(description="Ancien mot de passe incorrect ou données invalides."), + }, + ), + patch=extend_schema( + tags=['Authentification'], + operation_id='auth_change_password_partial', + summary="Changer le mot de passe (partiel)", + description="Variante PATCH du changement de mot de passe. " + "Authentification requise.", + request=ChangePasswordSerializer, + responses={ + 200: OpenApiResponse(description="Mot de passe mis à jour."), + 400: OpenApiResponse(description="Ancien mot de passe incorrect ou données invalides."), + }, + ), ) class ChangePasswordView(generics.UpdateAPIView): """ use postman to test give 4 fields new_password new_password_confirm email code post methode""" @@ -302,9 +489,21 @@ def update(self, request, *args, **kwargs): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) -@extend_schema( - description="Endpoint for updating points of users based on their activities.", - responses={200: "Points updated successfully."}, +@extend_schema_view( + get=extend_schema( + tags=['Utilisateurs & Profil'], + operation_id='users_update_points', + summary="Recalculer les points des utilisateurs", + description="Recalcule et met à jour le score de points de tous les " + "utilisateurs à partir de leurs incidents, événements et " + "participations. Tâche de maintenance, endpoint public.", + responses={ + 200: inline_serializer( + name='UpdatePointsResponse', + fields={'status': serializers.CharField(), 'message': serializers.CharField()}, + ), + }, + ), ) class UpdatePointAPIListView(generics.CreateAPIView): permission_classes = ( @@ -334,6 +533,24 @@ class PasswordResetView(generics.CreateAPIView): ) queryset = User.objects.all() serializer_class = ResetPasswordSerializer + + @extend_schema( + tags=['Authentification'], + operation_id='auth_password_reset_confirm', + summary="Confirmer la réinitialisation du mot de passe", + description="Réinitialise le mot de passe à partir de l'email, du code " + "reçu et du nouveau mot de passe (confirmé). Le code expire " + "après ~1h. Endpoint public.", + request=ResetPasswordSerializer, + responses={ + 201: inline_serializer( + name='PasswordResetConfirmResponse', + fields={'status': serializers.CharField(), 'message': serializers.CharField()}, + ), + 400: OpenApiResponse(description="Code/email manquant, mots de passe non " + "concordants, code expiré ou introuvable."), + }, + ) def post(self, request, *args, **kwargs): print("✅ post() de PasswordResetView appelée") if 'code' not in request.data or request.data['code'] is None: @@ -403,11 +620,6 @@ def post(self, request, *args, **kwargs): "message": "item successfully saved", }, status=status.HTTP_201_CREATED) -@extend_schema( - description="Endpoint for resetting user password.", - request=ResetPasswordSerializer, - responses={400: "Bad Request"}, -) class PasswordResetRequestView(generics.CreateAPIView): """ use postman to test give field email post methode""" permission_classes = ( @@ -416,6 +628,21 @@ class PasswordResetRequestView(generics.CreateAPIView): queryset = User.objects.all() serializer_class = RequestPasswordSerializer + @extend_schema( + tags=['Authentification'], + operation_id='auth_password_reset_request', + summary="Demander un code de réinitialisation", + description="Génère un code de réinitialisation à 7 caractères (valide " + "~1h) et l'envoie par email à l'utilisateur. Endpoint public.", + request=RequestPasswordSerializer, + responses={ + 201: inline_serializer( + name='PasswordResetRequestResponse', + fields={'status': serializers.CharField(), 'message': serializers.CharField()}, + ), + 400: OpenApiResponse(description="Email manquant ou utilisateur introuvable."), + }, + ) def post(self, request, *args, **kwargs): if 'email' not in request.data or request.data['email'] is None: return Response({ @@ -456,10 +683,6 @@ class PhoneOTPView(generics.CreateAPIView): permission_classes = () queryset = PhoneOTP.objects.all() serializer_class = PhoneOTPSerializer - @extend_schema( - description="Endpoint for generate otp code", - responses={200: "generate", 400: "Bad request"}, - ) def generate_otp(self, phone_number): secret_key = pyotp.random_base32() otp = pyotp.TOTP(secret_key) @@ -469,9 +692,23 @@ def generate_otp(self, phone_number): return otp_code_str @extend_schema( - description="Endpoint for retrivial a code otp", - request=PhoneOTPSerializer, - responses={200: PhoneOTPSerializer, 404: "Not Found"}, + tags=['Authentification'], + operation_id='auth_phone_otp_get', + summary="Récupérer le code OTP d'un numéro", + description="Retourne le dernier code OTP enregistré pour le numéro de " + "téléphone fourni en paramètre de requête. Endpoint public.", + parameters=[OpenApiParameter('phone_number', OpenApiTypes.STR, + OpenApiParameter.QUERY, required=True, + description="Numéro de téléphone")], + request=None, + responses={ + 200: inline_serializer( + name='PhoneOtpGetResponse', + fields={'otp_code': serializers.CharField()}, + ), + 400: OpenApiResponse(description="Numéro de téléphone manquant."), + 404: OpenApiResponse(description="Aucun code OTP pour ce numéro."), + }, ) def get(self, request, *args, **kwargs): phone_number = request.query_params.get('phone_number') @@ -484,52 +721,76 @@ def get(self, request, *args, **kwargs): return Response({'otp_code': otp_instance.otp_code}, status=status.HTTP_200_OK) @extend_schema( - description="Endpoint for creating a code otp", + tags=['Authentification'], + operation_id='auth_phone_otp_create', + summary="Générer et envoyer un code OTP", + description="Génère un code OTP pour le numéro fourni et l'envoie par " + "SMS (Orange Mali). Endpoint public.", request=PhoneOTPSerializer, - responses={201: PhoneOTPSerializer, 400: "Bad request"}, + responses={ + 201: inline_serializer( + name='PhoneOtpCreateResponse', + fields={'otp_code': serializers.CharField()}, + ), + 400: OpenApiResponse(description="Numéro de téléphone manquant."), + 500: OpenApiResponse(description="Échec de l'envoi du SMS."), + }, ) def post(self, request, *args, **kwargs): phone_number = request.data.get('phone_number') if not phone_number: raise ValidationError("Le numéro de téléphone est requis.") otp_code = self.generate_otp(phone_number) - if send_sms(phone_number, f"Votre code de vérification OTP est : {otp_code}"): + if send_sms(phone_number, otp_code): return Response({'otp_code': otp_code}, status=status.HTTP_201_CREATED) else: return Response({'message': 'Erreur lors de l\'envoi du SMS'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) -def send_sms(phone_number, message_text): - """Envoi SMS via Twilio API.""" +def send_sms(phone_number, otp_code): + """Envoi du code OTP par SMS via Twilio (remplace l'ancienne API Orange Mali). + + Twilio est déjà utilisé pour l'IVR (cf. ivr_views.py) ; on réutilise les mêmes + identifiants ``TWILIO_*``. Le numéro est normalisé en E.164 (+223 par défaut, Mali). + """ try: + from twilio.rest import Client client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN) - # Format recipient en E.164 (+223XXXXXXXX) recipient = phone_number if phone_number.startswith('+') else f"+223{phone_number.lstrip('0')}" message = client.messages.create( - body=message_text, + body=f"Votre code de vérification OTP est : {otp_code}", from_=settings.TWILIO_PHONE_NUMBER, - to=recipient + to=recipient, ) if message.sid: - print(f"SMS envoyé avec succès via Twilio. SID: {message.sid}") + print(f"SMS OTP envoyé via Twilio. SID: {message.sid}") return True - else: - print("Erreur: pas de SID retourné par Twilio") - return False + print("Erreur: pas de SID retourné par Twilio") + return False except Exception as e: print(f"Erreur lors de l'envoi SMS Twilio: {str(e)}") return False -@extend_schema( - description="Endpoint to get user who took incident into account", - responses={200: RegisterSerializer()}, -) class RegisterView(generics.CreateAPIView): serializer_class = RegisterSerializer + @extend_schema( + tags=['Authentification'], + operation_id='auth_register_citizen', + summary="Inscription citoyen (email)", + description="Crée un compte à partir d'un email seul et envoie un lien " + "de vérification par email. Endpoint public.", + request=RegisterSerializer, + responses={ + 201: inline_serializer( + name='RegisterCitizenResponse', + fields={'message': serializers.CharField()}, + ), + }, + ) def post(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -537,6 +798,23 @@ def post(self, request, *args, **kwargs): return Response({"message": "Un lien de vérification a été envoyé à votre adresse email."}, status=status.HTTP_201_CREATED) class VerifyEmailView(APIView): + @extend_schema( + tags=['Authentification'], + operation_id='auth_verify_email', + summary="Vérifier l'email", + description="Valide l'adresse email associée au token de vérification " + "et marque l'utilisateur comme vérifié. Endpoint public.", + parameters=[OpenApiParameter('token', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="Token de vérification")], + request=None, + responses={ + 200: inline_serializer( + name='VerifyEmailResponse', + fields={'message': serializers.CharField()}, + ), + 400: OpenApiResponse(description="Lien de vérification invalide."), + }, + ) def get(self, request, token, *args, **kwargs): try: user = User.objects.get(verification_token=token) @@ -547,6 +825,26 @@ def get(self, request, token, *args, **kwargs): except User.DoesNotExist: return Response({"error": "Lien de vérification invalide"}, status=status.HTTP_400_BAD_REQUEST) +@extend_schema_view( + put=extend_schema( + tags=['Authentification'], + operation_id='auth_set_password', + summary="Définir le mot de passe", + description="Définit le mot de passe de l'utilisateur authentifié " + "(étape post-vérification). Authentification requise.", + request=SetPasswordSerializer, + responses={200: OpenApiResponse(description="Mot de passe défini.")}, + ), + patch=extend_schema( + tags=['Authentification'], + operation_id='auth_set_password_partial', + summary="Définir le mot de passe (partiel)", + description="Variante PATCH pour définir le mot de passe de " + "l'utilisateur authentifié. Authentification requise.", + request=SetPasswordSerializer, + responses={200: OpenApiResponse(description="Mot de passe défini.")}, + ), +) class SetPasswordView(generics.UpdateAPIView): serializer_class = SetPasswordSerializer permission_classes = [IsAuthenticated] @@ -556,18 +854,70 @@ def get_object(self): class RequestOTPView(APIView): + @extend_schema( + tags=['Authentification'], + operation_id='auth_otp_request', + summary="Demander un OTP (téléphone)", + description="Crée ou récupère l'utilisateur par numéro de téléphone, " + "génère un OTP et l'envoie par SMS. Endpoint public.", + request=inline_serializer( + name='RequestOtpRequest', + fields={'phone': serializers.CharField()}, + ), + responses={ + 200: inline_serializer( + name='RequestOtpResponse', + fields={'message': serializers.CharField()}, + ), + 500: OpenApiResponse(description="Échec de l'envoi du SMS."), + }, + ) def post(self, request): phone = request.data.get("phone") user = User.objects.get_or_create_user(phone=phone) user.generate_otp() - if send_sms(phone, f"Votre code de vérification OTP est : {user.otp}"): + if send_sms(phone, user.otp): return Response({"message": "OTP envoyé."}, status=status.HTTP_200_OK) else: return Response({"message": "Erreur lors de l'envoi du SMS"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) class VerifyOTPView(APIView): + @extend_schema( + tags=['Authentification'], + operation_id='auth_otp_verify', + summary="Vérifier un OTP (téléphone)", + description="Vérifie le couple téléphone/OTP ; si valide, retourne des " + "tokens JWT et les informations de l'utilisateur. Endpoint public.", + request=inline_serializer( + name='VerifyOtpRequest', + fields={'phone': serializers.CharField(), 'otp': serializers.CharField()}, + ), + responses={ + 200: inline_serializer( + name='VerifyOtpResponse', + fields={ + 'refresh': serializers.CharField(), + 'access': serializers.CharField(), + 'user': inline_serializer( + name='VerifyOtpUser', + fields={ + 'id': serializers.UUIDField(), + 'email': serializers.EmailField(), + 'first_name': serializers.CharField(), + 'last_name': serializers.CharField(), + 'phone': serializers.CharField(), + 'is_verified': serializers.BooleanField(), + 'user_type': serializers.CharField(), + }, + ), + }, + ), + 400: OpenApiResponse(description="OTP invalide ou expiré."), + 404: OpenApiResponse(description="Utilisateur non trouvé."), + }, + ) def post(self, request): phone = request.data.get("phone") otp = request.data.get("otp") diff --git a/Mapapi/views/zone.py b/Mapapi/views/zone.py index df522d83..2eaea33c 100644 --- a/Mapapi/views/zone.py +++ b/Mapapi/views/zone.py @@ -3,15 +3,68 @@ from rest_framework.pagination import PageNumberPagination from rest_framework.response import Response -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiResponse +from drf_spectacular.types import OpenApiTypes from ..serializer import * from .common import CustomPageNumberPagination -@extend_schema( - description="Endpoint allowing retrival, updating and deletion of zone.", - responses={200: ZoneSerializer, 404: "zone not found"}, +@extend_schema_view( + get=extend_schema( + tags=['Zones'], + operation_id='zones_retrieve', + summary="Détails d'une zone", + description="Retourne une zone par son id. Endpoint public.", + parameters=[ + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="id de la zone"), + ], + responses={200: ZoneSerializer, 404: OpenApiResponse(description="Zone introuvable")}, + ), + put=extend_schema( + tags=['Zones'], + operation_id='zones_update', + summary="Mettre à jour une zone", + description="Met à jour entièrement une zone existante. Endpoint public.", + parameters=[ + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="id de la zone"), + ], + request=ZoneSerializer, + responses={ + 200: ZoneSerializer, + 400: OpenApiResponse(description="Erreurs de validation"), + 404: OpenApiResponse(description="Zone introuvable"), + }, + ), + delete=extend_schema( + tags=['Zones'], + operation_id='zones_destroy', + summary="Supprimer une zone", + description="Supprime une zone par son id. Endpoint public.", + parameters=[ + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="id de la zone"), + ], + responses={ + 204: OpenApiResponse(description="Zone supprimée"), + 404: OpenApiResponse(description="Zone introuvable"), + }, + ), + post=extend_schema( + tags=['Zones'], + operation_id='zones_create_at_id', + summary="Créer une zone (route /zone/)", + description="Hérité de `CreateAPIView` : crée une nouvelle zone (l'id du chemin " + "est ignoré). Préférer `POST /zone/`. Endpoint public.", + parameters=[ + OpenApiParameter('id', OpenApiTypes.UUID, OpenApiParameter.PATH, + description="ignoré"), + ], + request=ZoneSerializer, + responses={201: ZoneSerializer, 400: OpenApiResponse(description="Erreurs de validation")}, + ), ) class ZoneAPIView(generics.CreateAPIView): permission_classes = ( @@ -46,10 +99,25 @@ def delete(self, request, id, format=None): item.delete() return Response(status=204) -@extend_schema( - description="Endpoint allowing retrival and creating of zone.", - request=ZoneSerializer, - responses={201: ZoneSerializer, 400: "Bad request"}, +@extend_schema_view( + get=extend_schema( + tags=['Zones'], + operation_id='zones_list', + summary="Lister les zones", + description="Retourne la liste paginée des zones. Endpoint public.", + responses={ + 200: ZoneSerializer(many=True), + 500: OpenApiResponse(description="Erreur serveur (`{error}`)"), + }, + ), + post=extend_schema( + tags=['Zones'], + operation_id='zones_create', + summary="Créer une zone", + description="Crée une nouvelle zone. Endpoint public.", + request=ZoneSerializer, + responses={201: ZoneSerializer, 400: OpenApiResponse(description="Erreurs de validation")}, + ), ) class ZoneAPIListView(generics.ListCreateAPIView): permission_classes = ( diff --git a/Mapapi/ws_auth.py b/Mapapi/ws_auth.py new file mode 100644 index 00000000..5dd22b48 --- /dev/null +++ b/Mapapi/ws_auth.py @@ -0,0 +1,51 @@ +"""Authentification des WebSockets par le JWT (cookie httpOnly ou ?token=). + +Le navigateur envoie automatiquement le cookie d'accès lors du handshake WS vers +le domaine backend ; on le valide pour peupler scope['user']. Repli possible sur +un paramètre de requête ``?token=`` (utile pour le mobile/Bearer).""" +from urllib.parse import parse_qs + +from channels.db import database_sync_to_async +from channels.middleware import BaseMiddleware +from django.conf import settings +from django.contrib.auth.models import AnonymousUser +from rest_framework_simplejwt.exceptions import TokenError +from rest_framework_simplejwt.tokens import AccessToken + + +@database_sync_to_async +def _get_user(user_id): + from .models import User + try: + return User.objects.get(pk=user_id) + except User.DoesNotExist: + return AnonymousUser() + + +def _token_from_scope(scope): + # 1. cookie httpOnly access_token + for name, value in scope.get('headers', []): + if name == b'cookie': + cookies = value.decode() + for part in cookies.split(';'): + k, _, v = part.strip().partition('=') + if k == settings.AUTH_COOKIE_ACCESS: + return v + # 2. repli ?token= + qs = parse_qs((scope.get('query_string') or b'').decode()) + if 'token' in qs: + return qs['token'][0] + return None + + +class JWTCookieAuthMiddleware(BaseMiddleware): + async def __call__(self, scope, receive, send): + scope['user'] = AnonymousUser() + raw = _token_from_scope(scope) + if raw: + try: + access = AccessToken(raw) + scope['user'] = await _get_user(access['user_id']) + except (TokenError, KeyError): + scope['user'] = AnonymousUser() + return await super().__call__(scope, receive, send) diff --git a/backend/asgi.py b/backend/asgi.py index f4518a14..6c963203 100644 --- a/backend/asgi.py +++ b/backend/asgi.py @@ -1,16 +1,31 @@ """ ASGI config for backend project. -It exposes the ASGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ +Route HTTP (Django) ET WebSocket (Channels). Le WS est authentifié par le JWT +(cookie httpOnly ou ?token=) via JWTCookieAuthMiddleware. """ - import os from django.core.asgi import get_asgi_application os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings') -application = get_asgi_application() +# L'app HTTP Django doit être initialisée avant d'importer les consumers +# (qui touchent aux modèles). +django_asgi_app = get_asgi_application() + +from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402 +from channels.security.websocket import OriginValidator # noqa: E402 +from django.conf import settings # noqa: E402 + +from Mapapi.routing import websocket_urlpatterns # noqa: E402 +from Mapapi.ws_auth import JWTCookieAuthMiddleware # noqa: E402 + +application = ProtocolTypeRouter({ + 'http': django_asgi_app, + # Origine validée (anti-hijacking cross-site) PUIS auth JWT (cookie/?token=). + 'websocket': OriginValidator( + JWTCookieAuthMiddleware(URLRouter(websocket_urlpatterns)), + settings.WS_ALLOWED_ORIGINS, + ), +}) diff --git a/backend/settings.py b/backend/settings.py index 2dfbe0b8..ae41bdd5 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -25,14 +25,23 @@ allowed_hosts_value = os.environ.get("ALLOWED_HOSTS", "localhost") ALLOWED_HOSTS = [host.strip() for host in allowed_hosts_value.split(",")] -# Add CSRF trusted origins for HTTPS +# Add CSRF trusted origins for HTTPS. Inclut le frontend cross-site (cookies +# httpOnly + CSRF) : Railway, GitHub Pages, et le dev local (Vite :5173). CSRF_TRUSTED_ORIGINS = [f"https://{host.strip()}" for host in allowed_hosts_value.split(",")] +CSRF_TRUSTED_ORIGINS += [ + "https://*.up.railway.app", + "https://*.github.io", + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:3000", +] # Application definition INSTALLED_APPS = [ 'daphne', + 'channels', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -72,11 +81,16 @@ REST_FRAMEWORK = { # YOUR SETTINGS 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework_simplejwt.authentication.JWTAuthentication', + # JWT par cookie httpOnly (avec repli Bearer pour mobile/transition). + 'Mapapi.authentication.CookieJWTAuthentication', ), 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', } +# --- Cookies d'authentification httpOnly (cf. Mapapi/authentication.py) --- +AUTH_COOKIE_ACCESS = 'access_token' +AUTH_COOKIE_REFRESH = 'refresh_token' + SIMPLE_JWT = { "ACCESS_TOKEN_LIFETIME": timedelta(days=90), "REFRESH_TOKEN_LIFETIME": timedelta(days=1), @@ -95,19 +109,120 @@ "SLIDING_TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainSlidingSerializer", "SLIDING_TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSlidingSerializer", } +# Durées de vie des cookies = durées de vie des tokens. +AUTH_COOKIE_ACCESS_MAX_AGE = int(SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"].total_seconds()) +AUTH_COOKIE_REFRESH_MAX_AGE = int(SIMPLE_JWT["REFRESH_TOKEN_LIFETIME"].total_seconds()) + +# Cookie CSRF : en prod le front et le back sont sur des domaines différents +# (cross-site) → le cookie csrftoken doit être SameSite=None; Secure pour être +# envoyé sur les requêtes cross-site. En local (http, same-site) on garde Lax. +# Piloté par env (COOKIE_SAMESITE=None, COOKIE_SECURE=True sur Railway). +COOKIE_SAMESITE = os.environ.get("COOKIE_SAMESITE", "Lax") +COOKIE_SECURE = os.environ.get("COOKIE_SECURE", "False").lower() == "true" +CSRF_COOKIE_SAMESITE = COOKIE_SAMESITE +CSRF_COOKIE_SECURE = COOKIE_SECURE +# Le SPA lit le token CSRF dans le CORPS de la réponse (login / get_csrf_token), +# pas via document.cookie (illisible cross-site). On laisse toutefois le cookie +# lisible en JS pour le dev same-site. +CSRF_COOKIE_HTTPONLY = False +SESSION_COOKIE_SAMESITE = COOKIE_SAMESITE +SESSION_COOKIE_SECURE = COOKIE_SECURE SPECTACULAR_SETTINGS = { 'TITLE': 'Map Action API', - 'DESCRIPTION': 'This comprehensive document serves as the official guide to understanding and utilizing the Map Action API.' - 'Within these pages, developers will find detailed information, including endpoint descriptions, parameter specifications, response formats, authentication requirements, and usage examples.', + 'DESCRIPTION': ( + "API REST de la plateforme **Map Action** (gestion d'incidents " + "environnementaux/humanitaires au Mali) qui sert le dashboard React et " + "les clients mobile/IVR.\n\n" + "## Authentification\n" + "JWT **Bearer**. Connectez-vous via `POST /MapApi/login/` " + "(`{email, password}` → `{access, refresh}`), cliquez sur **Authorize** " + "en haut à droite et collez le token `access`. La connexion se fait par " + "**email** (pas username). Token d'accès : 90 jours.\n\n" + "## À savoir\n" + "- **Préfixe** : toutes les routes sont sous `/MapApi/`.\n" + "- **Slash final** : les routes sont insensibles au slash final " + "(`/incident/1` ≡ `/incident/1/`).\n" + "- **Ids** : ce sont des **UUID** (chaînes), pas des entiers.\n" + "- **Beaucoup d'endpoints de lecture/référentiel sont publics** " + "(pas d'auth) — voir le verrou sur chaque opération.\n" + "- **Pagination** : les listes paginées renvoient " + "`{count, next, previous, results}` (`?page=&page_size=`, défaut 100, " + "max 1000) ; certaines listes renvoient un tableau brut.\n" + "- **Uploads** : les endpoints avec fichiers " + "(`photo, video, audio, attachment, proof_image, proof_video, logo`) " + "exigent `multipart/form-data`.\n" + "- **Messages** d'erreur/notification : en **français**." + ), 'VERSION': '1.0.0', + 'CONTACT': {'name': 'Map Action', 'url': 'https://github.com/223MapAction'}, + 'LICENSE': {'name': 'Proprietary'}, + 'SERVERS': [ + {'url': 'https://backend-production-0726b.up.railway.app', 'description': 'Production (Railway)'}, + {'url': 'http://localhost:8000', 'description': 'Dev local (daphne direct)'}, + {'url': 'http://localhost', 'description': 'Dev local (via nginx)'}, + ], + 'TAGS': [ + {'name': 'Authentification', 'description': 'Login, refresh, logout, inscription, mot de passe, vérification.'}, + {'name': 'Utilisateurs & Profil', 'description': 'Profil courant, gestion des utilisateurs, changement de mot de passe.'}, + {'name': 'Organisations & Membres', 'description': "Organisations, membres, création d'agents/staff."}, + {'name': 'Incidents', 'description': "CRUD, cycle de vie, listes filtrées (carte, dashboard), corbeille, stats."}, + {'name': 'Prise en charge & Collaboration', 'description': "Prise en charge, collaborations (émetteur/récepteur), accept/refus."}, + {'name': 'Tâches', 'description': "Tâches d'un incident : création, complétion (preuves), échec, confirmation."}, + {'name': 'Suggestions de partenaires', 'description': 'Suggestions de partenaires/organisations sur un incident.'}, + {'name': 'Rapports', 'description': "Rapports liés aux incidents."}, + {'name': 'Notifications', 'description': 'Notifications utilisateur (temps réel via WebSocket).'}, + {'name': 'Messages & Communauté', 'description': 'Messages, réponses, communautés.'}, + {'name': 'Zones', 'description': 'Zones géographiques (régions/cercles/communes).'}, + {'name': 'Catégories & Indicateurs', 'description': "Catégories d'incident et indicateurs."}, + {'name': 'Prédiction & IA', 'description': "Prédictions du modèle ML et assistant IA (chat) par incident."}, + {'name': 'Événements & Participation', 'description': 'Événements et participations citoyennes.'}, + {'name': 'Contacts & Élus', 'description': 'Contacts et élus locaux.'}, + {'name': 'Médias', 'description': "Images de fond / médias."}, + {'name': 'IVR (Téléphonie)', 'description': 'Flux vocal Twilio (signalement par téléphone).'}, + {'name': 'Référentiel & Statistiques', 'description': 'Endpoints de référence et statistiques (souvent publics).'}, + ], + # Noms d'enum explicites — plusieurs jeux de choix s'appellent "role"/"status" + # et drf-spectacular génère sinon des noms illisibles (Role8a8Enum…). + 'ENUM_NAME_OVERRIDES': { + 'OrgRoleEnum': 'Mapapi.models.ORG_ROLES', + 'CollaborationRoleEnum': 'Mapapi.models.COLLAB_ROLES', + 'ChatRoleEnum': 'Mapapi.models.CHAT_ROLES', + 'SuggestionRoleEnum': 'Mapapi.models.SUGGESTION_ROLES', + 'AssignmentStatusEnum': 'Mapapi.models.ASSIGNMENT_STATUSES', + 'OrgAssignmentStatusEnum': 'Mapapi.models.ORG_ASSIGNMENT_STATUSES', + 'SuggestionStatusEnum': 'Mapapi.models.SUGGESTION_STATUSES', + 'PartnerStatusEnum': 'Mapapi.models.PARTNER_STATUSES', + 'UserTypeEnum': 'Mapapi.models.USER_TYPES', + 'IncidentEtatEnum': 'Mapapi.models.ETAT_INCIDENT', + 'RapportEtatEnum': 'Mapapi.models.ETAT_RAPPORT', + 'TaskStateEnum': 'Mapapi.models.TASK_STATES', + 'SeverityEnum': 'Mapapi.models.SEVERITY_CHOICES', + }, 'SERVE_INCLUDE_SCHEMA': False, - 'SWAGGER_UI_DIST': 'SIDECAR', # shorthand to use the sidecar instead + 'COMPONENT_SPLIT_REQUEST': True, # schémas requête/réponse distincts (lecture vs écriture) + 'SORT_OPERATIONS': False, # garde l'ordre par tag/déclaration + 'SCHEMA_PATH_PREFIX': r'/MapApi', + 'SWAGGER_UI_SETTINGS': { + 'persistAuthorization': True, # garde le token entre les rechargements + 'displayRequestDuration': True, + 'filter': True, # barre de recherche par tag/opération + 'docExpansion': 'none', + }, + # UI Swagger/ReDoc en self-host : assets du paquet `drf-spectacular-sidecar`, + # rassemblés par `collectstatic` (cf. Dockerfile.deploy) et servis par + # WhiteNoise. Aucune dépendance à un CDN externe. + 'SWAGGER_UI_DIST': 'SIDECAR', 'SWAGGER_UI_FAVICON_HREF': 'SIDECAR', 'REDOC_DIST': 'SIDECAR', } MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', + # WhiteNoise sert les fichiers statiques (UI Swagger/ReDoc) directement depuis + # daphne en prod — il n'y a pas de nginx. Doit suivre SecurityMiddleware. + 'whitenoise.middleware.WhiteNoiseMiddleware', + # Rend toutes les routes /MapApi/ insensibles au slash final (cf. middleware). + 'Mapapi.middleware.SlashInsensitiveMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'Mapapi.middleware.OrganisationFromSubdomainMiddleware', 'django.middleware.common.CommonMiddleware', @@ -154,13 +269,23 @@ 'USER': os.environ.get("POSTGRES_USER"), 'PASSWORD': os.environ.get("POSTGRES_PASSWORD"), 'PORT': os.environ.get("PORT"), - # Connexions persistantes : réutilise la même connexion TLS au lieu d'en ouvrir - # une nouvelle à chaque requête (~0.6s gagnées). CONN_HEALTH_CHECKS vérifie - # que la connexion est toujours valide avant réutilisation (compatible PgBouncer). - 'CONN_MAX_AGE': int(os.environ.get("DB_CONN_MAX_AGE", 60)), # secondes + # La BDD est un pooler Supabase distant : ouvrir une connexion TLS à chaque + # requête coûtait ~0,6 s (par requête, sur TOUS les endpoints). On réutilise + # la connexion entre requêtes (60 s par défaut, ajustable via DB_CONN_MAX_AGE). + 'CONN_MAX_AGE': int(os.environ.get("DB_CONN_MAX_AGE", "60")), + # Revalide une connexion persistée avant réutilisation (le pooler peut la + # fermer) et se reconnecte si elle est morte — évite les erreurs sur conn. obsolète. 'CONN_HEALTH_CHECKS': True, + # Pooler en mode « transaction » (pgbouncer, port 6543) : les curseurs côté + # serveur ne survivent pas entre transactions → requis avec CONN_MAX_AGE. + 'DISABLE_SERVER_SIDE_CURSORS': True, 'OPTIONS': { - 'sslmode': 'require', + # 'require' par défaut (Supabase) ; override en local/CI via DB_SSLMODE. + 'sslmode': os.environ.get('DB_SSLMODE', 'require'), + # Schéma applicatif = public (best practice). Surcharge le search_path + # par défaut du rôle Supabase (qui inclut 'extensions'). Override possible + # via DB_SEARCH_PATH. + 'options': '-c search_path=' + os.environ.get('DB_SEARCH_PATH', 'public'), }, }, } @@ -212,8 +337,20 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +# Auth = JWT Bearer (le token, pas l'origine, fait foi). Aucune dépendance aux +# cookies cross-site → on autorise toutes les origines SANS credentials, ce qui +# laisse n'importe quel front (dashboard Railway, localhost:n'importe quel port, +# l'environnement de test du dev front, mobile) appeler l'API avec un Bearer. +# Note : '*' n'est permis par le navigateur QUE si les credentials sont désactivés. CORS_ALLOW_ALL_ORIGINS = True CORS_ORIGIN_ALLOW_ALL = True +CORS_ALLOW_CREDENTIALS = False +CORS_ALLOWED_ORIGIN_REGEXES = [ + r"^http://localhost(:\d+)?$", + r"^http://127\.0\.0\.1(:\d+)?$", + r"^https://.*\.up\.railway\.app$", + r"^https://.*\.github\.io$", +] CORS_ALLOW_HEADERS = [ 'accept', 'accept-encoding', @@ -248,9 +385,38 @@ ] -# Celery Configuration -CELERY_BROKER_URL = 'redis://redis-server:6379/0' -CELERY_RESULT_BACKEND = 'redis://redis-server:6379/0' +# Celery Configuration (broker/result configurable for deploys; defaults to the +# docker-compose 'redis-server' service for local). +# Channels (WebSockets temps réel) : couche Redis (réutilise le Redis Celery par +# défaut ; surchargeable via CHANNELS_REDIS_URL). +CHANNELS_REDIS_URL = os.environ.get( + 'CHANNELS_REDIS_URL', + os.environ.get('CELERY_BROKER_URL', 'redis://redis-server:6379/0'), +) +CHANNEL_LAYERS = { + 'default': { + # Couche pub/sub Redis : fan-out fiable des broadcasts entre processus + # (HTTP qui émet via group_send ↔ Daphne qui relaie au WebSocket). L'ancienne + # RedisChannelLayer (listes + BZPOPMIN bloquant) ne délivrait pas les messages + # cross-process et se fermait en 1011 (aggravé par redis-py >= 5.1). + 'BACKEND': 'channels_redis.pubsub.RedisPubSubChannelLayer', + 'CONFIG': {'hosts': [CHANNELS_REDIS_URL]}, + }, +} +# Origines autorisées pour les WebSockets. L'anti-hijacking par origine ne protège +# que l'auth par cookie ; ici le WS est authentifié par ?token= (le token fait +# foi), donc on autorise toutes les origines par défaut — un front cross-site ne +# peut rien faire sans le token. Surchargeable via CHANNELS_ALLOWED_ORIGINS. +WS_ALLOWED_ORIGINS = os.environ.get( + 'CHANNELS_ALLOWED_ORIGINS', + '*', +).split(',') + +CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL', 'redis://redis-server:6379/0') +CELERY_RESULT_BACKEND = os.environ.get( + 'CELERY_RESULT_BACKEND', + os.environ.get('CELERY_BROKER_URL', 'redis://redis-server:6379/0'), +) CELERY_ACCEPT_CONTENT = ['application/json'] CELERY_TASK_SERIALIZER = 'json' CELERY_RESULT_SERIALIZER = 'json' @@ -258,6 +424,28 @@ CELERY_TASK_TRACK_STARTED = True CELERY_TASK_TIME_LIMIT = 30 * 60 +# Phase 4 — mécanismes temporels du cycle de vie de l'incident (Celery Beat). +# Validation tacite 72 h (D1) + anti-gel (T3) : horaires. Purge corbeille 30 j (D10) : quotidien. +CELERY_BEAT_SCHEDULE = { + 'auto-validate-overdue-resolutions': { + 'task': 'Mapapi.tasks.auto_validate_overdue_resolutions', + 'schedule': timedelta(hours=1), + }, + 'revert-stale-taken-incidents': { + 'task': 'Mapapi.tasks.revert_stale_taken_incidents', + 'schedule': timedelta(hours=1), + }, + 'purge-expired-trash': { + 'task': 'Mapapi.tasks.purge_expired_trash', + 'schedule': timedelta(days=1), + }, + # Acceptation tacite des assignations Super Admin → organisation à 72 h (D4) : horaire. + 'auto-accept-overdue-assignments': { + 'task': 'Mapapi.tasks.auto_accept_overdue_assignments', + 'schedule': timedelta(hours=1), + }, +} + # Django Q Configuration Q_CLUSTER = { 'name': 'backend', @@ -329,13 +517,16 @@ AUTH_USER_MODEL = 'Mapapi.User' -EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +# Config email entièrement pilotée par l'env (valeurs par défaut = config actuelle, +# donc aucun changement de comportement si les variables ne sont pas définies). +EMAIL_BACKEND = os.environ.get("EMAIL_BACKEND", 'django.core.mail.backends.smtp.EmailBackend') EMAIL_HOST = os.environ.get("EMAIL_HOST") -EMAIL_USE_TLS = True -EMAIL_USE_SSL = False -EMAIL_PORT = 2525 +EMAIL_PORT = int(os.environ.get("EMAIL_PORT", 2525)) +EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "True").lower() == "true" +EMAIL_USE_SSL = os.environ.get("EMAIL_USE_SSL", "False").lower() == "true" EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER") EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD") +DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "Map Action ") # Supabase storage configuration USE_SUPABASE_STORAGE = os.environ.get('USE_SUPABASE_STORAGE', 'False').lower() in ('true', '1', 't') @@ -345,15 +536,19 @@ TWILIO_AUTH_TOKEN = os.environ.get('TWILIO_AUTH_TOKEN') TWILIO_PHONE_NUMBER = os.environ.get('TWILIO_PHONE_NUMBER') +# SMS OTP : envoyé via Twilio (cf. Mapapi/views/user.py send_sms). L'ancienne +# intégration Orange Mali a été retirée (alignement avec l'upstream « orange to twilio »). -# Model-deploy service (remote AI analysis pipeline) +# Model-deploy service (remote AI analysis pipeline). +# NB: the service exposes /analyze, /analyze/upload and /chat — there is NO +# "/api1" prefix. The photo-upload task posts multipart → /analyze/upload. MODEL_DEPLOY_ANALYZE_URL = os.environ.get( 'MODEL_DEPLOY_ANALYZE_URL', - 'http://localhost:8001/api1/analyze/', + 'http://localhost:8001/analyze/upload', ) MODEL_DEPLOY_TIMEOUT = int(os.environ.get('MODEL_DEPLOY_TIMEOUT', 180)) MODEL_DEPLOY_CHAT_URL = os.environ.get( 'MODEL_DEPLOY_CHAT_URL', - 'http://localhost:8001/api1/chat', + 'http://localhost:8001/chat', ) MODEL_DEPLOY_CHAT_TIMEOUT = int(os.environ.get('MODEL_DEPLOY_CHAT_TIMEOUT', 120)) \ No newline at end of file diff --git a/backend/supabase_storage.py b/backend/supabase_storage.py index 64ae428b..b1c80f86 100644 --- a/backend/supabase_storage.py +++ b/backend/supabase_storage.py @@ -110,6 +110,8 @@ def exists(self, name): return False def url(self, name): + if not name: + return None try: storage_public = os.environ.get('SUPABASE_STORAGE_PUBLIC', 'False').lower() in ('true', '1', 't') if storage_public: @@ -122,22 +124,10 @@ def url(self, name): if isinstance(signed, dict): return signed.get("signedURL") or signed.get("signed_url") or None return signed # fallback si lib renvoie directement une str - except StorageException: - # essai fallback naïf (inutile si path correct) - try: - filename = name.split("/")[-1] - storage_public = os.environ.get('SUPABASE_STORAGE_PUBLIC', 'False').lower() in ('true', '1', 't') - if storage_public: - public = self._get_storage().get_public_url(filename) - if isinstance(public, dict): - return public.get('publicUrl') or public.get('publicURL') or public.get('public_url') or None - return public - signed = self._get_storage().create_signed_url(filename, self.signed_url_expiry) - if isinstance(signed, dict): - return signed.get("signedURL") or signed.get("signed_url") or None - return signed - except StorageException: - return None + except Exception: + # Dev-safe: missing config/object, network or protocol errors must never + # 500 a response. Return None so the field serializes as null. + return None def size(self, name): try: diff --git a/requirements.txt b/requirements.txt index a802ac5b..2863e09f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,6 @@ django-cors-headers==4.3.0 httpx==0.26.0 daphne==4.1.0 celery==5.3.6 -django-q==1.3.9 django-http-exceptions==1.4.0 drf-spectacular==0.27.1 overpy==0.7 @@ -32,3 +31,10 @@ pytest==8.3.1 pytest-cov==4.1.0 pytest-django==4.5.2 drf-spectacular-sidecar>=2024.3.4 +channels==4.1.0 +channels-redis==4.2.0 +# Épinglé < 5.1 : redis-py >= 5.1 applique un read-timeout sur les commandes +# bloquantes (BZPOPMIN), ce qui casse la boucle receive de channels-redis 4.2.0 +# → les WebSockets se fermaient avec le code 1011 après quelques secondes d'inactivité. +redis==5.0.8 +whitenoise==6.7.0 diff --git a/template/emails/collaboration_request.html b/template/emails/collaboration_request.html index 2f685c66..e7c66f64 100644 --- a/template/emails/collaboration_request.html +++ b/template/emails/collaboration_request.html @@ -5,156 +5,67 @@ Nouvelle demande de collaboration -
-
-
-

Demande de Collaboration

-
- -
-
Bonjour {{ organisation }},
- -
- L'organisation {{ requesting_organisation }} souhaite apporter son aide et collaborer avec vous sur l'incident suivant : -
+
+

Nouvelle demande de collaboration

+

Bonjour {{ organisation }},

-
-

"{{ incident_title }}"

-

- 📍 Zone : {{ incident_zone }}
- 📅 Date : {{ incident_creation_date|date:"d M Y" }} -

-
+

L'organisation {{ requesting_organisation }} souhaite collaborer sur l'incident + "{{ incident_title }}", + situé dans la zone {{ incident_zone }}, déclaré le {{ incident_creation_date }}.

-
- Connectez-vous à votre tableau de bord pour consulter les détails et accepter ou décliner cette offre de collaboration. -
+

Merci de vous connecter à votre compte pour obtenir plus d'informations sur cette demande de collaboration.

- +

Voici le lien direct pour accéder à la collaboration : + + Voir la demande de collaboration + +

-
- Cordialement,
- L'équipe Map Action -
-
+

Cordialement,

+

L'équipe Map Action

- +