feat: Mise en place du Backend-messagerie [#17]

This commit is contained in:
Luc SORIGNET
2025-05-17 11:35:57 +02:00
parent fc9a1ed252
commit c6bc0d0b51
9 changed files with 291 additions and 188 deletions

View File

@ -1,7 +1,4 @@
from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _
from django.conf import settings
from Auth.models import Profile from Auth.models import Profile
class Messagerie(models.Model): class Messagerie(models.Model):
@ -10,6 +7,9 @@ class Messagerie(models.Model):
emetteur = models.ForeignKey(Profile, on_delete=models.PROTECT, related_name='messages_envoyes') emetteur = models.ForeignKey(Profile, on_delete=models.PROTECT, related_name='messages_envoyes')
destinataire = models.ForeignKey(Profile, on_delete=models.PROTECT, related_name='messages_recus') destinataire = models.ForeignKey(Profile, on_delete=models.PROTECT, related_name='messages_recus')
corpus = models.CharField(max_length=200, default="", blank=True) corpus = models.CharField(max_length=200, default="", blank=True)
date_envoi = models.DateTimeField(auto_now_add=True) # Date d'envoi du message
is_read = models.BooleanField(default=False) # Statut lu/non lu
conversation_id = models.CharField(max_length=100, blank=True, default="") # Pour regrouper les messages par conversation
def __str__(self): def __str__(self):
return 'Messagerie_'+self.id return f'Messagerie_{self.id}'

View File

@ -8,6 +8,7 @@ class MessageSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Messagerie model = Messagerie
fields = '__all__' fields = '__all__'
read_only_fields = ['date_envoi']
def get_destinataire_profil(self, obj): def get_destinataire_profil(self, obj):
return obj.destinataire.email return obj.destinataire.email

View File

@ -1,5 +1,5 @@
from django.urls import path, re_path from django.urls import path, re_path
from .views import SendEmailView, search_recipients from .views import SendEmailView, search_recipients, ConversationListView, ConversationMessagesView, MarkAsReadView
from GestionMessagerie.views import MessagerieView, MessageView, MessageSimpleView from GestionMessagerie.views import MessagerieView, MessageView, MessageSimpleView
urlpatterns = [ urlpatterns = [
@ -8,4 +8,8 @@ urlpatterns = [
re_path(r'^messages/(?P<id>[0-9]+)$', MessageSimpleView.as_view(), name="messages"), re_path(r'^messages/(?P<id>[0-9]+)$', MessageSimpleView.as_view(), name="messages"),
path('send-email/', SendEmailView.as_view(), name='send_email'), path('send-email/', SendEmailView.as_view(), name='send_email'),
path('search-recipients/', search_recipients, name='search_recipients'), path('search-recipients/', search_recipients, name='search_recipients'),
# Endpoints pour le chat instantané
path('conversations/<int:profile_id>/', ConversationListView.as_view(), name='conversations'),
path('conversations/messages/<str:conversation_id>/', ConversationMessagesView.as_view(), name='conversation_messages'),
path('conversations/mark-as-read/<str:conversation_id>/', MarkAsReadView.as_view(), name='mark_as_read'),
] ]

View File

@ -5,16 +5,18 @@ from django.conf import settings
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from django.db.models import Q from django.db.models import Q
from .models import Messagerie
from Auth.models import Profile, ProfileRole from Auth.models import Profile, ProfileRole
from GestionMessagerie.serializers import MessageSerializer
from .models import *
from School.models import Teacher from School.models import Teacher
from GestionMessagerie.serializers import MessageSerializer
from School.serializers import TeacherSerializer from School.serializers import TeacherSerializer
from N3wtSchool import bdd
import N3wtSchool.mailManager as mailer import N3wtSchool.mailManager as mailer
from N3wtSchool import bdd
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from rest_framework.exceptions import NotFound
class MessagerieView(APIView): class MessagerieView(APIView):
def get(self, request, profile_id): def get(self, request, profile_id):
@ -140,3 +142,70 @@ def search_recipients(request):
return JsonResponse(results, safe=False) 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é.
"""
@swagger_auto_schema(
operation_description="Liste les conversations d'un utilisateur (parent ou enseignant).",
responses={200: openapi.Response('Liste des conversations')}
)
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)
class ConversationMessagesView(APIView):
"""
Récupère tous les messages d'une conversation donnée.
"""
@swagger_auto_schema(
operation_description="Récupère tous les messages d'une conversation donnée.",
responses={200: openapi.Response('Liste des messages')}
)
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)
class MarkAsReadView(APIView):
"""
Marque tous les messages reçus dans une conversation comme lus pour l'utilisateur connecté.
"""
@swagger_auto_schema(
operation_description="Marque tous les messages reçus dans une conversation comme lus pour l'utilisateur connecté.",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'profile_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='ID du profil utilisateur')
},
required=['profile_id']
),
responses={200: openapi.Response('Statut OK')}
)
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)

View File

@ -0,0 +1,25 @@
import os
import shutil
APPS = [
"Establishment",
"Settings",
"Subscriptions",
"Planning",
"GestionNotification",
"GestionMessagerie",
"Auth",
"School",
]
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
for app in APPS:
migrations_path = os.path.join(BASE_DIR, app, "migrations")
if os.path.isdir(migrations_path):
print(f"Suppression du dossier : {migrations_path}")
shutil.rmtree(migrations_path)
else:
print(f"Aucun dossier de migration trouvé pour {app}")
print("Nettoyage terminé.")

View File

@ -1,46 +1,16 @@
'use client'; 'use client';
import React from 'react'; import React, { useEffect, useState } from 'react';
import Chat from '@/components/Chat'; import Chat from '@/components/Chat';
import { getGravatarUrl } from '@/utils/gravatar'; import { useSession } from '@/context/SessionContext';
import { useEstablishment } from '@/context/EstablishmentContext';
const contacts = [
{
id: 1,
name: 'Facturation',
profilePic: getGravatarUrl('facturation@n3wtschool.com'),
},
{
id: 2,
name: 'Enseignant 1',
profilePic: getGravatarUrl('enseignant@n3wtschool.com'),
},
{
id: 3,
name: 'Contact',
profilePic: getGravatarUrl('contact@n3wtschool.com'),
},
];
export default function MessageriePage() { export default function MessageriePage() {
const simulateResponse = (contactId, setMessages) => { const { user } = useSession(); // Doit fournir l'id du parent connecté
setTimeout(() => { const { selectedEstablishmentId } = useEstablishment();
setMessages((prevMessages) => {
const contactMessages = prevMessages[contactId] || [];
return {
...prevMessages,
[contactId]: [
...contactMessages,
{
id: contactMessages.length + 2,
text: 'Réponse automatique',
isResponse: true,
date: new Date(),
},
],
};
});
}, 2000);
};
return <Chat contacts={contacts} simulateResponse={simulateResponse} />; if (!user) return <div>Chargement...</div>;
return (
<Chat userProfileId={user.id} establishmentId={selectedEstablishmentId} />
);
} }

View File

@ -1,26 +1,88 @@
// filepath: d:\Dev\n3wt-innov\n3wt-school\Front-End\src\components\Admin\InstantMessaging.js // filepath: d:\Dev\n3wt-innov\n3wt-school\Front-End\src\components\Admin\InstantMessaging.js
import React from 'react'; import React, { useEffect, useState } from 'react';
import Chat from '@/components/Chat'; import Chat from '@/components/Chat';
import { getGravatarUrl } from '@/utils/gravatar'; import { useEstablishment } from '@/context/EstablishmentContext';
import logger from '@/utils/logger'; import {
fetchConversations,
const contacts = [ sendMessage,
{ searchRecipients,
id: 1, } from '@/app/actions/messagerieAction';
name: 'Parent 1', import RecipientInput from '@/components/RecipientInput';
profilePic: getGravatarUrl('parent1@n3wtschool.com'), import Modal from '@/components/Modal';
},
{
id: 2,
name: 'Parent 2',
profilePic: getGravatarUrl('parent2@n3wtschool.com'),
},
];
export default function InstantMessaging({ csrfToken }) { export default function InstantMessaging({ csrfToken }) {
const handleSendMessage = (contact, message) => { const { user, selectedEstablishmentId } = useEstablishment();
logger.debug(`Message envoyé à ${contact.name}: ${message}`); const [discussions, setDiscussions] = useState([]);
const [recipients, setRecipients] = useState([]); // Liste des correspondants sélectionnés
const [showModal, setShowModal] = useState(false);
const [firstMessage, setFirstMessage] = useState('');
useEffect(() => {
if (user) {
fetchConversations(user.id).then(setDiscussions);
}
}, [user]);
// Fonction pour ajouter une nouvelle discussion avec plusieurs correspondants
const handleCreateDiscussion = async () => {
if (!user || recipients.length === 0 || !firstMessage.trim()) return;
for (const recipient of recipients) {
await sendMessage({
emetteur: user.id,
destinataire: recipient.id,
objet: '',
corpus: firstMessage,
conversation_id: undefined, // L'API générera un nouvel ID
});
}
setRecipients([]);
setFirstMessage('');
setShowModal(false);
fetchConversations(user.id).then(setDiscussions);
}; };
return <Chat contacts={contacts} onSendMessage={handleSendMessage} />; if (!user) return <div>Chargement...</div>;
return (
<div className="h-full w-full">
<Chat
userProfileId={user.id}
establishmentId={selectedEstablishmentId}
discussions={discussions}
setDiscussions={setDiscussions}
onShowCreateDiscussion={() => setShowModal(true)}
/>
<Modal
isOpen={showModal}
setIsOpen={setShowModal}
title="Nouvelle discussion"
modalClassName="w-full max-w-xs sm:max-w-md"
>
<div className="p-2 sm:p-4">
<h3 className="text-lg font-bold mb-2">Nouvelle discussion</h3>
<RecipientInput
label="Rechercher un correspondant"
recipients={recipients}
setRecipients={setRecipients}
searchRecipients={searchRecipients}
establishmentId={selectedEstablishmentId}
required
/>
<input
type="text"
value={firstMessage}
onChange={(e) => setFirstMessage(e.target.value)}
placeholder="Premier message"
className="w-full p-2 mb-2 border rounded"
/>
<button
onClick={handleCreateDiscussion}
className="w-full p-2 bg-green-500 text-white rounded-lg"
>
Démarrer la discussion
</button>
</div>
</Modal>
</div>
);
} }

View File

@ -1,48 +1,53 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { SendHorizontal } from 'lucide-react'; import { SendHorizontal, Plus } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import {
fetchConversations,
fetchMessages,
sendMessage,
markAsRead,
} from '@/app/actions/messagerieAction';
export default function Chat({ export default function Chat({
discussions, userProfileId,
setDiscussions, establishmentId,
onSendMessage, discussions: discussionsProp,
simulateResponse, setDiscussions: setDiscussionsProp,
onCreateDiscussion,
onShowCreateDiscussion,
}) { }) {
const [discussions, setDiscussions] = useState(discussionsProp || []);
const [selectedDiscussion, setSelectedDiscussion] = useState(null); const [selectedDiscussion, setSelectedDiscussion] = useState(null);
const [messages, setMessages] = useState({}); const [messages, setMessages] = useState([]);
const [newMessage, setNewMessage] = useState(''); const [newMessage, setNewMessage] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
const [newDiscussionName, setNewDiscussionName] = useState('');
const [newDiscussionProfilePic, setNewDiscussionProfilePic] = useState('');
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
const scrollToBottom = () => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); if (userProfileId) {
}; fetchConversations(userProfileId).then(setDiscussions);
}
}, [userProfileId]);
useEffect(() => { useEffect(() => {
scrollToBottom(); if (selectedDiscussion) {
}, [messages]); fetchMessages(selectedDiscussion.conversation_id).then(setMessages);
// Marquer comme lu
markAsRead(selectedDiscussion.conversation_id, userProfileId);
}
}, [selectedDiscussion, userProfileId]);
const handleSendMessage = () => { const handleSendMessage = async () => {
if (newMessage.trim() && selectedDiscussion) { if (newMessage.trim() && selectedDiscussion) {
const discussionMessages = messages[selectedDiscussion.id] || []; await sendMessage({
const newMessages = { conversation_id: selectedDiscussion.conversation_id,
...messages, emetteur: userProfileId,
[selectedDiscussion.id]: [ destinataire: selectedDiscussion.interlocuteur.id,
...discussionMessages, corpus: newMessage,
{ objet: '',
id: discussionMessages.length + 1, });
text: newMessage,
date: new Date(),
isResponse: false,
},
],
};
setMessages(newMessages);
setNewMessage(''); setNewMessage('');
onSendMessage && onSendMessage(selectedDiscussion, newMessage); fetchMessages(selectedDiscussion.conversation_id).then(setMessages);
simulateResponse && simulateResponse(selectedDiscussion.id, setMessages); fetchConversations(userProfileId).then(setDiscussions);
} }
}; };
@ -52,62 +57,26 @@ export default function Chat({
} }
}; };
const handleCreateDiscussion = () => {
if (newDiscussionName.trim()) {
const newDiscussion = {
id: discussions.length + 1,
name: newDiscussionName,
profilePic: newDiscussionProfilePic || '/default-profile.png', // Image par défaut si aucune n'est fournie
lastMessage: '',
lastMessageDate: new Date(),
};
setDiscussions([...discussions, newDiscussion]);
setNewDiscussionName('');
setNewDiscussionProfilePic('');
setShowCreateForm(false);
}
};
return ( return (
<div className="flex h-full"> <div className="flex h-full w-full">
{/* Liste des discussions */} {/* Bandeau droit : Liste des discussions */}
<div className="w-1/4 bg-gray-100 border-r border-gray-300 p-4 overflow-y-auto"> <div className="w-1/4 min-w-[280px] bg-gray-100 border-r border-gray-300 p-4 flex flex-col">
<h2 className="text-lg font-bold mb-4">Discussions</h2> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold">Discussions</h2>
<button <button
onClick={() => setShowCreateForm(!showCreateForm)} className="p-2 rounded-full bg-blue-500 hover:bg-blue-600 text-white shadow"
className="w-full p-2 mb-4 bg-blue-500 text-white rounded-lg" title="Nouvelle discussion"
onClick={onShowCreateDiscussion}
> >
{showCreateForm ? 'Annuler' : 'Créer une discussion'} <Plus size={20} />
</button>
{showCreateForm && (
<div className="mb-4 p-2 border rounded-lg bg-white">
<input
type="text"
value={newDiscussionName}
onChange={(e) => setNewDiscussionName(e.target.value)}
placeholder="Nom de la discussion"
className="w-full p-2 mb-2 border rounded"
/>
<input
type="text"
value={newDiscussionProfilePic}
onChange={(e) => setNewDiscussionProfilePic(e.target.value)}
placeholder="URL de la photo de profil (optionnel)"
className="w-full p-2 mb-2 border rounded"
/>
<button
onClick={handleCreateDiscussion}
className="w-full p-2 bg-green-500 text-white rounded-lg"
>
Ajouter
</button> </button>
</div> </div>
)} <div className="flex-1 overflow-y-auto">
{discussions && discussions.length > 0 ? ( {discussions && discussions.length > 0 ? (
discussions.map((discussion) => ( discussions.map((discussion) => (
<div <div
key={discussion.id} key={discussion.id}
className={`flex items-center p-2 mb-2 cursor-pointer rounded ${ className={`flex items-center p-2 mb-2 cursor-pointer rounded transition-colors ${
selectedDiscussion?.id === discussion.id selectedDiscussion?.id === discussion.id
? 'bg-blue-100' ? 'bg-blue-100'
: 'hover:bg-gray-200' : 'hover:bg-gray-200'
@ -128,7 +97,8 @@ export default function Chat({
</p> </p>
</div> </div>
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-400">
{new Date(discussion.lastMessageDate).toLocaleTimeString()} {discussion.lastMessageDate &&
new Date(discussion.lastMessageDate).toLocaleTimeString()}
</span> </span>
</div> </div>
)) ))
@ -136,6 +106,7 @@ export default function Chat({
<p className="text-gray-500">Aucune discussion disponible.</p> <p className="text-gray-500">Aucune discussion disponible.</p>
)} )}
</div> </div>
</div>
{/* Zone de chat */} {/* Zone de chat */}
<div className="flex-1 flex flex-col bg-white"> <div className="flex-1 flex flex-col bg-white">
@ -152,11 +123,10 @@ export default function Chat({
<h2 className="text-lg font-bold">{selectedDiscussion.name}</h2> <h2 className="text-lg font-bold">{selectedDiscussion.name}</h2>
</div> </div>
)} )}
{/* Messages */} {/* Messages */}
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
{selectedDiscussion && {selectedDiscussion &&
(messages[selectedDiscussion.id] || []).map((message) => ( messages.map((message) => (
<div <div
key={message.id} key={message.id}
className={`flex mb-4 ${ className={`flex mb-4 ${
@ -170,16 +140,16 @@ export default function Chat({
: 'bg-blue-500 text-white' : 'bg-blue-500 text-white'
}`} }`}
> >
<p>{message.text}</p> <p>{message.corpus}</p>
<span className="text-xs text-gray-500 block mt-1"> <span className="text-xs text-gray-500 block mt-1">
{new Date(message.date).toLocaleTimeString()} {message.date &&
new Date(message.date).toLocaleTimeString()}
</span> </span>
</div> </div>
</div> </div>
))} ))}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
{/* Champ de saisie */} {/* Champ de saisie */}
{selectedDiscussion && ( {selectedDiscussion && (
<div className="p-4 border-t border-gray-300 flex items-center"> <div className="p-4 border-t border-gray-300 flex items-center">
@ -189,7 +159,7 @@ export default function Chat({
onChange={(e) => setNewMessage(e.target.value)} onChange={(e) => setNewMessage(e.target.value)}
className="flex-1 p-2 border border-gray-300 rounded-lg mr-2" className="flex-1 p-2 border border-gray-300 rounded-lg mr-2"
placeholder="Écrire un message..." placeholder="Écrire un message..."
onKeyDown={handleKeyPress} onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()}
/> />
<button <button
onClick={handleSendMessage} onClick={handleSendMessage}

View File

@ -51,8 +51,10 @@ export const BE_PLANNING_PLANNINGS_URL = `${BASE_URL}/Planning/plannings`;
export const BE_PLANNING_EVENTS_URL = `${BASE_URL}/Planning/events`; export const BE_PLANNING_EVENTS_URL = `${BASE_URL}/Planning/events`;
// GESTION MESSAGERIE // GESTION MESSAGERIE
export const BE_GESTIONMESSAGERIE_CONVERSATIONS_URL = `${BASE_URL}/GestionMessagerie/conversations`;
export const BE_GESTIONMESSAGERIE_CONVERSATION_MESSAGES_URL = `${BASE_URL}/GestionMessagerie/conversations/messages`;
export const BE_GESTIONMESSAGERIE_MARK_AS_READ_URL = `${BASE_URL}/GestionMessagerie/conversations/mark-as-read`;
export const BE_GESTIONMESSAGERIE_MESSAGES_URL = `${BASE_URL}/GestionMessagerie/messages`; export const BE_GESTIONMESSAGERIE_MESSAGES_URL = `${BASE_URL}/GestionMessagerie/messages`;
export const BE_GESTIONMESSAGERIE_MESSAGERIE_URL = `${BASE_URL}/GestionMessagerie/messagerie`;
export const BE_GESTIONMESSAGERIE_SEND_MESSAGE_URL = `${BASE_URL}/GestionMessagerie/send-email/`; export const BE_GESTIONMESSAGERIE_SEND_MESSAGE_URL = `${BASE_URL}/GestionMessagerie/send-email/`;
export const BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL = `${BASE_URL}/GestionMessagerie/search-recipients`; export const BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL = `${BASE_URL}/GestionMessagerie/search-recipients`;