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,61 +0,0 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{% static 'css/main.css' %}">
<title>Monteschool</title>
</head>
<body>
<div class="sidebar">
</div>
<div class="container">
<div class="logo-circular centered">
<img src="{% static 'img/logo_min.svg' %}" alt="">
</div>
<h1 class="login-heading">Authentification</h1>
<form class="centered login-form" method="post">
{% csrf_token %}
<div class="input-group">
<label for="userInput">{{ form.email.label }}</label>
<div class="input-wrapper">
<span class="icon-ctn">
<i class="icon user"></i>
</span>
<input type="text" id="userInput" placeholder='Identifiant' name="email">
</div>
</div>
<div class="input-group">
<label for="userInput">{{ form.password.label }}</label>
<div class="input-wrapper">
<span class="icon-ctn">
<i class="icon key"></i>
</span>
<input type="password" id="userInput" placeholder="Mot de passe" name="password">
</div>
<p style="color:#FF0000">{{ message }}</p>
{% if form.errors %}
{% for field in form %}
{% for error in field.errors %}
<p style="color:#FF0000">{{ error }}</p>
{% endfor %}
{% endfor %}
{% for error in form.non_field_errors %}
<p style="color:#FF0000">{{ error }}</p>
{% endfor %}
{% endif %}
<label><a class="right" href='/reset/{{code}}'>Mot de passe oublié ?</a></label>
</div>
<div class="form-group-submit">
<button href="" class="btn primary" type="submit" name="connect">Se Connecter</button>
<br>
<h2>Pas de compte ?</h2>
<br>
<button href="" class="btn " name="register">S'inscrire</button>
</div>
</form>
</div>
</body>
</html>

View File

@ -1,64 +0,0 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{% static 'css/main.css' %}">
<title>Monteschool</title>
</head>
<body>
<div class="container negative full-size">
<div class="logo-circular centered">
<img src="{% static 'img/logo_min.svg' %}" alt="">
</div>
<h1 class="login-heading">Nouveau Mot de Passe</h1>
<form class="negative centered login-form" method="post">
{% csrf_token %}
<div class="input-group" hidden>
<label for="userInput">Identifiant</label>
<div class="input-wrapper">
<span class="icon-ctn">
<i class="icon user"></i>
</span>
<input type="text" id="userInput" placeholder='Identifiant' value='{{ identifiant }}' name="email">
</div>
</div>
<div class="input-group">
<label for="password">{{ form.password1.label }}</label>
<div class="input-wrapper">
<span class="icon-ctn">
<i class="icon key"></i>
</span>
<input type="password" id="password" placeholder="{{ form.password1.label }}" name="password1">
</div>
</div>
<div class="input-group">
<label for="confirmPassword">{{ form.password2.label }}</label>
<div class="input-wrapper">
<span class="icon-ctn">
<i class="icon key"></i>
</span>
<input type="password" id="confirmPassword" placeholder="{{ form.password2.label }}" name="password2">
</div>
</div>
<p style="color:#FF0000">{{ message }}</p>
{% if form.errors %}
{% for field in form %}
{% for error in field.errors %}
<p style="color:#FF0000">{{ error }}</p>
{% endfor %}
{% endfor %}
{% for error in form.non_field_errors %}
<p style="color:#FF0000">{{ error }}</p>
{% endfor %}
{% endif %}
<div class="form-group-submit negative">
<button href="" class="btn primary" type="submit" name="save">Enregistrer</button>
<br>
<button href="" class="btn" type="submit" name="cancel">Annuler</button>
</div>
</form>
</div>
</body>
</html>

View File

@ -1,37 +0,0 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{% static 'css/main.css' %}">
<title>Monteschool</title>
</head>
<body>
<div class="container negative full-size">
<div class="logo-circular centered">
<img src="{% static 'img/logo_min.svg' %}" alt="">
</div>
<h1 class="login-heading"> Réinitialiser Mot de Passe</h1>
<form class="negative centered login-form" method="post">
{% csrf_token %}
<div class="input-group">
<label for="username">Identifiant</label>
<div class="input-wrapper">
<span class="icon-ctn">
<i class="icon user"></i>
</span>
<input type="text" id="username" placeholder="Identifiant" name="email">
</div>
</div>
<p style="color:#FF0000">{{ message }}</p>
<div class="form-group-submit negative">
<button href="" class="btn primary" type="submit" name="reinit">Réinitialiser</button>
<br>
<button href="" class="btn" type="submit" name="cancel">Annuler</button>
</div>
</form>
</div>
</body>
</html>

View File

@ -1,64 +0,0 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{% static 'css/main.css' %}">
<title>Monteschool</title>
</head>
<body>
<div class="container negative full-size">
<div class="logo-circular centered">
<img src="{% static 'img/logo_min.svg' %}" alt="">
</div>
<h1 class="login-heading">S'inscrire</h1>
<form class="negative centered login-form" method="post">
{% csrf_token %}
<div class="input-group">
<label for="username">{{ form.email.label }}</label>
<div class="input-wrapper">
<span class="icon-ctn">
<i class="icon user"></i>
</span>
<input type="text" id="username" placeholder="Identifiant" name="email">
</div>
</div>
<div class="input-group">
<label for="password">{{ form.password1.label }}</label>
<div class="input-wrapper">
<span class="icon-ctn">
<i class="icon key"></i>
</span>
<input type="password" id="password" placeholder="{{ form.password1.label }}" name="password1">
</div>
</div>
<div class="input-group">
<label for="confirmPassword">{{ form.password2.label }}</label>
<div class="input-wrapper">
<span class="icon-ctn">
<i class="icon key"></i>
</span>
<input type="password" id="confirmPassword" placeholder="{{ form.password2.label }}" name="password2">
</div>
</div>
<p style="color:#FF0000">{{ message }}</p>
{% if form.errors %}
{% for field in form %}
{% for error in field.errors %}
<p style="color:#FF0000">{{ error }}</p>
{% endfor %}
{% endfor %}
{% for error in form.non_field_errors %}
<p style="color:#FF0000">{{ error }}</p>
{% endfor %}
{% endif %}
<div class="form-group-submit negative">
<button href="" class="btn primary" type="submit" name="validate">Enregistrer</button>
<br>
<button href="" class="btn" name="cancel">Annuler</button>
</div>
</form>
</div>
</body>
</html>

View File

@ -0,0 +1 @@
default_app_config = 'GestionEmail.apps.GestionEmailConfig'

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class GestionEmailConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'GestionEmail'

View File

@ -0,0 +1,9 @@
from django.urls import path
from .views import (
SendEmailView, search_recipients
)
urlpatterns = [
path('send-email/', SendEmailView.as_view(), name='send_email'),
path('search-recipients/', search_recipients, name='search_recipients'),
]

View File

