From c6bc0d0b515a5be7b0bf930ff628a5e9b5ebbb33 Mon Sep 17 00:00:00 2001 From: Luc SORIGNET Date: Sat, 17 May 2025 11:35:57 +0200 Subject: [PATCH] feat: Mise en place du Backend-messagerie [#17] --- Back-End/GestionMessagerie/models.py | 10 +- Back-End/GestionMessagerie/serializers.py | 1 + Back-End/GestionMessagerie/urls.py | 6 +- Back-End/GestionMessagerie/views.py | 77 ++++++- Back-End/clean_migration_dir.py | 25 +++ .../app/[locale]/parents/messagerie/page.js | 50 +---- .../src/components/Admin/InstantMessaging.js | 100 +++++++-- Front-End/src/components/Chat.js | 206 ++++++++---------- Front-End/src/utils/Url.js | 4 +- 9 files changed, 291 insertions(+), 188 deletions(-) create mode 100644 Back-End/clean_migration_dir.py diff --git a/Back-End/GestionMessagerie/models.py b/Back-End/GestionMessagerie/models.py index a17f91f..51b1c0e 100644 --- a/Back-End/GestionMessagerie/models.py +++ b/Back-End/GestionMessagerie/models.py @@ -1,15 +1,15 @@ -from django.contrib.auth.models import AbstractUser from django.db import models -from django.utils.translation import gettext_lazy as _ -from django.conf import settings from Auth.models import Profile - + class Messagerie(models.Model): id = models.AutoField(primary_key=True) objet = models.CharField(max_length=200, default="", blank=True) emetteur = models.ForeignKey(Profile, on_delete=models.PROTECT, related_name='messages_envoyes') destinataire = models.ForeignKey(Profile, on_delete=models.PROTECT, related_name='messages_recus') 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): - return 'Messagerie_'+self.id \ No newline at end of file + return f'Messagerie_{self.id}' \ No newline at end of file diff --git a/Back-End/GestionMessagerie/serializers.py b/Back-End/GestionMessagerie/serializers.py index ceea37a..dc446d1 100644 --- a/Back-End/GestionMessagerie/serializers.py +++ b/Back-End/GestionMessagerie/serializers.py @@ -8,6 +8,7 @@ class MessageSerializer(serializers.ModelSerializer): class Meta: model = Messagerie fields = '__all__' + read_only_fields = ['date_envoi'] def get_destinataire_profil(self, obj): return obj.destinataire.email diff --git a/Back-End/GestionMessagerie/urls.py b/Back-End/GestionMessagerie/urls.py index 6cb4da6..31dc35a 100644 --- a/Back-End/GestionMessagerie/urls.py +++ b/Back-End/GestionMessagerie/urls.py @@ -1,5 +1,5 @@ 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 urlpatterns = [ @@ -8,4 +8,8 @@ urlpatterns = [ re_path(r'^messages/(?P[0-9]+)$', MessageSimpleView.as_view(), name="messages"), path('send-email/', SendEmailView.as_view(), name='send_email'), path('search-recipients/', search_recipients, name='search_recipients'), + # Endpoints pour le chat instantané + path('conversations//', ConversationListView.as_view(), name='conversations'), + path('conversations/messages//', ConversationMessagesView.as_view(), name='conversation_messages'), + path('conversations/mark-as-read//', MarkAsReadView.as_view(), name='mark_as_read'), ] \ No newline at end of file diff --git a/Back-End/GestionMessagerie/views.py b/Back-End/GestionMessagerie/views.py index 61db0a4..f6bddb8 100644 --- a/Back-End/GestionMessagerie/views.py +++ b/Back-End/GestionMessagerie/views.py @@ -5,16 +5,18 @@ 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 Auth.models import Profile, ProfileRole - -from .models import * +from GestionMessagerie.serializers import MessageSerializer from School.models import Teacher -from GestionMessagerie.serializers import MessageSerializer from School.serializers import TeacherSerializer -from N3wtSchool import bdd 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): def get(self, request, profile_id): @@ -140,3 +142,70 @@ def search_recipients(request): 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) + diff --git a/Back-End/clean_migration_dir.py b/Back-End/clean_migration_dir.py new file mode 100644 index 0000000..a781cfd --- /dev/null +++ b/Back-End/clean_migration_dir.py @@ -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é.") \ No newline at end of file diff --git a/Front-End/src/app/[locale]/parents/messagerie/page.js b/Front-End/src/app/[locale]/parents/messagerie/page.js index 9244958..c7d86e0 100644 --- a/Front-End/src/app/[locale]/parents/messagerie/page.js +++ b/Front-End/src/app/[locale]/parents/messagerie/page.js @@ -1,46 +1,16 @@ 'use client'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import Chat from '@/components/Chat'; -import { getGravatarUrl } from '@/utils/gravatar'; - -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'), - }, -]; +import { useSession } from '@/context/SessionContext'; +import { useEstablishment } from '@/context/EstablishmentContext'; export default function MessageriePage() { - const simulateResponse = (contactId, setMessages) => { - setTimeout(() => { - setMessages((prevMessages) => { - const contactMessages = prevMessages[contactId] || []; - return { - ...prevMessages, - [contactId]: [ - ...contactMessages, - { - id: contactMessages.length + 2, - text: 'Réponse automatique', - isResponse: true, - date: new Date(), - }, - ], - }; - }); - }, 2000); - }; + const { user } = useSession(); // Doit fournir l'id du parent connecté + const { selectedEstablishmentId } = useEstablishment(); - return ; + if (!user) return
Chargement...
; + + return ( + + ); } diff --git a/Front-End/src/components/Admin/InstantMessaging.js b/Front-End/src/components/Admin/InstantMessaging.js index 3efcd20..b24bec4 100644 --- a/Front-End/src/components/Admin/InstantMessaging.js +++ b/Front-End/src/components/Admin/InstantMessaging.js @@ -1,26 +1,88 @@ // 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 { getGravatarUrl } from '@/utils/gravatar'; -import logger from '@/utils/logger'; - -const contacts = [ - { - id: 1, - name: 'Parent 1', - profilePic: getGravatarUrl('parent1@n3wtschool.com'), - }, - { - id: 2, - name: 'Parent 2', - profilePic: getGravatarUrl('parent2@n3wtschool.com'), - }, -]; +import { useEstablishment } from '@/context/EstablishmentContext'; +import { + fetchConversations, + sendMessage, + searchRecipients, +} from '@/app/actions/messagerieAction'; +import RecipientInput from '@/components/RecipientInput'; +import Modal from '@/components/Modal'; export default function InstantMessaging({ csrfToken }) { - const handleSendMessage = (contact, message) => { - logger.debug(`Message envoyé à ${contact.name}: ${message}`); + const { user, selectedEstablishmentId } = useEstablishment(); + 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 ; + if (!user) return
Chargement...
; + + return ( +
+ setShowModal(true)} + /> + +
+

