feat: mise en place de la messagerie [#17]

This commit is contained in:
Luc SORIGNET
2025-05-26 13:24:42 +02:00
parent e2df29d851
commit d37145b73e
64 changed files with 13113 additions and 853 deletions

View File

@ -1,211 +1,455 @@
from django.http.response import JsonResponse
from rest_framework.views import APIView
from rest_framework.parsers import JSONParser
from django.conf import settings
from rest_framework.response import Response
from rest_framework import status
from django.db.models import Q
from .models import Messagerie
from rest_framework.parsers import MultiPartParser, FormParser
from django.db import models
from .models import Conversation, ConversationParticipant, Message, UserPresence
from Auth.models import Profile, ProfileRole
from GestionMessagerie.serializers import MessageSerializer
from School.models import Teacher
from School.serializers import TeacherSerializer
import N3wtSchool.mailManager as mailer
from N3wtSchool import bdd
from GestionMessagerie.serializers import (
ConversationSerializer, MessageSerializer,
ConversationCreateSerializer, UserPresenceSerializer,
ProfileSimpleSerializer
)
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from rest_framework.exceptions import NotFound
from django.utils import timezone
import os
import uuid
import logging
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
from django.db.models import Q
class MessagerieView(APIView):
def get(self, request, profile_id):
messagesList = bdd.getObjects(_objectName=Messagerie, _columnName='destinataire__id', _value=profile_id)
messages_serializer = MessageSerializer(messagesList, many=True)
return JsonResponse(messages_serializer.data, safe=False)
logger = logging.getLogger(__name__)
class MessageView(APIView):
def post(self, request):
message_data=JSONParser().parse(request)
message_serializer = MessageSerializer(data=message_data)
# ====================== MESSAGERIE INSTANTANÉE ======================
if message_serializer.is_valid():
message_serializer.save()
return JsonResponse('Nouveau Message ajouté', safe=False)
return JsonResponse(message_serializer.errors, safe=False)
class MessageSimpleView(APIView):
def get(self, request, id):
message=bdd.getObject(Messagerie, "id", id)
message_serializer=MessageSerializer(message)
return JsonResponse(message_serializer.data, safe=False)
class SendEmailView(APIView):
class InstantConversationListView(APIView):
"""
API pour envoyer des emails aux parents et professeurs.
API pour lister les conversations instantanées d'un utilisateur
"""
def post(self, request):
data = request.data
recipients = data.get('recipients', [])
cc = data.get('cc', [])
bcc = data.get('bcc', [])
subject = data.get('subject', 'Notification')
message = data.get('message', '')
establishment_id = data.get('establishment_id', '')
if not recipients or not message:
return Response({'error': 'Les destinataires et le message sont requis.'}, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(
operation_description="Liste les conversations instantanées d'un utilisateur",
responses={200: ConversationSerializer(many=True)}
)
def get(self, request, user_id=None):
try:
# Récupérer la connexion SMTP
connection = mailer.getConnection(establishment_id)
user = Profile.objects.get(id=user_id)
# Envoyer l'email
return mailer.sendMail(
subject=subject,
message=message,
recipients=recipients,
cc=cc,
bcc=bcc,
attachments=[],
connection=connection
)
except NotFound as e:
return Response({'error': str(e)}, status=status.HTTP_404_NOT_FOUND)
conversations = Conversation.objects.filter(
participants__participant=user,
participants__is_active=True,
is_active=True
).distinct().order_by('-last_activity')
serializer = ConversationSerializer(conversations, many=True, context={'user': user})
return Response(serializer.data, status=status.HTTP_200_OK)
except Profile.DoesNotExist:
return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class ContactsView(APIView):
class InstantConversationCreateView(APIView):
"""
API pour récupérer les contacts associés à un établissement.
"""
def get(self, request, establishment_id):
try:
# Récupérer les enseignants associés à l'établissement
teachers = Teacher.objects.filter(profile_role__establishment_id=establishment_id)
teachers_serializer = TeacherSerializer(teachers, many=True)
# Ajouter un contact pour l'administration
admin_contact = {
"id": "admin",
"name": "Administration",
"email": "admin@etablissement.com",
"profilePic": "https://www.gravatar.com/avatar/admin"
}
contacts = [admin_contact] + teachers_serializer.data
return Response(contacts, status=status.HTTP_200_OK)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def search_recipients(request):
"""
API pour rechercher des destinataires en fonction d'un terme de recherche et d'un établissement.
"""
query = request.GET.get('q', '').strip() # Récupérer le terme de recherche depuis les paramètres GET
establishment_id = request.GET.get('establishment_id', None) # Récupérer l'ID de l'établissement
if not query:
return JsonResponse([], safe=False) # Retourner une liste vide si aucun terme n'est fourni
if not establishment_id:
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
# Rechercher dans les champs pertinents (nom, prénom, email) et filtrer par establishment_id
profiles = Profile.objects.filter(
Q(first_name__icontains=query) |
Q(last_name__icontains=query) |
Q(email__icontains=query),
roles__establishment_id=establishment_id, # Utiliser 'roles' au lieu de 'profilerole'
roles__is_active=True # Filtrer uniquement les ProfileRole actifs
).distinct()
# Construire la réponse avec les rôles associés
results = []
for profile in profiles:
profile_roles = ProfileRole.objects.filter(
profile=profile,
establishment_id=establishment_id,
is_active=True # Inclure uniquement les ProfileRole actifs
).values(
'id', 'role_type', 'establishment__name', 'is_active'
)
results.append({
'id': profile.id,
'first_name': profile.first_name,
'last_name': profile.last_name,
'email': profile.email,
'roles': list(profile_roles) # Inclure tous les rôles actifs associés pour cet établissement
})
return JsonResponse(results, safe=False)
class ConversationListView(APIView):
"""
Liste les conversations d'un utilisateur (parent ou enseignant).
Retourne la liste des interlocuteurs et le dernier message échangé.
API pour cer une nouvelle conversation instantanée
"""
@swagger_auto_schema(
operation_description="Liste les conversations d'un utilisateur (parent ou enseignant).",
responses={200: openapi.Response('Liste des conversations')}
operation_description="Crée une nouvelle conversation instantanée",
request_body=ConversationCreateSerializer,
responses={201: ConversationSerializer}
)
def get(self, request, profile_id):
# Récupérer toutes les conversations où l'utilisateur est émetteur ou destinataire
messages = Messagerie.objects.filter(Q(emetteur_id=profile_id) | Q(destinataire_id=profile_id))
# Grouper par conversation_id
conversations = {}
for msg in messages.order_by('-date_envoi'):
conv_id = msg.conversation_id or f"{min(msg.emetteur_id, msg.destinataire_id)}_{max(msg.emetteur_id, msg.destinataire_id)}"
if conv_id not in conversations:
conversations[conv_id] = msg
# Préparer la réponse
data = []
for conv_id, last_msg in conversations.items():
interlocuteur = last_msg.emetteur if last_msg.destinataire_id == int(profile_id) else last_msg.destinataire
data.append({
'conversation_id': conv_id,
'last_message': MessageSerializer(last_msg).data,
'interlocuteur': {
'id': interlocuteur.id,
'first_name': interlocuteur.first_name,
'last_name': interlocuteur.last_name,
'email': interlocuteur.email,
}
})
return Response(data, status=status.HTTP_200_OK)
def post(self, request):
serializer = ConversationCreateSerializer(data=request.data)
if serializer.is_valid():
conversation = serializer.save()
response_serializer = ConversationSerializer(conversation, context={'user': request.user})
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class ConversationMessagesView(APIView):
class InstantMessageListView(APIView):
"""
Récupère tous les messages d'une conversation donnée.
API pour lister les messages d'une conversation
"""
@swagger_auto_schema(
operation_description="Récupère tous les messages d'une conversation donnée.",
responses={200: openapi.Response('Liste des messages')}
operation_description="Liste les messages d'une conversation",
responses={200: MessageSerializer(many=True)}
)
def get(self, request, conversation_id):
messages = Messagerie.objects.filter(conversation_id=conversation_id).order_by('date_envoi')
serializer = MessageSerializer(messages, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
try:
conversation = Conversation.objects.get(id=conversation_id)
messages = conversation.messages.filter(is_deleted=False).order_by('created_at')
class MarkAsReadView(APIView):
# Récupérer l'utilisateur actuel depuis les paramètres de requête
user_id = request.GET.get('user_id')
user = None
if user_id:
try:
user = Profile.objects.get(id=user_id)
except Profile.DoesNotExist:
pass
serializer = MessageSerializer(messages, many=True, context={'user': user})
return Response(serializer.data, status=status.HTTP_200_OK)
except Conversation.DoesNotExist:
return Response({'error': 'Conversation not found'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class InstantMessageCreateView(APIView):
"""
Marque tous les messages reçus dans une conversation comme lus pour l'utilisateur connecté.
API pour envoyer un nouveau message instantané
"""
@swagger_auto_schema(
operation_description="Marque tous les messages reçus dans une conversation comme lus pour l'utilisateur connecté.",
operation_description="Envoie un nouveau message instantané",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'profile_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='ID du profil utilisateur')
'conversation_id': openapi.Schema(type=openapi.TYPE_STRING, description='ID de la conversation'),
'sender_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='ID de l\'expéditeur'),
'content': openapi.Schema(type=openapi.TYPE_STRING, description='Contenu du message'),
'message_type': openapi.Schema(type=openapi.TYPE_STRING, description='Type de message', default='text')
},
required=['profile_id']
required=['conversation_id', 'sender_id', 'content']
),
responses={200: openapi.Response('Statut OK')}
responses={201: MessageSerializer}
)
def post(self, request):
try:
conversation_id = request.data.get('conversation_id')
sender_id = request.data.get('sender_id')
content = request.data.get('content', '').strip()
message_type = request.data.get('message_type', 'text')
if not all([conversation_id, sender_id, content]):
return Response(
{'error': 'conversation_id, sender_id, and content are required'},
status=status.HTTP_400_BAD_REQUEST
)
# Vérifier que la conversation existe
conversation = Conversation.objects.get(id=conversation_id)
# Vérifier que l'expéditeur existe et peut envoyer dans cette conversation
sender = Profile.objects.get(id=sender_id)
participant = ConversationParticipant.objects.filter(
conversation=conversation,
participant=sender,
is_active=True
).first()
if not participant:
return Response(
{'error': 'You are not a participant in this conversation'},
status=status.HTTP_403_FORBIDDEN
)
# Récupérer les données de fichier si disponibles
file_url = request.data.get('file_url')
file_name = request.data.get('file_name')
file_type = request.data.get('file_type')
file_size = request.data.get('file_size')
# Créer le message
message = Message.objects.create(
conversation=conversation,
sender=sender,
content=content,
message_type=message_type,
file_url=file_url,
file_name=file_name,
file_type=file_type,
file_size=file_size
)
# Mettre à jour l'activité de la conversation
conversation.last_activity = message.created_at
conversation.save(update_fields=['last_activity'])
serializer = MessageSerializer(message)
return Response(serializer.data, status=status.HTTP_201_CREATED)
except Conversation.DoesNotExist:
return Response({'error': 'Conversation not found'}, status=status.HTTP_404_NOT_FOUND)
except Profile.DoesNotExist:
return Response({'error': 'Sender not found'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class InstantMarkAsReadView(APIView):
"""
API pour marquer une conversation comme lue
"""
@swagger_auto_schema(
operation_description="Marque une conversation comme lue pour un utilisateur",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'user_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='ID de l\'utilisateur')
},
required=['user_id']
),
responses={200: openapi.Response('Success')}
)
def post(self, request, conversation_id):
profile_id = request.data.get('profile_id')
Messagerie.objects.filter(conversation_id=conversation_id, destinataire_id=profile_id, is_read=False).update(is_read=True)
return Response({'status': 'ok'}, status=status.HTTP_200_OK)
try:
user_id = request.data.get('user_id')
if not user_id:
return Response({'error': 'user_id is required'}, status=status.HTTP_400_BAD_REQUEST)
participant = ConversationParticipant.objects.get(
conversation_id=conversation_id,
participant_id=user_id,
is_active=True
)
participant.last_read_at = timezone.now()
participant.save(update_fields=['last_read_at'])
return Response({'status': 'success'}, status=status.HTTP_200_OK)
except ConversationParticipant.DoesNotExist:
return Response({'error': 'Participant not found'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class UserPresenceView(APIView):
"""
API pour gérer la présence des utilisateurs
"""
@swagger_auto_schema(
operation_description="Met à jour le statut de présence d'un utilisateur",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'status': openapi.Schema(type=openapi.TYPE_STRING, description='Statut de présence')
},
required=['status']
),
responses={200: UserPresenceSerializer}
)
def post(self, request, user_id):
try:
user = Profile.objects.get(id=user_id)
status_value = request.data.get('status')
if status_value not in ['online', 'away', 'busy', 'offline']:
return Response({'error': 'Invalid status'}, status=status.HTTP_400_BAD_REQUEST)
presence, created = UserPresence.objects.get_or_create(user=user)
presence.status = status_value
presence.last_seen = timezone.now()
presence.save()
serializer = UserPresenceSerializer(presence)
return Response(serializer.data, status=status.HTTP_200_OK)
except Profile.DoesNotExist:
return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@swagger_auto_schema(
operation_description="Récupère le statut de présence d'un utilisateur",
responses={200: UserPresenceSerializer}
)
def get(self, request, user_id):
try:
user = Profile.objects.get(id=user_id)
presence, created = UserPresence.objects.get_or_create(user=user)
if created:
presence.status = 'offline'
presence.save()
serializer = UserPresenceSerializer(presence)
return Response(serializer.data, status=status.HTTP_200_OK)
except Profile.DoesNotExist:
return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class FileUploadView(APIView):
"""
API pour l'upload de fichiers dans la messagerie instantanée
"""
parser_classes = (MultiPartParser, FormParser)
@swagger_auto_schema(
operation_description="Upload un fichier pour la messagerie",
manual_parameters=[
openapi.Parameter('file', openapi.IN_FORM, description="Fichier à uploader", type=openapi.TYPE_FILE, required=True),
openapi.Parameter('conversation_id', openapi.IN_FORM, description="ID de la conversation", type=openapi.TYPE_INTEGER, required=True),
openapi.Parameter('sender_id', openapi.IN_FORM, description="ID de l'expéditeur", type=openapi.TYPE_INTEGER, required=True),
],
responses={
200: openapi.Response('Success', openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'fileUrl': openapi.Schema(type=openapi.TYPE_STRING),
'fileName': openapi.Schema(type=openapi.TYPE_STRING),
'fileSize': openapi.Schema(type=openapi.TYPE_INTEGER),
'fileType': openapi.Schema(type=openapi.TYPE_STRING),
}
)),
400: 'Bad Request',
413: 'File too large',
415: 'Unsupported file type'
}
)
def post(self, request):
try:
file = request.FILES.get('file')
conversation_id = request.data.get('conversation_id')
sender_id = request.data.get('sender_id')
if not file:
return Response({'error': 'Aucun fichier fourni'}, status=status.HTTP_400_BAD_REQUEST)
if not conversation_id or not sender_id:
return Response({'error': 'conversation_id et sender_id requis'}, status=status.HTTP_400_BAD_REQUEST)
# Vérifier que la conversation existe et que l'utilisateur y participe
try:
conversation = Conversation.objects.get(id=conversation_id)
sender = Profile.objects.get(id=sender_id)
# Vérifier que l'expéditeur participe à la conversation
if not ConversationParticipant.objects.filter(
conversation=conversation,
participant=sender,
is_active=True
).exists():
return Response({'error': 'Accès non autorisé à cette conversation'}, status=status.HTTP_403_FORBIDDEN)
except (Conversation.DoesNotExist, Profile.DoesNotExist):
return Response({'error': 'Conversation ou utilisateur introuvable'}, status=status.HTTP_404_NOT_FOUND)
# Valider le type de fichier
allowed_types = [
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain'
]
if file.content_type not in allowed_types:
return Response({'error': 'Type de fichier non autorisé'}, status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE)
# Valider la taille du fichier (10MB max)
max_size = 10 * 1024 * 1024 # 10MB
if file.size > max_size:
return Response({'error': 'Fichier trop volumineux (max 10MB)'}, status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE)
# Générer un nom de fichier unique
file_extension = os.path.splitext(file.name)[1]
unique_filename = f"{uuid.uuid4()}{file_extension}"
# Chemin de stockage : messagerie/conversation_id/
storage_path = f"messagerie/{conversation_id}/{unique_filename}"
# Sauvegarder le fichier
file_path = default_storage.save(storage_path, ContentFile(file.read()))
# Générer l'URL du fichier
file_url = default_storage.url(file_path)
if not file_url.startswith('http'):
# Construire l'URL complète si nécessaire
file_url = request.build_absolute_uri(file_url)
return Response({
'fileUrl': file_url,
'fileName': file.name,
'fileSize': file.size,
'fileType': file.content_type,
'filePath': file_path
}, status=status.HTTP_200_OK)
except Exception as e:
return Response({'error': f'Erreur lors de l\'upload: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class InstantRecipientSearchView(APIView):
"""
API pour rechercher des destinataires pour la messagerie instantanée
"""
@swagger_auto_schema(
operation_description="Recherche des destinataires pour la messagerie instantanée",
manual_parameters=[
openapi.Parameter('establishment_id', openapi.IN_QUERY, description="ID de l'établissement", type=openapi.TYPE_INTEGER, required=True),
openapi.Parameter('q', openapi.IN_QUERY, description="Terme de recherche", type=openapi.TYPE_STRING, required=True)
],
responses={200: ProfileSimpleSerializer(many=True)}
)
def get(self, request):
try:
establishment_id = request.query_params.get('establishment_id')
search_query = request.query_params.get('q', '').strip()
if not establishment_id:
return Response({'error': 'establishment_id is required'}, status=status.HTTP_400_BAD_REQUEST)
# Récupérer les IDs des profils actifs dans l'établissement
profile_roles = ProfileRole.objects.filter(
establishment_id=establishment_id,
is_active=True
).values_list('profile_id', flat=True)
# Rechercher les profils correspondants
users = Profile.objects.filter(id__in=profile_roles)
# Appliquer le filtre de recherche si un terme est fourni
if search_query:
users = users.filter(
Q(first_name__icontains=search_query) |
Q(last_name__icontains=search_query) |
Q(email__icontains=search_query)
)
# Exclure l'utilisateur actuel des résultats
if request.user.is_authenticated:
users = users.exclude(id=request.user.id)
serializer = ProfileSimpleSerializer(users[:10], many=True) # Limiter à 10 résultats
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class InstantConversationDeleteView(APIView):
"""
API pour supprimer (désactiver) une conversation instantanée
"""
@swagger_auto_schema(
operation_description="Supprime une conversation instantanée (désactivation soft)",
responses={200: openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
'message': openapi.Schema(type=openapi.TYPE_STRING)
}
)}
)
def delete(self, request, conversation_id):
try:
# Récupérer la conversation par son ID UUID
conversation = Conversation.objects.filter(id=conversation_id).first()
if not conversation:
return Response({'error': 'Conversation not found'}, status=status.HTTP_404_NOT_FOUND)
# Suppression simple : désactiver la conversation
conversation.is_active = False
conversation.save()
return Response({
'success': True,
'message': 'Conversation deleted successfully'
}, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Error deleting conversation: {str(e)}")
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)