@ -0,0 +1,119 @@
from django.http.response import JsonResponse
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from django.db.models import Q
from Auth.models import Profile, ProfileRole
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
import uuid
import logging
# Ajouter un logger pour debug
logger = logging.getLogger(__name__)
class SendEmailView(APIView):
"""
API pour envoyer des emails aux parents et professeurs.
"""
def post(self, request):
# Ajouter du debug
logger.info(f"Request data received: {request.data}")
logger.info(f"Request content type: {request.content_type}")
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', '')
# Debug des données reçues
logger.info(f"Recipients: {recipients} (type: {type(recipients)})")
logger.info(f"CC: {cc} (type: {type(cc)})")
logger.info(f"BCC: {bcc} (type: {type(bcc)})")
logger.info(f"Subject: {subject}")
logger.info(f"Message length: {len(message) if message else 0}")
logger.info(f"Establishment ID: {establishment_id}")
if not recipients or not message:
logger.error("Recipients or message missing")
logger.error(f"Recipients empty: {not recipients}, Message empty: {not message}")
logger.error(f"Recipients value: '{recipients}', Message value: '{message}'")
return Response({'error': 'Les destinataires et le message sont requis.'}, status=status.HTTP_400_BAD_REQUEST)
try:
# Récupérer la connexion SMTP
logger.info("Tentative de récupération de la connexion SMTP...")
connection = mailer.getConnection(establishment_id)
logger.info(f"Connexion SMTP récupérée: {connection}")
# Envoyer l'email
logger.info("Tentative d'envoi de l'email...")
result = mailer.sendMail(
subject=subject,
message=message,
recipients=recipients,
cc=cc,
bcc=bcc,
attachments=[],
connection=connection
)
logger.info(f"Email envoyé avec succès: {result}")
return result
except NotFound as e:
logger.error(f"NotFound error: {str(e)}")
return Response({'error': str(e)}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
logger.error(f"Exception during email sending: {str(e)}")
logger.error(f"Exception type: {type(e)}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
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)

View File

@ -0,0 +1,627 @@
import json
import logging
from uuid import UUID
from decimal import Decimal
from datetime import datetime
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from django.utils import timezone
from .models import Conversation, ConversationParticipant, Message, UserPresence, MessageRead
from .serializers import MessageSerializer, ConversationSerializer
from Auth.models import Profile
logger = logging.getLogger(__name__)
def serialize_for_websocket(data):
"""
Convertit récursivement les objets non-sérialisables en JSON en types sérialisables
"""
if isinstance(data, dict):
return {key: serialize_for_websocket(value) for key, value in data.items()}
elif isinstance(data, list):
return [serialize_for_websocket(item) for item in data]
elif isinstance(data, UUID):
return str(data)
elif isinstance(data, Decimal):
return float(data)
elif isinstance(data, datetime):
return data.isoformat()
else:
return data
class ChatConsumer(AsyncWebsocketConsumer):
"""Consumer WebSocket pour la messagerie instantanée"""
async def connect(self):
self.user_id = self.scope['url_route']['kwargs']['user_id']
self.user_group_name = f'user_{self.user_id}'
# Vérifier si l'utilisateur est authentifié
user = self.scope.get('user')
if not user or user.is_anonymous:
logger.warning(f"Tentative de connexion WebSocket non authentifiée pour user_id: {self.user_id}")
await self.close()
return
# Vérifier que l'utilisateur connecté correspond à l'user_id de l'URL
if str(user.id) != str(self.user_id):
logger.warning(f"Tentative d'accès WebSocket avec user_id incorrect: {self.user_id} vs {user.id}")
await self.close()
return
self.user = user
# Rejoindre le groupe utilisateur
await self.channel_layer.group_add(
self.user_group_name,
self.channel_name
)
# Rejoindre les groupes des conversations de l'utilisateur
conversations = await self.get_user_conversations(self.user_id)
for conversation in conversations:
await self.channel_layer.group_add(
f'conversation_{conversation.id}',
self.channel_name
)
# Mettre à jour le statut de présence
presence = await self.update_user_presence(self.user_id, 'online')
# Notifier les autres utilisateurs du changement de statut
if presence:
await self.broadcast_presence_update(self.user_id, 'online')
# Envoyer les statuts de présence existants des autres utilisateurs connectés
await self.send_existing_user_presences()
await self.accept()
logger.info(f"User {self.user_id} connected to chat")
async def send_existing_user_presences(self):
"""Envoyer les statuts de présence existants des autres utilisateurs connectés"""
try:
# Obtenir toutes les conversations de cet utilisateur
conversations = await self.get_user_conversations(self.user_id)
# Créer un set pour éviter les doublons d'utilisateurs
other_users = set()
# Pour chaque conversation, récupérer les participants
for conversation in conversations:
participants = await self.get_conversation_participants(conversation.id)
for participant in participants:
if participant.id != self.user_id:
other_users.add(participant.id)
# Envoyer le statut de présence pour chaque utilisateur
for user_id in other_users:
presence = await self.get_user_presence(user_id)
if presence:
await self.send(text_data=json.dumps({
'type': 'presence_update',
'user_id': str(user_id),
'status': presence.status
}))
except Exception as e:
logger.error(f"Error sending existing user presences: {str(e)}")
async def disconnect(self, close_code):
# Quitter tous les groupes
await self.channel_layer.group_discard(
self.user_group_name,
self.channel_name
)
if hasattr(self, 'user'):
conversations = await self.get_user_conversations(self.user_id)
for conversation in conversations:
await self.channel_layer.group_discard(
f'conversation_{conversation.id}',
self.channel_name
)
# Mettre à jour le statut de présence
presence = await self.update_user_presence(self.user_id, 'offline')
# Notifier les autres utilisateurs du changement de statut
if presence:
await self.broadcast_presence_update(self.user_id, 'offline')
logger.info(f"User {self.user_id} disconnected from chat")
async def receive(self, text_data):
"""Recevoir et traiter les messages du client"""
try:
text_data_json = json.loads(text_data)
message_type = text_data_json.get('type')
if message_type == 'send_message':
await self.handle_send_message(text_data_json)
elif message_type == 'typing_start':
await self.handle_typing_start(text_data_json)
elif message_type == 'typing_stop':
await self.handle_typing_stop(text_data_json)
elif message_type == 'mark_as_read':
await self.handle_mark_as_read(text_data_json)
elif message_type == 'join_conversation':
await self.handle_join_conversation(text_data_json)
elif message_type == 'leave_conversation':
await self.handle_leave_conversation(text_data_json)
elif message_type == 'presence_update':
await self.handle_presence_update(text_data_json)
else:
logger.warning(f"Unknown message type: {message_type}")
await self.send(text_data=json.dumps({
'type': 'error',
'message': f'Unknown message type: {message_type}'
}))
except json.JSONDecodeError:
await self.send(text_data=json.dumps({
'type': 'error',
'message': 'Invalid JSON format'
}))
except Exception as e:
logger.error(f"Error in receive: {str(e)}")
await self.send(text_data=json.dumps({
'type': 'error',
'message': 'Internal server error'
}))
async def handle_send_message(self, data):
"""Gérer l'envoi d'un nouveau message"""
conversation_id = data.get('conversation_id')
content = data.get('content', '').strip()
message_type = data.get('message_type', 'text')
attachment = data.get('attachment')
# Vérifier qu'on a soit du contenu, soit un fichier
if not conversation_id or (not content and not attachment):
await self.send(text_data=json.dumps({
'type': 'error',
'message': 'Conversation ID and content or attachment are required'
}))
return
# Vérifier que l'utilisateur peut envoyer dans cette conversation
can_send = await self.can_user_send_message(self.user_id, conversation_id)
if not can_send:
await self.send(text_data=json.dumps({
'type': 'error',
'message': 'You cannot send messages to this conversation'
}))
return
# Créer le message avec ou sans fichier
message = await self.create_message(conversation_id, self.user_id, content, message_type, attachment)
if not message:
await self.send(text_data=json.dumps({
'type': 'error',
'message': 'Failed to create message'
}))
return
# Sérialiser le message
message_data = await self.serialize_message(message)
# Auto-marquer comme lu pour les utilisateurs connectés (présents dans la conversation)
await self.auto_mark_read_for_online_users(message, conversation_id)
# Envoyer le message à tous les participants de la conversation
await self.channel_layer.group_send(
f'conversation_{conversation_id}',
{
'type': 'chat_message',
'message': message_data
}
)
async def handle_typing_start(self, data):
"""Gérer le début de frappe"""
conversation_id = data.get('conversation_id')
if conversation_id:
await self.update_typing_status(self.user_id, conversation_id, True)
# Récupérer le nom de l'utilisateur
user_name = await self.get_user_display_name(self.user_id)
await self.channel_layer.group_send(
f'conversation_{conversation_id}',
{
'type': 'typing_status',
'user_id': str(self.user_id),
'user_name': user_name,
'is_typing': True,
'conversation_id': str(conversation_id)
}
)
async def handle_typing_stop(self, data):
"""Gérer l'arrêt de frappe"""
conversation_id = data.get('conversation_id')
if conversation_id:
await self.update_typing_status(self.user_id, conversation_id, False)
# Récupérer le nom de l'utilisateur
user_name = await self.get_user_display_name(self.user_id)
await self.channel_layer.group_send(
f'conversation_{conversation_id}',
{
'type': 'typing_status',
'user_id': str(self.user_id),
'user_name': user_name,
'is_typing': False,
'conversation_id': str(conversation_id)
}
)
async def handle_mark_as_read(self, data):
"""Marquer les messages comme lus"""
conversation_id = data.get('conversation_id')
if conversation_id:
await self.mark_conversation_as_read(self.user_id, conversation_id)
await self.channel_layer.group_send(
f'conversation_{conversation_id}',
{
'type': 'messages_read',
'user_id': str(self.user_id),
'conversation_id': str(conversation_id)
}
)
async def handle_join_conversation(self, data):
"""Rejoindre une conversation"""
conversation_id = data.get('conversation_id')
if conversation_id:
await self.channel_layer.group_add(
f'conversation_{conversation_id}',
self.channel_name
)
async def handle_leave_conversation(self, data):
"""Quitter une conversation"""
conversation_id = data.get('conversation_id')
if conversation_id:
await self.channel_layer.group_discard(
f'conversation_{conversation_id}',
self.channel_name
)
async def handle_presence_update(self, data):
"""Gérer les mises à jour de présence"""
status = data.get('status', 'online')
if status in ['online', 'offline', 'away']:
await self.update_user_presence(self.user_id, status)
await self.broadcast_presence_update(self.user_id, status)
# Méthodes pour recevoir les messages des groupes
async def chat_message(self, event):
"""Envoyer un message de chat au WebSocket"""
message_data = serialize_for_websocket(event['message'])
await self.send(text_data=json.dumps({
'type': 'new_message',
'message': message_data
}))
async def typing_status(self, event):
"""Envoyer le statut de frappe"""
# Ne pas envoyer à l'expéditeur
if str(event['user_id']) != str(self.user_id):
await self.send(text_data=json.dumps({
'type': 'typing_status',
'user_id': str(event['user_id']),
'user_name': event.get('user_name', ''),
'is_typing': event['is_typing'],
'conversation_id': str(event['conversation_id'])
}))
async def messages_read(self, event):
"""Notifier que des messages ont été lus"""
if str(event['user_id']) != str(self.user_id):
await self.send(text_data=json.dumps({
'type': 'messages_read',
'user_id': str(event['user_id']),
'conversation_id': str(event['conversation_id'])
}))
async def user_presence_update(self, event):
"""Notifier d'un changement de présence"""
await self.send(text_data=json.dumps({
'type': 'presence_update',
'user_id': str(event['user_id']),
'status': event['status']
}))
async def new_conversation_notification(self, event):
"""Notifier d'une nouvelle conversation"""
conversation = serialize_for_websocket(event['conversation'])
conversation_id = conversation['id']
# Rejoindre automatiquement le groupe de la nouvelle conversation
await self.channel_layer.group_add(
f'conversation_{conversation_id}',
self.channel_name
)
# Envoyer la notification au client
await self.send(text_data=json.dumps({
'type': 'new_conversation',
'conversation': conversation
}))
# Diffuser les présences des participants de cette nouvelle conversation
try:
participants = await self.get_conversation_participants(conversation_id)
for participant in participants:
# Ne pas diffuser sa propre présence à soi-même
if participant.id != self.user_id:
presence = await self.get_user_presence(participant.id)
if presence:
await self.send(text_data=json.dumps({
'type': 'presence_update',
'user_id': str(participant.id),
'status': presence.status
}))
except Exception as e:
logger.error(f"Error sending presence updates for new conversation: {str(e)}")
async def broadcast_presence_update(self, user_id, status):
"""Diffuser un changement de statut de présence à tous les utilisateurs connectés"""
try:
# Obtenir tous les utilisateurs qui ont des conversations avec cet utilisateur
user_conversations = await self.get_user_conversations(user_id)
# Créer un set pour éviter les doublons d'utilisateurs
notified_users = set()
# Pour chaque conversation, notifier tous les participants
for conversation in user_conversations:
participants = await self.get_conversation_participants(conversation.id)
for participant in participants:
if participant.id != user_id and participant.id not in notified_users:
notified_users.add(participant.id)
# Envoyer la notification au groupe utilisateur
await self.channel_layer.group_send(
f'user_{participant.id}',
{
'type': 'user_presence_update',
'user_id': user_id,
'status': status
}
)
logger.info(f"Broadcasted presence update for user {user_id} ({status}) to {len(notified_users)} users")
except Exception as e:
logger.error(f"Error broadcasting presence update: {str(e)}")
# Méthodes d'accès aux données (database_sync_to_async)
@database_sync_to_async
def get_user(self, user_id):
try:
return Profile.objects.get(id=user_id)
except Profile.DoesNotExist:
return None
@database_sync_to_async
def get_user_display_name(self, user_id):
"""Obtenir le nom d'affichage d'un utilisateur"""
try:
user = Profile.objects.get(id=user_id)
if user.first_name and user.last_name:
return f"{user.first_name} {user.last_name}"
elif user.first_name:
return user.first_name
elif user.last_name:
return user.last_name
else:
return user.email or f"Utilisateur {user_id}"
except Profile.DoesNotExist:
return f"Utilisateur {user_id}"
@database_sync_to_async
def get_user_conversations(self, user_id):
return list(Conversation.objects.filter(
participants__participant_id=user_id,
participants__is_active=True,
is_active=True
).distinct())
@database_sync_to_async
def get_conversation_participants(self, conversation_id):
"""Obtenir tous les participants d'une conversation"""
return list(Profile.objects.filter(
conversation_participants__conversation_id=conversation_id,
conversation_participants__is_active=True
))
@database_sync_to_async
def get_conversations_data(self, user_id):
try:
user = Profile.objects.get(id=user_id)
conversations = Conversation.objects.filter(
participants__participant=user,
participants__is_active=True,
is_active=True
).distinct()
serializer = ConversationSerializer(conversations, many=True, context={'user': user})
return serializer.data
except Exception as e:
logger.error(f"Error getting conversations data: {str(e)}")
return []
@database_sync_to_async
def can_user_send_message(self, user_id, conversation_id):
return ConversationParticipant.objects.filter(
conversation_id=conversation_id,
participant_id=user_id,
is_active=True
).exists()
@database_sync_to_async
def create_message(self, conversation_id, sender_id, content, message_type, attachment=None):
try:
conversation = Conversation.objects.get(id=conversation_id)
sender = Profile.objects.get(id=sender_id)
message_data = {
'conversation': conversation,
'sender': sender,
'content': content,
'message_type': message_type
}
# Ajouter les informations du fichier si présent
if attachment:
message_data.update({
'file_url': attachment.get('fileUrl'),
'file_name': attachment.get('fileName'),
'file_size': attachment.get('fileSize'),
'file_type': attachment.get('fileType'),
})
# Si c'est un fichier, s'assurer que le type de message est correct
if attachment.get('fileType', '').startswith('image/'):
message_data['message_type'] = 'image'
else:
message_data['message_type'] = 'file'
message = Message.objects.create(**message_data)
# Mettre à jour l'activité de la conversation
conversation.last_activity = message.created_at
conversation.save(update_fields=['last_activity'])
return message
except Exception as e:
logger.error(f"Error creating message: {str(e)}")
return None
@database_sync_to_async
def serialize_message(self, message):
serializer = MessageSerializer(message)
return serialize_for_websocket(serializer.data)
@database_sync_to_async
def get_user_presence(self, user_id):
"""Récupérer la présence d'un utilisateur"""
try:
return UserPresence.objects.get(user_id=user_id)
except UserPresence.DoesNotExist:
return None
@database_sync_to_async
def update_user_presence(self, user_id, status):
try:
user = Profile.objects.get(id=user_id)
presence, created = UserPresence.objects.get_or_create(user=user)
old_status = presence.status
presence.status = status
presence.save()
# Si le statut a changé, notifier les autres utilisateurs
if old_status != status or created:
logger.info(f"User {user_id} presence changed from {old_status} to {status}")
return presence
except Exception as e:
logger.error(f"Error updating user presence: {str(e)}")
return None
@database_sync_to_async
def update_typing_status(self, user_id, conversation_id, is_typing):
try:
user = Profile.objects.get(id=user_id)
presence, created = UserPresence.objects.get_or_create(user=user)
if is_typing:
conversation = Conversation.objects.get(id=conversation_id)
presence.is_typing_in = conversation
else:
presence.is_typing_in = None
presence.save()
except Exception as e:
logger.error(f"Error updating typing status: {str(e)}")
@database_sync_to_async
def mark_conversation_as_read(self, user_id, conversation_id):
"""Marquer tous les messages non lus d'une conversation comme lus"""
try:
# Mettre à jour le last_read_at du participant
participant = ConversationParticipant.objects.get(
conversation_id=conversation_id,
participant_id=user_id
)
current_time = timezone.now()
participant.last_read_at = current_time
participant.save(update_fields=['last_read_at'])
# Créer des enregistrements MessageRead pour tous les messages non lus
# que l'utilisateur n'a pas encore explicitement lus
unread_messages = Message.objects.filter(
conversation_id=conversation_id,
created_at__lte=current_time,
is_deleted=False
).exclude(
sender_id=user_id # Exclure ses propres messages
).exclude(
read_by__participant_id=user_id # Exclure les messages déjà marqués comme lus
)
# Créer les enregistrements MessageRead en batch
message_reads = [
MessageRead(message=message, participant_id=user_id, read_at=current_time)
for message in unread_messages
]
if message_reads:
MessageRead.objects.bulk_create(message_reads, ignore_conflicts=True)
logger.info(f"Marked {len(message_reads)} messages as read for user {user_id} in conversation {conversation_id}")
except Exception as e:
logger.error(f"Error marking conversation as read: {str(e)}")
@database_sync_to_async
def auto_mark_read_for_online_users(self, message, conversation_id):
"""Auto-marquer comme lu pour les utilisateurs en ligne dans la conversation"""
try:
# Obtenir tous les participants de la conversation (synchrone)
participants = ConversationParticipant.objects.filter(
conversation_id=conversation_id,
is_active=True
).exclude(participant_id=message.sender.id)
# Obtenir l'heure de création du message
message_time = message.created_at
# Préparer les enregistrements MessageRead à créer
message_reads = []
for participant_obj in participants:
participant = participant_obj.participant
# Vérifier si l'utilisateur est en ligne (synchrone)
try:
presence = UserPresence.objects.filter(user=participant).first()
if presence and presence.status == 'online':
# Vérifier qu'il n'existe pas déjà un enregistrement MessageRead
if not MessageRead.objects.filter(message=message, participant=participant).exists():
message_reads.append(MessageRead(
message=message,
participant=participant,
read_at=message_time
))
except:
# En cas d'erreur de présence, ne pas marquer comme lu
continue
# Créer les enregistrements MessageRead en batch
if message_reads:
MessageRead.objects.bulk_create(message_reads, ignore_conflicts=True)
logger.info(f"Auto-marked {len(message_reads)} messages as read for online users in conversation {conversation_id}")
except Exception as e:
logger.error(f"Error in auto_mark_read_for_online_users: {str(e)}")

View File

@ -0,0 +1,108 @@
import jwt
import logging
from urllib.parse import parse_qs
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from channels.middleware import BaseMiddleware
from channels.db import database_sync_to_async
from Auth.models import Profile
logger = logging.getLogger(__name__)
@database_sync_to_async
def get_user(user_id):
"""Récupérer l'utilisateur de manière asynchrone"""
try:
return Profile.objects.get(id=user_id)
except Profile.DoesNotExist:
return AnonymousUser()
class JWTAuthMiddleware(BaseMiddleware):
"""Middleware pour l'authentification JWT dans les WebSockets"""
def __init__(self, inner):
super().__init__(inner)
def _check_cors_origin(self, scope):
"""Vérifier si l'origine est autorisée pour les WebSockets"""
origin = None
# Récupérer l'origine depuis les headers
for name, value in scope.get('headers', []):
if name == b'origin':
origin = value.decode('latin1')
break
if not origin:
logger.warning("Aucune origine trouvée dans les headers WebSocket")
return False
# Récupérer les origines autorisées depuis la configuration CORS
allowed_origins = getattr(settings, 'CORS_ALLOWED_ORIGINS', [])
# Si CORS_ORIGIN_ALLOW_ALL est True, autoriser toutes les origines
if getattr(settings, 'CORS_ORIGIN_ALLOW_ALL', False):
logger.info(f"Origine WebSocket autorisée (CORS_ORIGIN_ALLOW_ALL): {origin}")
return True
# Vérifier si l'origine est dans la liste des origines autorisées
if origin in allowed_origins:
logger.info(f"Origine WebSocket autorisée: {origin}")
return True
logger.warning(f"Origine WebSocket non autorisée: {origin}. Origines autorisées: {allowed_origins}")
return False
async def __call__(self, scope, receive, send):
# Vérifier les CORS pour les WebSockets
if not self._check_cors_origin(scope):
logger.error("Connexion WebSocket refusée: origine non autorisée")
# Fermer la connexion WebSocket avec un code d'erreur
await send({
'type': 'websocket.close',
'code': 1008 # Policy Violation
})
return
# Extraire le token de l'URL
query_string = parse_qs(scope['query_string'].decode())
token = query_string.get('token')
if token:
token = token[0]
try:
# Décoder le token JWT
payload = jwt.decode(
token,
settings.SIMPLE_JWT['SIGNING_KEY'],
algorithms=[settings.SIMPLE_JWT['ALGORITHM']]
)
# Vérifier que c'est un token d'accès
if payload.get('type') != 'access':
logger.warning(f"Token type invalide: {payload.get('type')}")
scope['user'] = AnonymousUser()
else:
# Récupérer l'utilisateur
user_id = payload.get('user_id')
user = await get_user(user_id)
scope['user'] = user
logger.info(f"Utilisateur authentifié via JWT: {user.email if hasattr(user, 'email') else 'Unknown'}")
except jwt.ExpiredSignatureError:
logger.warning("Token JWT expiré")
scope['user'] = AnonymousUser()
except jwt.InvalidTokenError as e:
logger.warning(f"Token JWT invalide: {str(e)}")
scope['user'] = AnonymousUser()
except Exception as e:
logger.error(f"Erreur lors de l'authentification JWT: {str(e)}")
scope['user'] = AnonymousUser()
else:
scope['user'] = AnonymousUser()
return await super().__call__(scope, receive, send)
def JWTAuthMiddlewareStack(inner):
"""Stack middleware pour l'authentification JWT"""
return JWTAuthMiddleware(inner)

View File

@ -1,7 +1,104 @@
from django.db import models
from Auth.models import Profile
from django.utils import timezone
import uuid
class Conversation(models.Model):
"""Modèle pour gérer les conversations entre utilisateurs"""
CONVERSATION_TYPES = [
('private', 'Privée'),
('group', 'Groupe'),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=255, blank=True, null=True) # Nom pour les groupes
conversation_type = models.CharField(max_length=10, choices=CONVERSATION_TYPES, default='private')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
last_activity = models.DateTimeField(default=timezone.now)
is_active = models.BooleanField(default=True)
def __str__(self):
if self.name:
return f'Conversation: {self.name}'
return f'Conversation {self.id}'
def get_participants(self):
return Profile.objects.filter(conversation_participants__conversation=self)
class ConversationParticipant(models.Model):
"""Modèle pour gérer les participants d'une conversation"""
conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='participants')
participant = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name='conversation_participants')
joined_at = models.DateTimeField(auto_now_add=True)
last_read_at = models.DateTimeField(default=timezone.now)
is_active = models.BooleanField(default=True)
class Meta:
unique_together = ('conversation', 'participant')
def __str__(self):
return f'{self.participant.email} in {self.conversation.id}'
class Message(models.Model):
"""Modèle pour les messages instantanés"""
MESSAGE_TYPES = [
('text', 'Texte'),
('file', 'Fichier'),
('image', 'Image'),
('system', 'Système'),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='messages')
sender = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name='sent_messages')
content = models.TextField()
message_type = models.CharField(max_length=10, choices=MESSAGE_TYPES, default='text')
file_url = models.URLField(blank=True, null=True) # Pour les fichiers/images
file_name = models.CharField(max_length=255, blank=True, null=True) # Nom original du fichier
file_size = models.BigIntegerField(blank=True, null=True) # Taille en bytes
file_type = models.CharField(max_length=100, blank=True, null=True) # MIME type
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
is_edited = models.BooleanField(default=False)
is_deleted = models.BooleanField(default=False)
class Meta:
ordering = ['created_at']
def __str__(self):
return f'Message from {self.sender.email} at {self.created_at}'
class MessageRead(models.Model):
"""Modèle pour tracker les messages lus par chaque participant"""
message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='read_by')
participant = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name='read_messages')
read_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('message', 'participant')
def __str__(self):
return f'{self.participant.email} read {self.message.id}'
class UserPresence(models.Model):
"""Modèle pour gérer la présence des utilisateurs"""
PRESENCE_STATUS = [
('online', 'En ligne'),
('away', 'Absent'),
('busy', 'Occupé'),
('offline', 'Hors ligne'),
]
user = models.OneToOneField(Profile, on_delete=models.CASCADE, related_name='presence')
status = models.CharField(max_length=10, choices=PRESENCE_STATUS, default='offline')
last_seen = models.DateTimeField(default=timezone.now)
is_typing_in = models.ForeignKey(Conversation, on_delete=models.SET_NULL, null=True, blank=True, related_name='typing_users')
def __str__(self):
return f'{self.user.email} - {self.status}'
# Ancien modèle conservé pour compatibilité
class Messagerie(models.Model):
id = models.AutoField(primary_key=True)
objet = models.CharField(max_length=200, default="", blank=True)