Nouvelle discussion

+ + setFirstMessage(e.target.value)} + placeholder="Premier message" + className="w-full p-2 mb-2 border rounded" + /> + +
+
+
+ ); } diff --git a/Front-End/src/components/Chat.js b/Front-End/src/components/Chat.js index ff661ec..594bac3 100644 --- a/Front-End/src/components/Chat.js +++ b/Front-End/src/components/Chat.js @@ -1,48 +1,53 @@ import React, { useState, useRef, useEffect } from 'react'; -import { SendHorizontal } from 'lucide-react'; +import { SendHorizontal, Plus } from 'lucide-react'; import Image from 'next/image'; +import { + fetchConversations, + fetchMessages, + sendMessage, + markAsRead, +} from '@/app/actions/messagerieAction'; export default function Chat({ - discussions, - setDiscussions, - onSendMessage, - simulateResponse, + userProfileId, + establishmentId, + discussions: discussionsProp, + setDiscussions: setDiscussionsProp, + onCreateDiscussion, + onShowCreateDiscussion, }) { + const [discussions, setDiscussions] = useState(discussionsProp || []); const [selectedDiscussion, setSelectedDiscussion] = useState(null); - const [messages, setMessages] = useState({}); + const [messages, setMessages] = useState([]); const [newMessage, setNewMessage] = useState(''); - const [showCreateForm, setShowCreateForm] = useState(false); - const [newDiscussionName, setNewDiscussionName] = useState(''); - const [newDiscussionProfilePic, setNewDiscussionProfilePic] = useState(''); const messagesEndRef = useRef(null); - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }; + useEffect(() => { + if (userProfileId) { + fetchConversations(userProfileId).then(setDiscussions); + } + }, [userProfileId]); useEffect(() => { - scrollToBottom(); - }, [messages]); + if (selectedDiscussion) { + 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) { - const discussionMessages = messages[selectedDiscussion.id] || []; - const newMessages = { - ...messages, - [selectedDiscussion.id]: [ - ...discussionMessages, - { - id: discussionMessages.length + 1, - text: newMessage, - date: new Date(), - isResponse: false, - }, - ], - }; - setMessages(newMessages); + await sendMessage({ + conversation_id: selectedDiscussion.conversation_id, + emetteur: userProfileId, + destinataire: selectedDiscussion.interlocuteur.id, + corpus: newMessage, + objet: '', + }); setNewMessage(''); - onSendMessage && onSendMessage(selectedDiscussion, newMessage); - simulateResponse && simulateResponse(selectedDiscussion.id, setMessages); + fetchMessages(selectedDiscussion.conversation_id).then(setMessages); + fetchConversations(userProfileId).then(setDiscussions); } }; @@ -52,89 +57,55 @@ 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 ( -
- {/* Liste des discussions */} -
-

Discussions

- - {showCreateForm && ( -
- setNewDiscussionName(e.target.value)} - placeholder="Nom de la discussion" - className="w-full p-2 mb-2 border rounded" - /> - setNewDiscussionProfilePic(e.target.value)} - placeholder="URL de la photo de profil (optionnel)" - className="w-full p-2 mb-2 border rounded" - /> - -
- )} - {discussions && discussions.length > 0 ? ( - discussions.map((discussion) => ( -
setSelectedDiscussion(discussion)} - > - {`${discussion.name}'s -
-

{discussion.name}

-

- {discussion.lastMessage} -

+
+ {/* Bandeau droit : Liste des discussions */} +
+
+

Discussions

+ +
+
+ {discussions && discussions.length > 0 ? ( + discussions.map((discussion) => ( +
setSelectedDiscussion(discussion)} + > + {`${discussion.name}'s +
+

{discussion.name}

+

+ {discussion.lastMessage} +

+
+ + {discussion.lastMessageDate && + new Date(discussion.lastMessageDate).toLocaleTimeString()} +
- - {new Date(discussion.lastMessageDate).toLocaleTimeString()} - -
- )) - ) : ( -

Aucune discussion disponible.

- )} + )) + ) : ( +

Aucune discussion disponible.

+ )} +
{/* Zone de chat */} @@ -152,11 +123,10 @@ export default function Chat({

{selectedDiscussion.name}

)} - {/* Messages */}
{selectedDiscussion && - (messages[selectedDiscussion.id] || []).map((message) => ( + messages.map((message) => (
-

{message.text}

+

{message.corpus}

- {new Date(message.date).toLocaleTimeString()} + {message.date && + new Date(message.date).toLocaleTimeString()}
))}
- {/* Champ de saisie */} {selectedDiscussion && (
@@ -189,7 +159,7 @@ export default function Chat({ onChange={(e) => setNewMessage(e.target.value)} className="flex-1 p-2 border border-gray-300 rounded-lg mr-2" placeholder="Écrire un message..." - onKeyDown={handleKeyPress} + onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()} />