View File

@ -0,0 +1,7 @@
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r'ws/chat/(?P<user_id>\w+)/$', consumers.ChatConsumer.as_asgi()),
re_path(r'ws/chat/conversation/(?P<conversation_id>[\w-]+)/$', consumers.ChatConsumer.as_asgi()),
]

View File

@ -1,15 +1,266 @@
from rest_framework import serializers
from Auth.models import Profile
from GestionMessagerie.models import Messagerie
from GestionMessagerie.models import Messagerie, Conversation, ConversationParticipant, Message, MessageRead, UserPresence
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
class ProfileSimpleSerializer(serializers.ModelSerializer):
"""Sérialiseur simple pour les profils utilisateur"""
class Meta:
model = Profile
fields = ['id', 'first_name', 'last_name', 'email']
class UserPresenceSerializer(serializers.ModelSerializer):
"""Sérialiseur pour la présence utilisateur"""
user = ProfileSimpleSerializer(read_only=True)
class Meta:
model = UserPresence
fields = ['user', 'status', 'last_seen', 'is_typing_in']
class MessageReadSerializer(serializers.ModelSerializer):
"""Sérialiseur pour les messages lus"""
participant = ProfileSimpleSerializer(read_only=True)
class Meta:
model = MessageRead
fields = ['participant', 'read_at']
class MessageSerializer(serializers.ModelSerializer):
"""Sérialiseur pour les messages instantanés"""
sender = ProfileSimpleSerializer(read_only=True)
read_by = MessageReadSerializer(many=True, read_only=True)
attachment = serializers.SerializerMethodField()
is_read = serializers.SerializerMethodField()
class Meta:
model = Message
fields = ['id', 'conversation', 'sender', 'content', 'message_type', 'file_url',
'file_name', 'file_size', 'file_type', 'attachment',
'created_at', 'updated_at', 'is_edited', 'is_deleted', 'read_by', 'is_read']
read_only_fields = ['id', 'created_at', 'updated_at']
def get_attachment(self, obj):
"""Retourne les informations du fichier attaché sous forme d'objet"""
if obj.file_url:
return {
'fileName': obj.file_name,
'fileSize': obj.file_size,
'fileType': obj.file_type,
'fileUrl': obj.file_url,
}
return None
def get_is_read(self, obj):
"""Détermine si le message est lu par l'utilisateur actuel"""
user = self.context.get('user')
if not user or not user.is_authenticated:
return False
# Si c'est le message de l'utilisateur lui-même, vérifier si quelqu'un d'autre l'a lu
if obj.sender == user:
# Pour les messages envoyés par l'utilisateur, vérifier si au moins un autre participant l'a explicitement lu
# Utiliser le modèle MessageRead pour une vérification précise
from .models import MessageRead
other_participants = obj.conversation.participants.exclude(participant=user).filter(is_active=True)
for participant in other_participants:
# Vérifier si ce participant a explicitement lu ce message
if MessageRead.objects.filter(message=obj, participant=participant.participant).exists():
return True
# Fallback: vérifier last_read_at seulement si l'utilisateur était en ligne récemment
# ou si last_read_at est postérieur à created_at (lecture explicite après réception)
if (participant.last_read_at and
participant.last_read_at > obj.created_at):
# Vérifier la présence de l'utilisateur pour s'assurer qu'il était en ligne
try:
from .models import UserPresence
user_presence = UserPresence.objects.filter(user=participant.participant).first()
# Si l'utilisateur était en ligne récemment (dans les 5 minutes suivant le message)
# ou si last_read_at est bien après created_at (lecture délibérée)
time_diff = participant.last_read_at - obj.created_at
if (user_presence and user_presence.last_seen and
user_presence.last_seen >= obj.created_at) or time_diff.total_seconds() > 10:
return True
except:
# En cas d'erreur, continuer avec la logique conservative
pass
return False
else:
# Pour les messages reçus, vérifier si l'utilisateur actuel l'a lu
# D'abord vérifier dans MessageRead pour une lecture explicite
from .models import MessageRead
if MessageRead.objects.filter(message=obj, participant=user).exists():
return True
# Fallback: vérifier last_read_at du participant
participant = obj.conversation.participants.filter(
participant=user,
is_active=True
).first()
if participant and participant.last_read_at:
# Seulement considérer comme lu si last_read_at est postérieur à created_at
return participant.last_read_at > obj.created_at
return False
class ConversationParticipantSerializer(serializers.ModelSerializer):
"""Sérialiseur pour les participants d'une conversation"""
participant = ProfileSimpleSerializer(read_only=True)
class Meta:
model = ConversationParticipant
fields = ['participant', 'joined_at', 'last_read_at', 'is_active']
class ConversationSerializer(serializers.ModelSerializer):
"""Sérialiseur pour les conversations"""
participants = ConversationParticipantSerializer(many=True, read_only=True)
last_message = serializers.SerializerMethodField()
unread_count = serializers.SerializerMethodField()
interlocuteur = serializers.SerializerMethodField()
class Meta:
model = Conversation
fields = ['id', 'name', 'conversation_type', 'created_at', 'updated_at',
'last_activity', 'is_active', 'participants', 'last_message', 'unread_count', 'interlocuteur']
read_only_fields = ['id', 'created_at', 'updated_at']
def get_last_message(self, obj):
last_message = obj.messages.filter(is_deleted=False).last()
if last_message:
return MessageSerializer(last_message).data
return None
def get_unread_count(self, obj):
user = self.context.get('user')
if not user or not user.is_authenticated:
return 0
participant = obj.participants.filter(participant=user).first()
if not participant:
return 0
# Nouvelle logique : compter les messages qui ne sont PAS dans MessageRead
# et qui ont été créés après last_read_at (ou tous si last_read_at est None)
# Base query : messages de la conversation, excluant les propres messages et les supprimés
# ET ne comptant que les messages textuels
base_query = obj.messages.filter(
is_deleted=False,
message_type='text' # Ne compter que les messages textuels
).exclude(sender=user)
# Si l'utilisateur n'a pas de last_read_at, tous les messages sont non lus
if not participant.last_read_at:
unread_from_timestamp = base_query
else:
# Messages créés après le dernier moment de lecture
unread_from_timestamp = base_query.filter(
created_at__gt=participant.last_read_at
)
# Soustraire les messages explicitement marqués comme lus dans MessageRead
from .models import MessageRead
read_message_ids = MessageRead.objects.filter(
participant=user,
message__conversation=obj
).values_list('message_id', flat=True)
# Compter les messages non lus = messages après last_read_at MOINS ceux explicitement lus
unread_count = unread_from_timestamp.exclude(
id__in=read_message_ids
).count()
return unread_count
def get_interlocuteur(self, obj):
"""Pour les conversations privées, retourne l'autre participant"""
user = self.context.get('user')
if not user or not user.is_authenticated or obj.conversation_type != 'private':
return None
# Trouver l'autre participant (pas l'utilisateur actuel)
other_participant = obj.participants.filter(is_active=True).exclude(participant=user).first()
if other_participant:
return ProfileSimpleSerializer(other_participant.participant).data
return None
class ConversationCreateSerializer(serializers.ModelSerializer):
"""Sérialiseur pour créer une conversation"""
participant_ids = serializers.ListField(
child=serializers.IntegerField(),
write_only=True
)
class Meta:
model = Conversation
fields = ['name', 'conversation_type', 'participant_ids']
def create(self, validated_data):
participant_ids = validated_data.pop('participant_ids')
conversation_type = validated_data.get('conversation_type', 'private')
# Pour les conversations privées, ne pas utiliser de nom spécifique
# Le nom sera géré côté frontend en affichant le nom de l'interlocuteur
if conversation_type == 'private':
validated_data['name'] = None
conversation = super().create(validated_data)
# Ajouter les participants
participants = []
for participant_id in participant_ids:
try:
participant = Profile.objects.get(id=participant_id)
ConversationParticipant.objects.create(
conversation=conversation,
participant=participant
)
participants.append(participant)
except Profile.DoesNotExist:
continue
# Notifier les participants via WebSocket de la nouvelle conversation
try:
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
channel_layer = get_channel_layer()
if channel_layer:
# Envoyer à chaque participant avec le bon contexte
for participant in participants:
# Sérialiser la conversation avec le contexte de ce participant
conversation_data = ConversationSerializer(conversation, context={'user': participant}).data
async_to_sync(channel_layer.group_send)(
f'user_{participant.id}',
{
'type': 'new_conversation_notification',
'conversation': conversation_data
}
)
except Exception as e:
# Log l'erreur mais ne pas interrompre la création de la conversation
import logging
logger = logging.getLogger(__name__)
logger.error(f"Erreur lors de la notification WebSocket de nouvelle conversation: {str(e)}")
return conversation
# Ancien sérialiseur conservé pour compatibilité
class MessageLegacySerializer(serializers.ModelSerializer):
destinataire_profil = serializers.SerializerMethodField()
emetteur_profil = serializers.SerializerMethodField()
class Meta:
model = Messagerie
fields = '__all__'
read_only_fields = ['date_envoi']
def get_destinataire_profil(self, obj):
return obj.destinataire.email

View File

@ -1,15 +1,22 @@
from django.urls import path, re_path
from .views import SendEmailView, search_recipients, ConversationListView, ConversationMessagesView, MarkAsReadView
from GestionMessagerie.views import MessagerieView, MessageView, MessageSimpleView
from django.urls import path
from .views import (
InstantConversationListView, InstantConversationCreateView, InstantConversationDeleteView,
InstantMessageListView, InstantMessageCreateView,
InstantMarkAsReadView, FileUploadView,
InstantRecipientSearchView
)
urlpatterns = [
re_path(r'^messagerie/(?P<profile_id>[0-9]+)$', MessagerieView.as_view(), name="messagerie"),
re_path(r'^messages$', MessageView.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('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'),
# URLs pour messagerie instantanée
path('conversations/', InstantConversationListView.as_view(), name='conversations'),
path('create-conversation/', InstantConversationCreateView.as_view(), name='create_conversation'),
path('send-message/', InstantMessageCreateView.as_view(), name='send_message'),
path('conversations/mark-as-read/', InstantMarkAsReadView.as_view(), name='mark_as_read'),
path('search-recipients/', InstantRecipientSearchView.as_view(), name='search_recipients'),
path('upload-file/', FileUploadView.as_view(), name='upload_file'),
# URLs avec paramètres - doivent être après les URLs statiques
path('conversations/user/<int:user_id>/', InstantConversationListView.as_view(), name='conversations_by_user'),
path('conversations/<uuid:conversation_id>/', InstantConversationDeleteView.as_view(), name='delete_conversation'),
path('conversations/<uuid:conversation_id>/messages/', InstantMessageListView.as_view(), name='conversation_messages'),
]

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)

View File

@ -8,9 +8,40 @@ https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
from django.urls import re_path
from django.conf import settings
from django.contrib.staticfiles.handlers import ASGIStaticFilesHandler
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'N3wtSchool.settings')
application = get_asgi_application()
# Initialize Django ASGI application early to ensure the AppRegistry
# is populated before importing code that may import ORM models.
django_asgi_app = get_asgi_application()
# Import consumers after Django is initialized
from GestionMessagerie.consumers import ChatConsumer
from GestionMessagerie.middleware import JWTAuthMiddlewareStack
# WebSocket URL patterns
websocket_urlpatterns = [
re_path(r'ws/chat/(?P<user_id>\w+)/$', ChatConsumer.as_asgi()),
]
# Créer l'application ASGI avec gestion des fichiers statiques
if settings.DEBUG:
# En mode DEBUG, utiliser ASGIStaticFilesHandler pour servir les fichiers statiques
http_application = ASGIStaticFilesHandler(django_asgi_app)
else:
http_application = django_asgi_app
application = ProtocolTypeRouter({
"http": http_application,
"websocket": AllowedHostsOriginValidator(
JWTAuthMiddlewareStack(
URLRouter(websocket_urlpatterns)
)
),
})

View File

@ -8,6 +8,10 @@ from rest_framework import status
from rest_framework.exceptions import NotFound
from Settings.models import SMTPSettings
from Establishment.models import Establishment # Importer le modèle Establishment
import logging
# Ajouter un logger pour debug
logger = logging.getLogger(__name__)
def getConnection(id_establishement):
try:
@ -53,6 +57,8 @@ def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connec
from_email = settings.EMAIL_HOST_USER
logger.info(f"From email: {from_email}")
email = EmailMultiAlternatives(
subject=subject,
body=plain_message,
@ -67,10 +73,15 @@ def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connec
for attachment in attachments:
email.attach(*attachment)
logger.info("Tentative d'envoi de l'email...")
email.send(fail_silently=False)
logger.info("Email envoyé avec succès !")
return Response({'message': 'Email envoyé avec succès.'}, status=status.HTTP_200_OK)
except Exception as e:
print(f"[DEBUG] Erreur lors de l'envoi de l'email : {e}")
logger.error(f"Erreur lors de l'envoi de l'email: {str(e)}")
logger.error(f"Type d'erreur: {type(e)}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def envoieReinitMotDePasse(recipients, code):

View File

@ -46,6 +46,7 @@ INSTALLED_APPS = [
'Subscriptions.apps.GestioninscriptionsConfig',
'Auth.apps.GestionloginConfig',
'GestionMessagerie.apps.GestionMessagerieConfig',
'GestionEmail.apps.GestionEmailConfig',
'GestionNotification.apps.GestionNotificationConfig',
'School.apps.SchoolConfig',
'Planning.apps.PlanningConfig',
@ -62,14 +63,15 @@ INSTALLED_APPS = [
'django_celery_beat',
'N3wtSchool',
'drf_yasg',
'rest_framework_simplejwt'
'rest_framework_simplejwt',
'channels',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware', # Déplacez ici, avant CorsMiddleware
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
@ -161,6 +163,11 @@ LOGGING = {
"level": os.getenv("GESTION_MESSAGERIE_LOG_LEVEL", "INFO"),
"propagate": False,
},
"GestionEmail": {
"handlers": ["console"],
"level": os.getenv("GESTION_EMAIL_LOG_LEVEL", "INFO"),
"propagate": False,
},
"School": {
"handlers": ["console"],
"level": os.getenv("GESTION_ENSEIGNANTS_LOG_LEVEL", "INFO"),
@ -250,18 +257,35 @@ else:
DOCUMENT_DIR = 'documents'
CORS_ORIGIN_ALLOW_ALL = True
# Configuration CORS temporaire pour debug
CORS_ALLOW_ALL_HEADERS = True
CORS_ALLOW_CREDENTIALS = True
# Configuration CORS spécifique pour la production
CORS_ALLOWED_ORIGINS = os.getenv('CORS_ALLOWED_ORIGINS', 'http://localhost:3000,http://localhost:8080,http://127.0.0.1:3000,http://127.0.0.1:8080').split(',')
CORS_ALLOW_HEADERS = [
'content-type',
'accept',
'accept-encoding',
'authorization',
'content-type',
'dnt',
'origin',
'user-agent',
'x-csrftoken',
'x-requested-with',
'X-Auth-Token',
'x-csrftoken'
]
CORS_ALLOWED_ORIGINS = [
os.getenv('CORS_ALLOWED_ORIGINS', 'http://localhost:3000')
# Méthodes HTTP autorisées
CORS_ALLOWED_METHODS = [
'DELETE',
'GET',
'OPTIONS',
'PATCH',
'POST',
'PUT',
]
CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost:3000,http://localhost:8080').split(',')
@ -303,6 +327,7 @@ REST_FRAMEWORK = {
'PAGE_SIZE': NB_RESULT_SUBSCRIPTIONS_PER_PAGE,
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
'rest_framework.authentication.SessionAuthentication',
),
}
@ -344,4 +369,16 @@ DOCUSEAL_JWT = {
'SIGNING_KEY': SECRET_KEY,
'EXPIRATION_DELTA': timedelta(hours=1),
'API_KEY': DOCUSEAL_API_KEY
}
# Django Channels Configuration
ASGI_APPLICATION = 'N3wtSchool.asgi.application'
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
"hosts": [('redis', 6379)],
},
},
}

View File

@ -43,6 +43,7 @@ urlpatterns = [
path("Subscriptions/", include(("Subscriptions.urls", 'Subscriptions'), namespace='Subscriptions')),
path("Auth/", include(("Auth.urls", 'Auth'), namespace='Auth')),
path("GestionMessagerie/", include(("GestionMessagerie.urls", 'GestionMessagerie'), namespace='GestionMessagerie')),
path("GestionEmail/", include(("GestionEmail.urls", 'GestionEmail'), namespace='GestionEmail')),
path("GestionNotification/", include(("GestionNotification.urls", 'GestionNotification'), namespace='GestionNotification')),
path("School/", include(("School.urls", 'School'), namespace='School')),
path("DocuSeal/", include(("DocuSeal.urls", 'DocuSeal'), namespace='DocuSeal')),

View File

@ -305,7 +305,7 @@ class RegistrationSchoolFileMaster(models.Model):
class RegistrationParentFileMaster(models.Model):
groups = models.ManyToManyField(RegistrationFileGroup, related_name='parent_file_masters', blank=True)
name = models.CharField(max_length=255, default="")
description = models.CharField(blank=True, null=True)
description = models.CharField(max_length=500, blank=True, null=True)
is_required = models.BooleanField(default=False)
############################################################
@ -355,7 +355,7 @@ class StudentCompetency(models.Model):
class Meta:
unique_together = ('student', 'establishment_competency', 'period')
indexes = [
models.Index(fields=['student', 'establishment_competency', 'period']),
]

View File

@ -8,6 +8,7 @@ APPS = [
"Planning",
"GestionNotification",
"GestionMessagerie",
"GestionEmail",
"Auth",
"School",
"Common"

Binary file not shown.

View File

@ -67,3 +67,6 @@ vine==5.1.0
wcwidth==0.2.13
webencodings==0.5.1
xhtml2pdf==0.2.16
channels==4.0.0
channels-redis==4.1.0
daphne==4.1.0

View File

@ -14,13 +14,14 @@ test_mode = os.getenv('TEST_MODE', 'False') == 'True'
commands = [
["python", "manage.py", "collectstatic", "--noinput"],
["python", "manage.py", "flush", "--noinput"],
#["python", "manage.py", "flush", "--noinput"],
["python", "manage.py", "makemigrations", "Common", "--noinput"],
["python", "manage.py", "makemigrations", "Establishment", "--noinput"],
["python", "manage.py", "makemigrations", "Settings", "--noinput"],
["python", "manage.py", "makemigrations", "Subscriptions", "--noinput"],
["python", "manage.py", "makemigrations", "Planning", "--noinput"],
["python", "manage.py", "makemigrations", "GestionNotification", "--noinput"],
["python", "manage.py", "makemigrations", "GestionEmail", "--noinput"],
["python", "manage.py", "makemigrations", "GestionMessagerie", "--noinput"],
["python", "manage.py", "makemigrations", "Auth", "--noinput"],
["python", "manage.py", "makemigrations", "School", "--noinput"],
@ -35,14 +36,15 @@ for command in commands:
if run_command(command) != 0:
exit(1)
if test_mode:
for test_command in test_commands:
if run_command(test_command) != 0:
exit(1)
#if test_mode:
# for test_command in test_commands:
# if run_command(test_command) != 0:
# exit(1)
# Lancer les processus en parallèle
processes = [
subprocess.Popen(["python", "manage.py", "runserver", "0.0.0.0:8080"]),
subprocess.Popen(["daphne", "-b", "0.0.0.0", "-p", "8080", "N3wtSchool.asgi:application"]),
subprocess.Popen(["celery", "-A", "N3wtSchool", "worker", "--loglevel=info"]),
subprocess.Popen(["celery", "-A", "N3wtSchool", "beat", "--loglevel=info", "--scheduler", "django_celery_beat.schedulers:DatabaseScheduler"])
]