diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..52c4fa7cddf084ab7e970d6440341fadead9d46f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# --- Étape 1 : Build du Frontend --- +FROM node:20-slim AS frontend-builder +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm install +COPY frontend/ ./ +# On s'assure que l'API pointe vers le même domaine en prod +ENV VITE_API_URL=/api +RUN npm run build + +# --- Étape 2 : Image finale --- +FROM python:3.12-slim + +# Installation des dépendances système +RUN apt-get update && apt-get install -y \ + nginx \ + redis-server \ + gcc \ + libpq-dev \ + curl \ + supervisor \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Installation des dépendances Python +COPY backend/requirements.txt ./backend/ +RUN pip install --upgrade pip && pip install -r backend/requirements.txt +RUN pip install gunicorn daphne + +# Copie du code Backend +COPY backend/ ./backend/ + +# Copie du build Frontend depuis l'étape 1 +COPY --from=frontend-builder /app/frontend/dist ./frontend_dist + +# Copie des configurations +COPY nginx.conf /etc/nginx/sites-available/default +COPY supervisord.conf ./supervisord.conf +COPY start.sh ./start.sh +RUN chmod +x ./start.sh + +# Configuration Django pour Hugging Face +ENV PYTHONUNBUFFERED=1 +ENV DJANGO_SETTINGS_MODULE=educonnect.settings +# On force SQLite pour Hugging Face si aucune DB n'est fournie +ENV DB_HOST="" + +# Hugging Face écoute sur le port 7860 +EXPOSE 7860 + +CMD ["./start.sh"] diff --git a/backend/Dockerfile b/backend/Dockerfile index 84e37100db51c52418ad5ef4e529abc6242177df..30503b634bb6209b50833c1b1bdc5559b2f41bc9 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11-slim +FROM python:3.12-slim ENV PYTHONUNBUFFERED=1 ENV PYTHONDONTWRITEBYTECODE=1 @@ -6,9 +6,10 @@ ENV PYTHONDONTWRITEBYTECODE=1 WORKDIR /app # Installer les dépendances système -RUN apt-get update && apt-get install -y \\ - postgresql-client \\ - gcc \\ +RUN apt-get update && apt-get install -y \ + postgresql-client \ + gcc \ + libpq-dev \ && rm -rf /var/lib/apt/lists/* # Copier requirements @@ -27,4 +28,4 @@ RUN python manage.py collectstatic --noinput EXPOSE 8000 -CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "educonnect_api.wsgi:application"] +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "educonnect.wsgi:application"] diff --git a/backend/apps/ai_tools/views.py b/backend/apps/ai_tools/views.py index bc5f8aac928a7a9c7f51ffd0bd2e0c84f17aac99..ff36142f068976a313b60baa67b9cc6e60166b4f 100644 --- a/backend/apps/ai_tools/views.py +++ b/backend/apps/ai_tools/views.py @@ -162,7 +162,7 @@ IMPORTANT - Formatage des formules : """ response = client.models.generate_content( - model="gemini-2.5-flash", contents=prompt + model="gemini-flash-latest", contents=prompt ) # Estimation des tokens (approximatif) diff --git a/backend/apps/bookings/apps.py b/backend/apps/bookings/apps.py index add9c998873ca27442ee635d702c429899d17dbb..9fa6a6a7e2c394a231f92ca015a2577aea4c2f53 100644 --- a/backend/apps/bookings/apps.py +++ b/backend/apps/bookings/apps.py @@ -4,3 +4,6 @@ from django.apps import AppConfig class BookingsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'apps.bookings' + + def ready(self): + import apps.bookings.signals # noqa diff --git a/backend/apps/bookings/signals.py b/backend/apps/bookings/signals.py new file mode 100644 index 0000000000000000000000000000000000000000..5cd43d53f029eb2ff3f6830001c880de5bc912d1 --- /dev/null +++ b/backend/apps/bookings/signals.py @@ -0,0 +1,58 @@ +# ============================================ +# apps/bookings/signals.py - Signaux pour les réservations +# ============================================ +from django.db.models.signals import post_save +from django.dispatch import receiver +import datetime +import logging + +logger = logging.getLogger(__name__) + + +@receiver(post_save, sender='bookings.Booking') +def schedule_tasks_on_confirm(sender, instance, created, **kwargs): + """ + Quand un booking passe en CONFIRMED: + 1. Planifie le déblocage des messages + 2. Planifie les rappels de rendez-vous + """ + from django.utils import timezone + + # Ne traiter que les bookings confirmés + if instance.status != 'CONFIRMED': + return + + # Calculer l'heure de début + booking_start = datetime.datetime.combine(instance.date, instance.time) + booking_start = timezone.make_aware(booking_start) + now = timezone.now() + + # === 1. Déblocage des messages === + if booking_start <= now: + try: + from apps.messaging.tasks import unlock_messages_for_booking + # Exécuter immédiatement + unlock_messages_for_booking.delay(instance.id) + logger.info(f"Déblocage immédiat des messages pour booking {instance.id}") + except Exception as e: + logger.error(f"Erreur lors du déblocage immédiat: {e}") + else: + try: + from apps.messaging.tasks import unlock_messages_for_booking + # Planifier l'exécution à l'heure du booking + unlock_messages_for_booking.apply_async( + args=[instance.id], + eta=booking_start + ) + logger.info(f"Déblocage planifié pour booking {instance.id} à {booking_start}") + except Exception as e: + logger.error(f"Erreur lors de la planification du déblocage: {e}") + + # === 2. Planifier les rappels de rendez-vous === + try: + from apps.bookings.tasks import schedule_all_reminders + # Planifier tous les rappels de manière asynchrone + schedule_all_reminders.delay(instance.id) + logger.info(f"Planification des rappels pour booking {instance.id}") + except Exception as e: + logger.error(f"Erreur lors de la planification des rappels: {e}") diff --git a/backend/apps/bookings/tasks.py b/backend/apps/bookings/tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..27229fffb5a0880f73b01643b7e47723a75f5a81 --- /dev/null +++ b/backend/apps/bookings/tasks.py @@ -0,0 +1,206 @@ +# ============================================ +# apps/bookings/tasks.py - Tâches Celery pour les réservations +# ============================================ +import datetime +from celery import shared_task +from django.utils import timezone +import logging + +logger = logging.getLogger(__name__) + +# Intervalles de rappel avant le rendez-vous (en minutes) +REMINDER_INTERVALS = [ + (1440, "24 heures"), # 24h avant + (60, "1 heure"), # 1h avant + (30, "30 minutes"), # 30min avant + (15, "15 minutes"), # 15min avant + (5, "5 minutes"), # 5min avant +] + + +@shared_task(name='bookings.send_booking_reminder') +def send_booking_reminder(booking_id, time_label): + """ + Envoie une notification de rappel aux participants d'un booking. + + Args: + booking_id: ID du booking + time_label: Texte décrivant le temps restant (ex: "30 minutes") + """ + from apps.bookings.models import Booking + from apps.notifications.models import Notification, NotificationTitle, NotificationMessage + from apps.notifications.services import NotificationService + + try: + booking = Booking.objects.get(id=booking_id) + + # Vérifier que le booking est toujours confirmé + if booking.status != 'CONFIRMED': + logger.info(f"Booking {booking_id} n'est plus confirmé, skip reminder.") + return + + # Récupérer les noms des participants + student_profile = booking.student.profiles.filter(is_current=True).first() + student_name = student_profile.name if student_profile else booking.student.email + + mentor_profile = booking.mentor.profiles.filter(is_current=True).first() + mentor_name = mentor_profile.name if mentor_profile else booking.mentor.email + + # Notification pour l'étudiant + notif_student = Notification.objects.create( + user=booking.student, + type='BOOKING', + link=f'/chat?partner={booking.mentor.id}' + ) + NotificationTitle.objects.create( + notification=notif_student, + title=f'⏰ Rappel : Rendez-vous dans {time_label}' + ) + NotificationMessage.objects.create( + notification=notif_student, + message=f'Votre session avec {mentor_name} commence dans {time_label}. Préparez-vous !' + ) + NotificationService._send_ws_notification(notif_student) + + # Notification pour le mentor + notif_mentor = Notification.objects.create( + user=booking.mentor, + type='BOOKING', + link=f'/chat?partner={booking.student.id}' + ) + NotificationTitle.objects.create( + notification=notif_mentor, + title=f'⏰ Rappel : Rendez-vous dans {time_label}' + ) + NotificationMessage.objects.create( + notification=notif_mentor, + message=f'Votre session avec {student_name} commence dans {time_label}. L\'étudiant vous attend !' + ) + NotificationService._send_ws_notification(notif_mentor) + + logger.info(f"Rappel envoyé pour booking {booking_id} ({time_label})") + return True + + except Booking.DoesNotExist: + logger.error(f"Booking {booking_id} introuvable") + return False + except Exception as e: + logger.error(f"Erreur lors de l'envoi du rappel: {e}") + raise + + +@shared_task(name='bookings.send_booking_starting_now') +def send_booking_starting_now(booking_id): + """ + Notification spéciale quand le rendez-vous commence. + """ + from apps.bookings.models import Booking + from apps.notifications.models import Notification, NotificationTitle, NotificationMessage + from apps.notifications.services import NotificationService + + try: + booking = Booking.objects.get(id=booking_id) + + if booking.status != 'CONFIRMED': + return + + # Récupérer les noms + student_profile = booking.student.profiles.filter(is_current=True).first() + student_name = student_profile.name if student_profile else booking.student.email + + mentor_profile = booking.mentor.profiles.filter(is_current=True).first() + mentor_name = mentor_profile.name if mentor_profile else booking.mentor.email + + # Notification pour l'étudiant + notif_student = Notification.objects.create( + user=booking.student, + type='BOOKING', + link=f'/chat?partner={booking.mentor.id}' + ) + NotificationTitle.objects.create( + notification=notif_student, + title='🚀 C\'est l\'heure de votre session !' + ) + NotificationMessage.objects.create( + notification=notif_student, + message=f'Votre session de mentorat avec {mentor_name} commence maintenant. Cliquez pour rejoindre le chat.' + ) + NotificationService._send_ws_notification(notif_student) + + # Notification pour le mentor + notif_mentor = Notification.objects.create( + user=booking.mentor, + type='BOOKING', + link=f'/chat?partner={booking.student.id}' + ) + NotificationTitle.objects.create( + notification=notif_mentor, + title='🚀 C\'est l\'heure de votre session !' + ) + NotificationMessage.objects.create( + notification=notif_mentor, + message=f'Votre session avec {student_name} commence maintenant. L\'étudiant vous attend !' + ) + NotificationService._send_ws_notification(notif_mentor) + + logger.info(f"Notification de démarrage envoyée pour booking {booking_id}") + return True + + except Booking.DoesNotExist: + logger.error(f"Booking {booking_id} introuvable") + return False + except Exception as e: + logger.error(f"Erreur: {e}") + raise + + +@shared_task(name='bookings.schedule_all_reminders') +def schedule_all_reminders(booking_id): + """ + Planifie tous les rappels pour un booking. + À appeler quand un booking est confirmé. + """ + from apps.bookings.models import Booking + + try: + booking = Booking.objects.get(id=booking_id) + + if booking.status != 'CONFIRMED': + return + + # Calculer l'heure de début + booking_start = datetime.datetime.combine(booking.date, booking.time) + booking_start = timezone.make_aware(booking_start) + now = timezone.now() + + scheduled_count = 0 + + # Planifier chaque rappel + for minutes_before, label in REMINDER_INTERVALS: + reminder_time = booking_start - datetime.timedelta(minutes=minutes_before) + + # Ne planifier que les rappels dans le futur + if reminder_time > now: + send_booking_reminder.apply_async( + args=[booking_id, label], + eta=reminder_time + ) + scheduled_count += 1 + logger.info(f"Rappel planifié pour booking {booking_id}: {label} (à {reminder_time})") + + # Planifier la notification de démarrage + if booking_start > now: + send_booking_starting_now.apply_async( + args=[booking_id], + eta=booking_start + ) + logger.info(f"Notification de démarrage planifiée pour booking {booking_id}") + + return f"Planifié {scheduled_count} rappels pour booking {booking_id}" + + except Booking.DoesNotExist: + logger.error(f"Booking {booking_id} introuvable") + return None + except Exception as e: + logger.error(f"Erreur lors de la planification: {e}") + raise diff --git a/backend/apps/bookings/views.py b/backend/apps/bookings/views.py index 2376b999c06dadf557068e6e64d736ed0df91fee..c74ae70e4469ade92032355dcf9133b4fde049e1 100644 --- a/backend/apps/bookings/views.py +++ b/backend/apps/bookings/views.py @@ -89,7 +89,6 @@ class BookingViewSet(HashIdMixin, viewsets.ModelViewSet): bookings = Booking.objects.filter( mentor=request.user, - status='PENDING', is_active=True ).order_by('date', 'time') diff --git a/backend/apps/mentors/admin.py b/backend/apps/mentors/admin.py index 32db04cf8cd5199d98a09b8f3381d9bcb168d010..3884c24e8ad4ad17f2f3a92343bc8811390f545c 100644 --- a/backend/apps/mentors/admin.py +++ b/backend/apps/mentors/admin.py @@ -43,11 +43,30 @@ from .models import MentorApplication @admin.register(MentorApplication) class MentorApplicationAdmin(admin.ModelAdmin): - list_display = ('user', 'status_badge', 'university', 'created_at', 'cv_link') - list_filter = ('status', 'created_at') + list_display = ('user', 'status_badge', 'ai_status_badge', 'ai_validated', 'ai_score', 'university', 'created_at', 'cv_link', 'id_card_link') + list_filter = ('status', 'ai_status', 'ai_validated', 'created_at') search_fields = ('user__email', 'user__first_name', 'user__last_name', 'university') - readonly_fields = ('created_at', 'updated_at') + readonly_fields = ('created_at', 'updated_at', 'ai_status', 'ai_recommendation', 'ai_score', 'ai_validated') + fieldsets = ( + ('Informations Utilisateur', { + 'fields': ('user', 'university', 'bio', 'specialties', 'availability') + }), + ('Documents', { + 'fields': ('cv_file', 'id_card_photo') + }), + ('Réseaux Sociaux', { + 'fields': ('linkedin', 'twitter', 'website') + }), + ('Analyse IA (Automatique)', { + 'fields': ('ai_status', 'ai_validated', 'ai_score', 'ai_recommendation'), + 'description': 'Ces champs sont remplis automatiquement par l\'IA Gemini après la soumission.' + }), + ('Décision Admin', { + 'fields': ('status', 'created_at', 'updated_at') + }), + ) + def status_badge(self, obj): colors = { 'PENDING': 'orange', @@ -61,13 +80,33 @@ class MentorApplicationAdmin(admin.ModelAdmin): ) status_badge.short_description = 'Statut' + def ai_status_badge(self, obj): + colors = { + 'PENDING': '#6b7280', # gray-500 + 'PROCESSING': '#3b82f6', # blue-500 + 'COMPLETED': '#10b981', # emerald-500 + 'FAILED': '#ef4444', # red-500 + } + return format_html( + '{}', + colors.get(obj.ai_status, 'gray'), + obj.get_ai_status_display() + ) + ai_status_badge.short_description = 'Analyse IA' + def cv_link(self, obj): if obj.cv_file: - return format_html('Voir le CV', obj.cv_file.url) + return format_html('Voir CV', obj.cv_file.url) return "-" cv_link.short_description = 'CV' - actions = ['approve_applications', 'reject_applications'] + def id_card_link(self, obj): + if obj.id_card_photo: + return format_html('Voir ID', obj.id_card_photo.url) + return "-" + id_card_link.short_description = 'Photo ID' + + actions = ['approve_applications', 'reject_applications', 'retry_ai_review'] @admin.action(description='Approuver les candidatures sélectionnées') def approve_applications(self, request, queryset): @@ -79,3 +118,15 @@ class MentorApplicationAdmin(admin.ModelAdmin): updated = queryset.update(status='REJECTED') self.message_user(request, f"{updated} candidature(s) rejetée(s).") + @admin.action(description='Relancer l\'analyse IA') + def retry_ai_review(self, request, queryset): + from .services import MentorAIRewiewService + import threading + for instance in queryset: + thread = threading.Thread( + target=MentorAIRewiewService.review_application, + args=(instance.id,) + ) + thread.start() + self.message_user(request, f"Analyse IA relancée pour {queryset.count()} candidature(s).") + diff --git a/backend/apps/mentors/apps.py b/backend/apps/mentors/apps.py index 19bd76a3ed5deae530db8e0cd512162af9bf0d9d..cc46b9dc757695c8f3736ed4b0799a50bec6b1b3 100644 --- a/backend/apps/mentors/apps.py +++ b/backend/apps/mentors/apps.py @@ -4,3 +4,6 @@ from django.apps import AppConfig class MentorsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'apps.mentors' + + def ready(self): + import apps.mentors.signals diff --git a/backend/apps/mentors/migrations/0005_mentorapplication_ai_recommendation_and_more.py b/backend/apps/mentors/migrations/0005_mentorapplication_ai_recommendation_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..82f5524583dc2e8e665903d2c1c015330bb1f301 --- /dev/null +++ b/backend/apps/mentors/migrations/0005_mentorapplication_ai_recommendation_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.7 on 2025-12-22 12:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mentors', '0004_mentorspecificdateavailability'), + ] + + operations = [ + migrations.AddField( + model_name='mentorapplication', + name='ai_recommendation', + field=models.TextField(blank=True, verbose_name='Recommandation IA'), + ), + migrations.AddField( + model_name='mentorapplication', + name='ai_score', + field=models.IntegerField(blank=True, null=True, verbose_name='Score de confiance IA'), + ), + migrations.AddField( + model_name='mentorapplication', + name='ai_status', + field=models.CharField(choices=[('PENDING', 'En attente'), ('PROCESSING', "En cours d'analyse"), ('COMPLETED', 'Analyse terminée'), ('FAILED', "Échec de l'analyse")], default='PENDING', max_length=20), + ), + migrations.AddField( + model_name='mentorapplication', + name='ai_validated', + field=models.BooleanField(default=False, verbose_name="Validé par l'IA"), + ), + migrations.AddField( + model_name='mentorapplication', + name='id_card_photo', + field=models.ImageField(blank=True, null=True, upload_to='mentors/ids/'), + ), + ] diff --git a/backend/apps/mentors/models.py b/backend/apps/mentors/models.py index 655e828e73325ad7cfb20fba8a884823dd09c76a..e49474136a28118e6f7886ef5a0a4b850e26f21e 100644 --- a/backend/apps/mentors/models.py +++ b/backend/apps/mentors/models.py @@ -136,6 +136,21 @@ class MentorApplication(TimestampMixin, SoftDeleteMixin): twitter = models.URLField(max_length=500, blank=True) website = models.URLField(max_length=500, blank=True) + # Nouveau champ : Photo d'Identité + id_card_photo = models.ImageField(upload_to='mentors/ids/', null=True, blank=True) + + # Champs pour l'analyse IA + AI_STATUS_CHOICES = [ + ('PENDING', 'En attente'), + ('PROCESSING', 'En cours d\'analyse'), + ('COMPLETED', 'Analyse terminée'), + ('FAILED', 'Échec de l\'analyse'), + ] + ai_status = models.CharField(max_length=20, choices=AI_STATUS_CHOICES, default='PENDING') + ai_recommendation = models.TextField(blank=True, verbose_name="Recommandation IA") + ai_score = models.IntegerField(null=True, blank=True, verbose_name="Score de confiance IA") + ai_validated = models.BooleanField(default=False, verbose_name="Validé par l'IA") + class Meta: db_table = 'mentor_applications' ordering = ['-created_at'] diff --git a/backend/apps/mentors/serializers.py b/backend/apps/mentors/serializers.py index 80e67364486d509ec0bc7d66d66ad21daee88064..d8e1fe4c08e21955456e6bcc76c57be7d68dfc88 100644 --- a/backend/apps/mentors/serializers.py +++ b/backend/apps/mentors/serializers.py @@ -287,11 +287,15 @@ class MentorApplicationSerializer(serializers.ModelSerializer): class Meta: model = MentorApplication fields = [ - 'id', 'user', 'cv_file', 'status', 'bio', 'university', + 'id', 'user', 'cv_file', 'id_card_photo', 'status', 'bio', 'university', 'specialties', 'availability', 'linkedin', 'twitter', 'website', + 'ai_status', 'ai_recommendation', 'ai_score', 'ai_validated', 'created_at' ] - read_only_fields = ['id', 'user', 'status', 'created_at'] + read_only_fields = [ + 'id', 'user', 'status', 'ai_status', 'ai_recommendation', + 'ai_score', 'ai_validated', 'created_at' + ] def validate_cv_file(self, value): if not value.name.endswith('.pdf'): diff --git a/backend/apps/mentors/services.py b/backend/apps/mentors/services.py new file mode 100644 index 0000000000000000000000000000000000000000..20b95816856cbf7d85bc7085eba33d66e81bc941 --- /dev/null +++ b/backend/apps/mentors/services.py @@ -0,0 +1,122 @@ +# ============================================ +# apps/mentors/services.py - Services Mentors +# ============================================ +import os +import logging +import json +import re +from pypdf import PdfReader +from google import genai +from django.conf import settings +from .models import MentorApplication + +logger = logging.getLogger(__name__) + +class MentorAIRewiewService: + @staticmethod + def extract_text_from_pdf(pdf_path): + """Extrait le texte d'un fichier PDF""" + try: + if not os.path.exists(pdf_path): + logger.error(f"Fichier PDF non trouvé: {pdf_path}") + return "" + + reader = PdfReader(pdf_path) + text = "" + for page in reader.pages: + text += page.extract_text() + "\n" + return text + except Exception as e: + logger.error(f"Erreur lors de l'extraction du PDF: {str(e)}") + return "" + + @staticmethod + def review_application(application_id): + """Analyse une candidature avec l'IA""" + try: + application = MentorApplication.objects.get(id=application_id) + application.ai_status = 'PROCESSING' + application.save(update_fields=['ai_status']) + + # Extraction du texte du CV + cv_text = "" + if application.cv_file: + cv_text = MentorAIRewiewService.extract_text_from_pdf(application.cv_file.path) + + # Récupérer le nom de l'utilisateur + user_name = "Inconnu" + profile = application.user.profiles.filter(is_current=True).first() + if profile: + user_name = profile.name + + # Préparation du prompt + prompt = f""" + Tu es un expert en recrutement académique pour EduLab Africa. + Ta mission est d'analyser la candidature d'un utilisateur qui souhaite devenir Mentor sur notre plateforme. + + Voici les informations de la candidature : + - Nom : {user_name} + - Université : {application.university} + - Spécialités : {', '.join(application.specialties)} + - Biographie : {application.bio} + - LinkedIn : {application.linkedin} + + Texte extrait du CV (PDF) : + --- + {cv_text[:4000]} + --- + + Analyse cette candidature selon les critères suivants : + 1. Expertise technique dans les spécialités mentionnées. + 2. Expérience académique ou professionnelle pertinente. + 3. Capacité à transmettre des connaissances (pédagogie). + 4. Cohérence globale du profil. + + Réponds UNIQUEMENT au format JSON suivant (sans texte avant ou après) : + {{ + "score": (un entier entre 0 et 100), + "recommendation": "Un résumé détaillé de ton analyse en français et pourquoi tu valides ou non le profil.", + "validated": (true ou false) + }} + """ + + # Appel à Gemini via le nouveau SDK (comme dans Tutor AI) + if not settings.GEMINI_API_KEY: + raise ValueError("GEMINI_API_KEY non configurée dans les settings.") + + client = genai.Client(api_key=settings.GEMINI_API_KEY) + + response = client.models.generate_content( + model='gemini-flash-latest', + contents=prompt + ) + + # Parser la réponse + text_response = response.text + json_match = re.search(r'\{.*\}', text_response, re.DOTALL) + + if json_match: + result = json.loads(json_match.group()) + + application.ai_score = result.get('score', 0) + application.ai_recommendation = result.get('recommendation', '') + application.ai_validated = result.get('validated', False) + application.ai_status = 'COMPLETED' + else: + application.ai_status = 'FAILED' + application.ai_recommendation = "L'IA n'a pas renvoyé un format JSON valide." + + application.save() + return True + + except Exception as e: + logger.error(f"Erreur lors de l'analyse IA: {str(e)}") + try: + # Re-fetch application to avoid stale data if needed + app = MentorApplication.objects.get(id=application_id) + app.ai_status = 'FAILED' + app.ai_recommendation = f"Erreur technique: {str(e)}" + app.save() + except: + pass + return False diff --git a/backend/apps/mentors/signals.py b/backend/apps/mentors/signals.py new file mode 100644 index 0000000000000000000000000000000000000000..2ed91f7794c6fff5e5c4f2f7e376c003603719e5 --- /dev/null +++ b/backend/apps/mentors/signals.py @@ -0,0 +1,17 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from .models import MentorApplication +from .services import MentorAIRewiewService +import threading + +@receiver(post_save, sender=MentorApplication) +def trigger_ai_review(sender, instance, created, **kwargs): + """Déclenche l'analyse IA dès qu'une candidature est soumise""" + if created: + # On lance l'analyse dans un thread séparé pour ne pas bloquer la réponse HTTP + # En production, on utiliserait Celery + thread = threading.Thread( + target=MentorAIRewiewService.review_application, + args=(instance.id,) + ) + thread.start() diff --git a/backend/apps/messaging/consumers.py b/backend/apps/messaging/consumers.py index 39c1aa3de33aeed7f7ffb09601f5d2072896a5ce..f949eb4d2b524db76d43b2284a905a643d0ae51b 100644 --- a/backend/apps/messaging/consumers.py +++ b/backend/apps/messaging/consumers.py @@ -2,6 +2,7 @@ # apps/messaging/consumers.py - WebSocket Consumer # ============================================ import json +import datetime from channels.generic.websocket import AsyncWebsocketConsumer from channels.db import database_sync_to_async from apps.messaging.models import Message, MessageContent, Conversation @@ -26,6 +27,9 @@ class ChatConsumer(AsyncWebsocketConsumer): await self.close() return + # Stocker l'user_id pour filtrage ultérieur + self.user_id = user.id + # Rejoindre le groupe await self.channel_layer.group_add( self.room_group_name, @@ -36,10 +40,11 @@ class ChatConsumer(AsyncWebsocketConsumer): async def disconnect(self, close_code): # Quitter le groupe - await self.channel_layer.group_discard( - self.room_group_name, - self.channel_name - ) + if hasattr(self, 'room_group_name'): + await self.channel_layer.group_discard( + self.room_group_name, + self.channel_name + ) async def receive(self, text_data): """Recevoir un message du WebSocket""" @@ -47,31 +52,71 @@ class ChatConsumer(AsyncWebsocketConsumer): message_content = data.get('message') user = self.scope['user'] - # Sauvegarder en base de données - message = await self.save_message(user, self.conversation_id, message_content) + # Sauvegarder en base de données avec vérification du booking + message, is_visible, other_user_id = await self.save_message_with_visibility( + user, self.conversation_id, message_content + ) + + # Préparer les données du message + msg_data = { + 'id': message.id, + 'content': message_content, + 'sender': { + 'id': user.id, + 'email': user.email + }, + 'timestamp': message.created_at.isoformat(), + 'is_visible_to_recipient': is_visible + } - # Envoyer au groupe + # Envoyer au groupe avec les infos de visibilité await self.channel_layer.group_send( self.room_group_name, { 'type': 'chat_message', - 'message': { - 'id': message.id, - 'content': message_content, - 'sender': { - 'id': user.id, - 'email': user.email - }, - 'timestamp': message.created_at.isoformat() - } + 'message': msg_data, + 'sender_id': user.id, + 'is_visible': is_visible } ) + + # Si le message n'est pas visible, notifier l'expéditeur directement + if not is_visible: + await self.send(text_data=json.dumps({ + 'type': 'message_queued', + 'message': "Votre message sera délivré au destinataire lors de votre prochain rendez-vous.", + 'message_id': message.id + })) async def chat_message(self, event): - """Envoyer le message au WebSocket""" + """Envoyer le message au WebSocket - avec filtrage""" + message = event['message'] + sender_id = event.get('sender_id') + is_visible = event.get('is_visible', True) + + # Si je suis l'expéditeur, je vois toujours mon message + if sender_id == self.user_id: + await self.send(text_data=json.dumps({ + 'message': message + })) + # Si le message est visible pour le destinataire, l'envoyer + elif is_visible: + await self.send(text_data=json.dumps({ + 'message': message + })) + # Sinon, ne pas envoyer au destinataire (message en attente) + + async def messages_unlocked(self, event): + """ + Notifie le client que des messages ont été débloqués. + Le frontend doit recharger la conversation pour voir les nouveaux messages. + """ + count = event.get('count', 0) await self.send(text_data=json.dumps({ - 'message': event['message'] + 'type': 'messages_unlocked', + 'count': count })) + @database_sync_to_async def is_participant(self, user, conversation_id): @@ -86,15 +131,45 @@ class ChatConsumer(AsyncWebsocketConsumer): return False @database_sync_to_async - def save_message(self, user, conversation_id, content): - """Sauvegarder le message en base de données""" + def save_message_with_visibility(self, user, conversation_id, content): + """Sauvegarder le message avec vérification du booking actif""" from django.utils import timezone + from apps.bookings.models import Booking + from django.db.models import Q conversation = Conversation.objects.get(id=conversation_id) + # Trouver l'autre participant + other_participant = conversation.participants.exclude(user=user).filter(is_active=True).first() + other_user = other_participant.user if other_participant else None + other_user_id = other_user.id if other_user else None + + # Vérifier s'il y a un booking actif + is_visible = False + if other_user: + now = timezone.now() + + # Chercher un booking CONFIRMED en cours + active_booking = Booking.objects.filter( + Q(student=user, mentor=other_user) | Q(student=other_user, mentor=user), + status='CONFIRMED', + date=now.date(), + time__lte=now.time() + ).first() + + if active_booking: + # Vérifier si on est encore dans le créneau (2h max) + booking_start = datetime.datetime.combine(now.date(), active_booking.time) + booking_start = timezone.make_aware(booking_start) + + if now <= booking_start + datetime.timedelta(hours=2): + is_visible = True + + # Créer le message avec la visibilité appropriée message = Message.objects.create( conversation=conversation, - sender=user + sender=user, + is_visible_to_recipient=is_visible ) MessageContent.objects.create( @@ -106,4 +181,4 @@ class ChatConsumer(AsyncWebsocketConsumer): conversation.last_message_at = timezone.now() conversation.save() - return message \ No newline at end of file + return message, is_visible, other_user_id \ No newline at end of file diff --git a/backend/apps/messaging/serializers.py b/backend/apps/messaging/serializers.py index e4f68ae26b4d9fc103f4089678c92078bbce2117..8ccb0034364ae0f701e7351556b6daadf12775ad 100644 --- a/backend/apps/messaging/serializers.py +++ b/backend/apps/messaging/serializers.py @@ -50,7 +50,30 @@ class ConversationSerializer(serializers.ModelSerializer): return UserSerializer([p.user for p in parts], many=True).data def get_last_message(self, obj): - last_msg = obj.messages.filter(is_active=True).last() + """ + Retourne le dernier message VISIBLE pour l'utilisateur actuel. + - Si l'utilisateur est l'expéditeur, il voit son message + - Sinon, le message doit avoir is_visible_to_recipient=True + """ + request = self.context.get('request') + if not request or not request.user.is_authenticated: + # Sans contexte, on affiche le dernier message visible + last_msg = obj.messages.filter( + is_active=True, + is_visible_to_recipient=True + ).last() + return MessageSerializer(last_msg).data if last_msg else None + + from django.db.models import Q + + # Messages que l'utilisateur peut voir: + # 1. Messages qu'il a envoyés (sender=me) + # 2. Messages visibles (is_visible_to_recipient=True) + visible_messages = obj.messages.filter(is_active=True).filter( + Q(sender=request.user) | Q(is_visible_to_recipient=True) + ) + + last_msg = visible_messages.order_by('-created_at').first() return MessageSerializer(last_msg).data if last_msg else None def get_unread_count(self, obj): @@ -61,9 +84,21 @@ class ConversationSerializer(serializers.ModelSerializer): is_active=True ).first() if participant: - return participant.get_unread_count() + # Compter uniquement les messages non lus ET visibles + from django.db.models import Q + if not participant.last_read_at: + return obj.messages.filter( + is_active=True, + is_visible_to_recipient=True + ).exclude(sender=request.user).count() + return obj.messages.filter( + is_active=True, + is_visible_to_recipient=True, + created_at__gt=participant.last_read_at + ).exclude(sender=request.user).count() return 0 + class MessageCreateSerializer(serializers.Serializer): content = serializers.CharField(required=False, allow_blank=True) attachments = serializers.ListField( diff --git a/backend/apps/messaging/tasks.py b/backend/apps/messaging/tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..44bcb004ced80a75019ff1d4b9c33224f9410e83 --- /dev/null +++ b/backend/apps/messaging/tasks.py @@ -0,0 +1,192 @@ +# ============================================ +# apps/messaging/tasks.py - Tâches Celery pour la messagerie +# ============================================ +import datetime +from celery import shared_task +from django.utils import timezone +from django.db.models import Q +import logging + +logger = logging.getLogger(__name__) + + +@shared_task(name='messaging.unlock_messages_for_booking') +def unlock_messages_for_booking(booking_id): + """ + Débloque tous les messages en attente entre deux utilisateurs + quand leur rendez-vous commence. + + Cette tâche est planifiée pour s'exécuter à l'heure de début du booking. + """ + from apps.bookings.models import Booking + from apps.messaging.models import Conversation, Message + + try: + booking = Booking.objects.get(id=booking_id) + + # Vérifier que le booking est toujours confirmé + if booking.status != 'CONFIRMED': + logger.info(f"Booking {booking_id} n'est plus confirmé, skip.") + return + + student = booking.student + mentor = booking.mentor + + # Trouver la conversation entre ces deux users + conversation = Conversation.objects.filter( + participants__user=student, + participants__is_active=True, + is_active=True + ).filter( + participants__user=mentor, + participants__is_active=True + ).first() + + if not conversation: + logger.info(f"Pas de conversation entre {student.id} et {mentor.id}") + return + + # Débloquer tous les messages en attente dans cette conversation + updated_count = Message.objects.filter( + conversation=conversation, + is_visible_to_recipient=False, + is_active=True + ).update(is_visible_to_recipient=True) + + logger.info(f"Débloqué {updated_count} messages pour booking {booking_id}") + + # Notifier les utilisateurs via WebSocket que des messages sont disponibles + try: + from channels.layers import get_channel_layer + from asgiref.sync import async_to_sync + + channel_layer = get_channel_layer() + + async_to_sync(channel_layer.group_send)( + f'chat_{conversation.id}', + { + 'type': 'messages_unlocked', + 'count': updated_count + } + ) + except Exception as e: + logger.warning(f"Impossible de notifier via WebSocket: {e}") + + return updated_count + + except Booking.DoesNotExist: + logger.error(f"Booking {booking_id} introuvable") + return 0 + except Exception as e: + logger.error(f"Erreur lors du déblocage des messages: {e}") + raise + + +@shared_task(name='messaging.schedule_unlock_for_upcoming_bookings') +def schedule_unlock_for_upcoming_bookings(): + """ + Tâche périodique qui vérifie les bookings confirmés à venir + et planifie le déblocage des messages pour chacun. + + À exécuter toutes les 5-10 minutes via Celery Beat. + """ + from apps.bookings.models import Booking + from django_celery_beat.models import PeriodicTask, ClockedSchedule + import json + + now = timezone.now() + + # Trouver les bookings confirmés qui commencent dans les 15 prochaines minutes + # et pour lesquels on n'a pas encore planifié de tâche + upcoming_bookings = Booking.objects.filter( + status='CONFIRMED', + date=now.date(), + time__gte=now.time(), + is_active=True + ) + + for booking in upcoming_bookings: + # Calculer l'heure de début + booking_start = datetime.datetime.combine(booking.date, booking.time) + booking_start = timezone.make_aware(booking_start) + + # Si le booking commence dans moins de 15 minutes + if booking_start <= now + datetime.timedelta(minutes=15): + task_name = f'unlock_msgs_booking_{booking.id}' + + # Vérifier si la tâche existe déjà + if not PeriodicTask.objects.filter(name=task_name).exists(): + try: + # Créer un schedule pour l'heure exacte + clocked, _ = ClockedSchedule.objects.get_or_create( + clocked_time=booking_start + ) + + PeriodicTask.objects.create( + clocked=clocked, + name=task_name, + task='messaging.unlock_messages_for_booking', + args=json.dumps([booking.id]), + one_off=True, # Ne s'exécute qu'une fois + enabled=True + ) + + logger.info(f"Tâche planifiée pour booking {booking.id} à {booking_start}") + except Exception as e: + logger.error(f"Erreur lors de la planification pour booking {booking.id}: {e}") + + return f"Vérifié {upcoming_bookings.count()} bookings" + + +@shared_task(name='messaging.check_pending_messages') +def check_pending_messages(): + """ + Tâche périodique de sécurité qui vérifie s'il y a des messages + qui auraient dû être débloqués mais ne l'ont pas été. + + Utile en cas de redémarrage du serveur ou de tâche ratée. + """ + from apps.messaging.models import Message + from apps.bookings.models import Booking + + now = timezone.now() + + # Trouver les messages en attente dont l'expéditeur et le destinataire + # ont eu un booking qui a déjà commencé + pending_messages = Message.objects.filter( + is_visible_to_recipient=False, + is_active=True + ).select_related('conversation', 'sender') + + unlocked_total = 0 + + for message in pending_messages: + conversation = message.conversation + sender = message.sender + + # Trouver le destinataire + recipient_part = conversation.participants.exclude(user=sender).filter(is_active=True).first() + if not recipient_part: + continue + + recipient = recipient_part.user + + # Vérifier s'il y a un booking passé ou en cours entre eux + booking = Booking.objects.filter( + Q(student=sender, mentor=recipient) | Q(student=recipient, mentor=sender), + status='CONFIRMED', + is_active=True + ).filter( + Q(date__lt=now.date()) | + Q(date=now.date(), time__lte=now.time()) + ).first() + + if booking: + message.is_visible_to_recipient = True + message.save(update_fields=['is_visible_to_recipient']) + unlocked_total += 1 + + if unlocked_total > 0: + logger.info(f"Check périodique: débloqué {unlocked_total} messages") + + return unlocked_total diff --git a/backend/apps/messaging/views.py b/backend/apps/messaging/views.py index d4dd9430448dfde33002ca84c295d29bd2cfb6f7..ce34efbc49f1fbac4723d28cfa4cf674439e3079 100644 --- a/backend/apps/messaging/views.py +++ b/backend/apps/messaging/views.py @@ -201,27 +201,13 @@ class ConversationViewSet(HashIdMixin, viewsets.ModelViewSet): ) serializer.is_valid(raise_exception=True) - # On surcharge la méthode create du serializer pour gérer ce champ ? - # Non, MessageCreateSerializer.create fait le create. - # On va modifier MessageCreateSerializer pour accepter un argument extra ou on update après. - # Update après est moins performant (2 DB calls) mais plus simple sans toucher au serializer complexe. - message = serializer.save() if is_visible: message.is_visible_to_recipient = True message.save(update_fields=['is_visible_to_recipient']) - # Broadcast to WebSocket - # IMPORTANT: Si le message est caché, faut-il l'envoyer par WS ? - # OUI, mais le frontend du destinataire doit savoir qu'il est caché (ou ne pas l'afficher). - # MAIS si on l'envoie, le destinataire reçoit la data. C'est "envoyé mais pas vu". - # Le user a dit "le destinataire ne le voit pas". - # Si on l'envoie par WS, le JS le reçoit. - # On devrait peut-être NE PAS l'envoyer au destinataire via WS si caché. - # Ou l'envoyer avec un flag "hidden" et le front ne l'affiche pas ? - # Sécurité: Mieux vaut ne pas l'envoyer au destinataire. - + # Broadcast to WebSocket avec les infos de visibilité try: from channels.layers import get_channel_layer from asgiref.sync import async_to_sync @@ -230,26 +216,28 @@ class ConversationViewSet(HashIdMixin, viewsets.ModelViewSet): msg_data = MessageSerializer(message).data - # Envoyer au groupe du chat - # Problème: le groupe inclut les deux users. - # Si on envoie, les deux reçoivent. - # On ne peut pas filtrer par destinataire facilement ici sauf si on a des groupes par user. - # Solution: Envoyer le message avec le champ 'is_visible_to_recipient'. - # Le frontend devra filtrer. C'est acceptable car ce n'est pas une donnée ultra sensible, juste une règle métier. - # Et le serializer inclut déjà 'is_visible_to_recipient' ? Non, il faut l'ajouter au serializer. - async_to_sync(channel_layer.group_send)( f'chat_{conversation.id}', { 'type': 'chat_message', - 'message': msg_data + 'message': msg_data, + 'sender_id': request.user.id, + 'is_visible': is_visible } ) except Exception as e: pass + # Préparer la réponse + response_data = MessageSerializer(message).data + + # Ajouter un avertissement si le message est en attente + if not is_visible: + response_data['queued'] = True + response_data['queued_message'] = "Votre message sera délivré au destinataire lors de votre prochain rendez-vous." + return Response( - MessageSerializer(message).data, + response_data, status=status.HTTP_201_CREATED ) diff --git a/backend/apps/opportunities/views.py b/backend/apps/opportunities/views.py index dc44d58bddc16aeb0e872fd2d6887ee48c8a2e4b..9baad047e6ff752c2d31ac949e3d546bd479628c 100644 --- a/backend/apps/opportunities/views.py +++ b/backend/apps/opportunities/views.py @@ -5,14 +5,14 @@ from rest_framework import viewsets, filters from django_filters.rest_framework import DjangoFilterBackend from rest_framework.decorators import action from rest_framework.response import Response -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import IsAuthenticated, AllowAny from apps.opportunities.models import Opportunity, OpportunityView from apps.opportunities.serializers import OpportunitySerializer class OpportunityViewSet(viewsets.ReadOnlyModelViewSet): """Liste des opportunités""" - permission_classes = [IsAuthenticated] + permission_classes = [AllowAny] queryset = Opportunity.objects.filter(is_active=True) serializer_class = OpportunitySerializer filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] diff --git a/backend/apps/users/apps.py b/backend/apps/users/apps.py index 2bb189ca6811f6aeb6bdb4b2b2002b8294e3596e..7c5545c50538e3908b493ce78319b1c5d7b5e34e 100644 --- a/backend/apps/users/apps.py +++ b/backend/apps/users/apps.py @@ -4,3 +4,6 @@ from django.apps import AppConfig class UsersConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'apps.users' + + def ready(self): + import apps.users.signals # noqa diff --git a/backend/apps/users/encryption.py b/backend/apps/users/encryption.py new file mode 100644 index 0000000000000000000000000000000000000000..b5e8a37b348e4aec034ffec30fbbcdd8f1d0bfed --- /dev/null +++ b/backend/apps/users/encryption.py @@ -0,0 +1,78 @@ +# ============================================ +# apps/users/encryption.py - Service de chiffrement +# ============================================ +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.backends import default_backend +import logging + +logger = logging.getLogger(__name__) + + +def generate_rsa_keypair(): + """ + Génère une paire de clés RSA 2048 bits. + Retourne un tuple (public_key_pem, private_key_pem) + """ + try: + # Générer la clé privée + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=default_backend() + ) + + # Convertir la clé privée en PEM + private_key_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() + ).decode('utf-8') + + # Extraire la clé publique et la convertir en PEM + public_key = private_key.public_key() + public_key_pem = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo + ).decode('utf-8') + + return public_key_pem, private_key_pem + + except Exception as e: + logger.error(f"Erreur lors de la génération des clés RSA: {e}") + return None, None + + +def ensure_user_has_keys(user_profile): + """ + S'assure qu'un profil utilisateur a des clés de chiffrement. + Si non, les génère et les sauvegarde. + + Args: + user_profile: Instance de UserProfile + + Returns: + bool: True si les clés existent ou ont été générées avec succès + """ + if user_profile.public_key and user_profile.encrypted_private_key: + return True + + try: + public_key, private_key = generate_rsa_keypair() + + if public_key and private_key: + user_profile.public_key = public_key + # Note: En production, la clé privée devrait être chiffrée + # avec le mot de passe de l'utilisateur ou un secret dérivé. + # Pour l'instant, on la stocke en clair (le frontend la chiffre lui-même). + user_profile.encrypted_private_key = private_key + user_profile.save(update_fields=['public_key', 'encrypted_private_key']) + + logger.info(f"Clés générées pour le profil {user_profile.id}") + return True + + return False + + except Exception as e: + logger.error(f"Erreur lors de la génération des clés pour profil {user_profile.id}: {e}") + return False diff --git a/backend/apps/users/management/__init__.py b/backend/apps/users/management/__init__.py index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..de76b0a8f66c18b54ff95de7facd954dae41d912 100644 --- a/backend/apps/users/management/__init__.py +++ b/backend/apps/users/management/__init__.py @@ -0,0 +1 @@ +# Empty file to make this a Python package diff --git a/backend/apps/users/management/commands/__init__.py b/backend/apps/users/management/commands/__init__.py index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..de76b0a8f66c18b54ff95de7facd954dae41d912 100644 --- a/backend/apps/users/management/commands/__init__.py +++ b/backend/apps/users/management/commands/__init__.py @@ -0,0 +1 @@ +# Empty file to make this a Python package diff --git a/backend/apps/users/management/commands/fix_profile_flags.py b/backend/apps/users/management/commands/fix_profile_flags.py new file mode 100644 index 0000000000000000000000000000000000000000..767398440b9d89493b436bb6b6d996426f9fb210 --- /dev/null +++ b/backend/apps/users/management/commands/fix_profile_flags.py @@ -0,0 +1,47 @@ +# ============================================ +# apps/users/management/commands/fix_profile_flags.py +# ============================================ +from django.core.management.base import BaseCommand +from apps.users.models import UserProfile + + +class Command(BaseCommand): + help = 'Corrige les flags is_current des profils utilisateurs' + + def handle(self, *args, **options): + # Trouver tous les profils sans is_current=True + profiles_to_fix = UserProfile.objects.filter(is_current=False) + + total = profiles_to_fix.count() + self.stdout.write(f"Profils à corriger: {total}") + + if total == 0: + self.stdout.write(self.style.SUCCESS("Tous les profils sont déjà corrects!")) + return + + # Mettre à jour tous les profils + updated = profiles_to_fix.update(is_current=True) + + self.stdout.write( + self.style.SUCCESS(f"✓ {updated} profils mis à jour avec is_current=True") + ) + + # Vérifier que tous ont des clés + profiles_without_keys = UserProfile.objects.filter( + is_current=True + ).filter( + public_key__isnull=True + ) | UserProfile.objects.filter( + is_current=True, + public_key='' + ) + + if profiles_without_keys.exists(): + self.stdout.write( + self.style.WARNING( + f"\n⚠ {profiles_without_keys.count()} profils n'ont pas de clés de chiffrement." + ) + ) + self.stdout.write( + "Exécutez: python manage.py generate_encryption_keys" + ) diff --git a/backend/apps/users/management/commands/generate_encryption_keys.py b/backend/apps/users/management/commands/generate_encryption_keys.py new file mode 100644 index 0000000000000000000000000000000000000000..5eb8958af9e20eca8f5b047b509ca0b521baf001 --- /dev/null +++ b/backend/apps/users/management/commands/generate_encryption_keys.py @@ -0,0 +1,56 @@ +# ============================================ +# apps/users/management/commands/generate_encryption_keys.py +# ============================================ +from django.core.management.base import BaseCommand +from apps.users.models import UserProfile +from apps.users.encryption import ensure_user_has_keys + + +class Command(BaseCommand): + help = 'Génère les clés de chiffrement pour tous les profils qui n\'en ont pas' + + def add_arguments(self, parser): + parser.add_argument( + '--force', + action='store_true', + help='Régénère les clés même si elles existent déjà', + ) + + def handle(self, *args, **options): + force = options['force'] + + # Récupérer tous les profils courants + profiles = UserProfile.objects.filter(is_current=True) + + if not force: + # Filtrer ceux qui n'ont pas de clés + profiles = profiles.filter( + public_key__isnull=True + ) | profiles.filter( + public_key='' + ) | profiles.filter( + encrypted_private_key__isnull=True + ) | profiles.filter( + encrypted_private_key='' + ) + + total = profiles.count() + self.stdout.write(f"Profils à traiter: {total}") + + success = 0 + failed = 0 + + for profile in profiles: + if ensure_user_has_keys(profile): + success += 1 + self.stdout.write( + self.style.SUCCESS(f"✓ Clés générées pour {profile.user.email}") + ) + else: + failed += 1 + self.stdout.write( + self.style.ERROR(f"✗ Échec pour {profile.user.email}") + ) + + self.stdout.write('') + self.stdout.write(self.style.SUCCESS(f"Terminé: {success} succès, {failed} échecs")) diff --git a/backend/apps/users/serializers.py b/backend/apps/users/serializers.py index 3fef2be66468c38a0dc85449494dc5c3aae86127..96bcf7ca94e1118dbb1a24a436ca5f87a022e29a 100644 --- a/backend/apps/users/serializers.py +++ b/backend/apps/users/serializers.py @@ -93,17 +93,24 @@ class UserRegistrationSerializer(serializers.Serializer): country = validated_data.pop('country', None) university = validated_data.pop('university', None) - # Créer l'utilisateur + # Créer l'utilisateur (déclenche le signal post_save qui crée un profil de base) user = User.objects.create_user(**validated_data) - # Créer le profil - profile = UserProfile.objects.create(user=user, name=name) + # Récupérer le profil créé par le signal et le mettre à jour avec les vraies infos + profile = UserProfile.objects.filter(user=user).first() + if not profile: + # Au cas où le signal n'aurait pas fonctionné + profile = UserProfile.objects.create(user=user, name=name, is_current=True) + else: + profile.name = name + profile.is_current = True # S'assurer que le profil est actif + profile.save() if country: - UserCountry.objects.create(profile=profile, country=country) + UserCountry.objects.update_or_create(profile=profile, defaults={'country': country, 'is_current': True}) if university: - UserUniversity.objects.create(profile=profile, university=university) + UserUniversity.objects.update_or_create(profile=profile, defaults={'university': university, 'is_current': True}) return user diff --git a/backend/apps/users/signals.py b/backend/apps/users/signals.py new file mode 100644 index 0000000000000000000000000000000000000000..f101552127b5adc0f0390d1b9809d34b01ed26ea --- /dev/null +++ b/backend/apps/users/signals.py @@ -0,0 +1,49 @@ +# ============================================ +# apps/users/signals.py - Signaux pour les utilisateurs +# ============================================ +from django.db.models.signals import post_save +from django.dispatch import receiver +import logging + +logger = logging.getLogger(__name__) + + +@receiver(post_save, sender='users.UserProfile') +def generate_encryption_keys_on_profile_create(sender, instance, created, **kwargs): + """ + Génère automatiquement les clés de chiffrement quand un profil est créé + ou si le profil n'a pas de clés. + """ + # Vérifier si le profil n'a pas déjà de clés + if not instance.public_key or not instance.encrypted_private_key: + try: + from apps.users.encryption import generate_rsa_keypair + + public_key, private_key = generate_rsa_keypair() + + if public_key and private_key: + # Utiliser update() pour éviter de déclencher à nouveau le signal + from apps.users.models import UserProfile + UserProfile.objects.filter(pk=instance.pk).update( + public_key=public_key, + encrypted_private_key=private_key, + is_current=True # S'assurer que le profil est actif + ) + logger.info(f"Clés de chiffrement générées pour le profil {instance.pk}") + except Exception as e: + logger.error(f"Erreur lors de la génération des clés: {e}") + + +@receiver(post_save, sender='users.User') +def ensure_user_profile_has_keys(sender, instance, created, **kwargs): + """ + S'assure que le profil courant de l'utilisateur a des clés de chiffrement. + """ + try: + profile = instance.profiles.filter(is_current=True).first() + + if profile and (not profile.public_key or not profile.encrypted_private_key): + from apps.users.encryption import ensure_user_has_keys + ensure_user_has_keys(profile) + except Exception as e: + logger.error(f"Erreur lors de la vérification des clés utilisateur: {e}") diff --git a/backend/celery.py b/backend/celery.py deleted file mode 100644 index 1f49f88ad5843f003443b0f6404549b761b454d4..0000000000000000000000000000000000000000 --- a/backend/celery.py +++ /dev/null @@ -1,12 +0,0 @@ -import os -from celery import Celery - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'educonnect_api.settings') - -app = Celery('educonnect_api') -app.config_from_object('django.conf:settings', namespace='CELERY') -app.autodiscover_tasks() - -@app.task(bind=True) -def debug_task(self): - print(f'Request: {self.request!r}') \ No newline at end of file diff --git a/backend/educonnect/__init__.py b/backend/educonnect/__init__.py index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..098b8a4bf4b2f2efd85b89ec55a3e5f86b91b6b5 100644 --- a/backend/educonnect/__init__.py +++ b/backend/educonnect/__init__.py @@ -0,0 +1,8 @@ +# ============================================ +# educonnect/__init__.py +# ============================================ +# This will make sure the app is always imported when +# Django starts so that shared_task will use this app. +from .celery import app as celery_app + +__all__ = ('celery_app',) diff --git a/backend/educonnect/asgi.py b/backend/educonnect/asgi.py index d68399f9e523d07f24bfa43371eede124316eb12..9e0254233a8299d33d09044d2aa60c26d1de8b7e 100644 --- a/backend/educonnect/asgi.py +++ b/backend/educonnect/asgi.py @@ -1,29 +1,25 @@ -# ============================================ -# educonnect_api/asgi.py -# ============================================ - import os +import django from django.core.asgi import get_asgi_application -from channels.routing import ProtocolTypeRouter, URLRouter -from channels.auth import AuthMiddlewareStack -from apps.messaging.consumers import ChatConsumer -from django.urls import path -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'educonnect_api.settings') +# Définir les paramètres Django avant tout import d'application +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'educonnect.settings') +# Initialiser Django django_asgi_app = get_asgi_application() +# Maintenant on peut importer le reste from channels.routing import ProtocolTypeRouter, URLRouter - +from django.urls import path +from apps.messaging.consumers import ChatConsumer from apps.notifications.consumers import NotificationConsumer +from educonnect.middleware import JwtAuthMiddleware websocket_urlpatterns = [ path('ws/chat//', ChatConsumer.as_asgi()), path('ws/notifications/', NotificationConsumer.as_asgi()), ] -from educonnect.middleware import JwtAuthMiddleware - application = ProtocolTypeRouter({ "http": django_asgi_app, "websocket": JwtAuthMiddleware( diff --git a/backend/educonnect/celery.py b/backend/educonnect/celery.py new file mode 100644 index 0000000000000000000000000000000000000000..1c8f05b562fc95c7753fa28e968ae4852f9d344a --- /dev/null +++ b/backend/educonnect/celery.py @@ -0,0 +1,22 @@ +# ============================================ +# educonnect/celery.py - Configuration Celery +# ============================================ +import os +from celery import Celery + +# set the default Django settings module for the 'celery' program. +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'educonnect.settings') + +app = Celery('educonnect') + +# Using a string here means the worker doesn't have to serialize +# the configuration object to child processes. +app.config_from_object('django.conf:settings', namespace='CELERY') + +# Load task modules from all registered Django app configs. +app.autodiscover_tasks() + + +@app.task(bind=True, ignore_result=True) +def debug_task(self): + print(f'Request: {self.request!r}') diff --git a/backend/educonnect/settings.py b/backend/educonnect/settings.py index d18ab6695a51575b1de35ace5c155221d141116f..8b58dcdbc9fcd44dd1de43d18670b7ee8e8587b6 100644 --- a/backend/educonnect/settings.py +++ b/backend/educonnect/settings.py @@ -30,6 +30,7 @@ INSTALLED_APPS = [ 'django_filters', 'drf_spectacular', 'channels', + 'django_celery_beat', # Local apps 'apps.users', @@ -79,31 +80,31 @@ WSGI_APPLICATION = 'educonnect.wsgi.application' ASGI_APPLICATION = 'educonnect.asgi.application' # Database -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', - 'OPTIONS': { - 'timeout': 20, # Augmente le timeout pour éviter "database is locked" +if config('DB_HOST', default=None): + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': config('DB_NAME', default='educonnect_db'), + 'USER': config('DB_USER', default='postgres'), + 'PASSWORD': config('DB_PASSWORD', default='postgres'), + 'HOST': config('DB_HOST'), + 'PORT': config('DB_PORT', default='5432'), + 'CONN_MAX_AGE': 600, + 'OPTIONS': { + 'connect_timeout': 10, + } + } + } +else: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + 'OPTIONS': { + 'timeout': 20, + } } } -} - -# Uncomment below for PostgreSQL -# DATABASES = { -# 'default': { -# 'ENGINE': 'django.db.backends.postgresql', -# 'NAME': config('DB_NAME', default='educonnect_db'), -# 'USER': config('DB_USER', default='postgres'), -# 'PASSWORD': config('DB_PASSWORD', default='postgres'), -# 'HOST': config('DB_HOST', default='localhost'), -# 'PORT': config('DB_PORT', default='5432'), -# 'CONN_MAX_AGE': 600, -# 'OPTIONS': { -# 'connect_timeout': 10, -# } -# } -# } # Custom User Model AUTH_USER_MODEL = 'users.User' @@ -297,3 +298,27 @@ BADGE_THRESHOLDS = { 'MASTER': {'points': 2500}, 'LEGEND': {'points': 5000}, } + +# Celery Configuration +CELERY_BROKER_URL = config('CELERY_BROKER_URL', default='redis://localhost:6379/0') +CELERY_RESULT_BACKEND = config('CELERY_RESULT_BACKEND', default='redis://localhost:6379/0') +CELERY_ACCEPT_CONTENT = ['json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_TIMEZONE = TIME_ZONE +CELERY_TASK_TRACK_STARTED = True +CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 minutes max + +# Celery Beat - Tâches périodiques +CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler' +CELERY_BEAT_SCHEDULE = { + 'check-pending-messages-every-5-minutes': { + 'task': 'messaging.check_pending_messages', + 'schedule': 300.0, # 5 minutes + }, + 'schedule-unlock-upcoming-bookings': { + 'task': 'messaging.schedule_unlock_for_upcoming_bookings', + 'schedule': 600.0, # 10 minutes + }, +} + diff --git a/backend/list_models.py b/backend/list_models.py new file mode 100644 index 0000000000000000000000000000000000000000..14a9da4c762f5d4d0f8bb86d6f482edd5dfd9a35 --- /dev/null +++ b/backend/list_models.py @@ -0,0 +1,21 @@ +from google import genai +import os +from decouple import config + +def list_models(): + api_key = config('GEMINI_API_KEY', default='') + if not api_key: + print("GEMINI_API_KEY not found") + return + + client = genai.Client(api_key=api_key) + try: + print("Listing models...") + for model in client.models.list(): + if 'generateContent' in model.supported_actions: + print(f"Model: {model.name}") + except Exception as e: + print(f"Error: {e}") + +if __name__ == "__main__": + list_models() diff --git a/backend/requirements.txt b/backend/requirements.txt index e21103ffae82dd07f19e5049b54cd1091c87f179..97f38d75138c457bce207d10ee107a34a7285377 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,6 +1,6 @@ amqp==5.3.1 annotated-types==0.7.0 -anyio==4.12.0 +anyio>=3.5.0 asgiref==3.11.0 attrs==25.4.0 autobahn==25.11.1 @@ -32,9 +32,11 @@ django-allauth==0.57.0 django-cors-headers==4.3.0 django-filter==23.3 django-ses==3.5.0 +django-celery-beat==2.5.0 +django-celery-results==2.5.1 django-storages==1.14.2 djangorestframework==3.14.0 -djangorestframework-simplejwt==5.3.0 +djangorestframework-simplejwt==5.5.1 drf-spectacular==0.26.5 factory-boy==3.3.0 Faker==20.1.0 @@ -42,7 +44,7 @@ flake8==6.1.0 google-ai-generativelanguage==0.4.0 google-api-core==2.28.1 google-auth==2.43.0 -google-genai==1.52.0 +google-genai>=0.1.0 google-generativeai==0.3.1 googleapis-common-protos==1.72.0 grpcio==1.76.0 @@ -66,7 +68,7 @@ mccabe==0.7.0 msgpack==1.1.2 mypy_extensions==1.1.0 oauthlib==3.3.1 -openai==1.3.7 +openai>=1.3.7 packaging==25.0 pathspec==0.12.1 Pillow==10.1.0 @@ -122,3 +124,4 @@ wcwidth==0.2.14 websockets==15.0.1 whitenoise==6.6.0 zope.interface==8.1.1 +pypdf==5.1.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..b71e550c41cc8d2dec367a5fc8fcccbe60cc947f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,101 @@ + +services: + db: + image: postgres:15 + volumes: + - postgres_data:/var/lib/postgresql/data + environment: + POSTGRES_DB: educonnect_db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5433:5432" + + redis: + image: redis:7-alpine + ports: + - "6380:6379" + + backend: + build: ./backend + command: python manage.py runserver 0.0.0.0:8000 + volumes: + - ./backend:/app + - media_volume:/app/media + - static_volume:/app/staticfiles + ports: + - "8000:8000" + env_file: + - ./backend/.env + environment: + - DEBUG=True + - DB_HOST=db + - DB_PORT=5432 + - DB_NAME=educonnect_db + - DB_USER=postgres + - DB_PASSWORD=postgres + - REDIS_HOST=redis + - CELERY_BROKER_URL=redis://redis:6379/0 + - CELERY_RESULT_BACKEND=redis://redis:6379/0 + depends_on: + - db + - redis + + celery: + build: ./backend + command: celery -A educonnect worker -l info + volumes: + - ./backend:/app + env_file: + - ./backend/.env + environment: + - DEBUG=True + - DB_HOST=db + - DB_PORT=5432 + - DB_NAME=educonnect_db + - DB_USER=postgres + - DB_PASSWORD=postgres + - REDIS_HOST=redis + - CELERY_BROKER_URL=redis://redis:6379/0 + - CELERY_RESULT_BACKEND=redis://redis:6379/0 + depends_on: + - db + - redis + + channels: + build: ./backend + command: daphne -b 0.0.0.0 -p 8001 educonnect.asgi:application + volumes: + - ./backend:/app + ports: + - "8001:8001" + env_file: + - ./backend/.env + environment: + - DEBUG=True + - DB_HOST=db + - DB_PORT=5432 + - DB_NAME=educonnect_db + - DB_USER=postgres + - DB_PASSWORD=postgres + - REDIS_HOST=redis + depends_on: + - db + - redis + + frontend: + build: ./frontend + volumes: + - ./frontend:/app + - /app/node_modules + ports: + - "5173:3000" + environment: + - VITE_API_URL=http://localhost:8000/api + depends_on: + - backend + +volumes: + postgres_data: + media_volume: + static_volume: diff --git a/ANALYTICS_SYSTEM.md b/documentation/ANALYTICS_SYSTEM.md similarity index 100% rename from ANALYTICS_SYSTEM.md rename to documentation/ANALYTICS_SYSTEM.md diff --git a/AVATAR_ERROR_FIX.md b/documentation/AVATAR_ERROR_FIX.md similarity index 100% rename from AVATAR_ERROR_FIX.md rename to documentation/AVATAR_ERROR_FIX.md diff --git a/AVATAR_UPLOAD_COMPLETE_FIX.md b/documentation/AVATAR_UPLOAD_COMPLETE_FIX.md similarity index 100% rename from AVATAR_UPLOAD_COMPLETE_FIX.md rename to documentation/AVATAR_UPLOAD_COMPLETE_FIX.md diff --git a/AVATAR_UPLOAD_FIX.md b/documentation/AVATAR_UPLOAD_FIX.md similarity index 100% rename from AVATAR_UPLOAD_FIX.md rename to documentation/AVATAR_UPLOAD_FIX.md diff --git a/BADGES_INTEGRATION.md b/documentation/BADGES_INTEGRATION.md similarity index 100% rename from BADGES_INTEGRATION.md rename to documentation/BADGES_INTEGRATION.md diff --git a/CHAT_BUG_FIX.md b/documentation/CHAT_BUG_FIX.md similarity index 100% rename from CHAT_BUG_FIX.md rename to documentation/CHAT_BUG_FIX.md diff --git a/DATABASE_LOCKED_SOLUTION.md b/documentation/DATABASE_LOCKED_SOLUTION.md similarity index 100% rename from DATABASE_LOCKED_SOLUTION.md rename to documentation/DATABASE_LOCKED_SOLUTION.md diff --git a/DEBOUNCE_EXPLAINED.md b/documentation/DEBOUNCE_EXPLAINED.md similarity index 100% rename from DEBOUNCE_EXPLAINED.md rename to documentation/DEBOUNCE_EXPLAINED.md diff --git a/DEBUGGING_AVAILABILITIES.md b/documentation/DEBUGGING_AVAILABILITIES.md similarity index 100% rename from DEBUGGING_AVAILABILITIES.md rename to documentation/DEBUGGING_AVAILABILITIES.md diff --git a/DEBUG_ERREURS.md b/documentation/DEBUG_ERREURS.md similarity index 100% rename from DEBUG_ERREURS.md rename to documentation/DEBUG_ERREURS.md diff --git a/documentation/ENCRYPTION.md b/documentation/ENCRYPTION.md new file mode 100644 index 0000000000000000000000000000000000000000..d727a16fecc8b7855cefa9b89f62aa412c16c32d --- /dev/null +++ b/documentation/ENCRYPTION.md @@ -0,0 +1,143 @@ +# Système de Chiffrement End-to-End - EduConnect + +## Vue d'ensemble + +Le système de chiffrement garantit que tous les messages entre utilisateurs sont chiffrés de bout en bout (E2E) en utilisant RSA-2048 et AES-256. + +## Architecture + +### Backend (Django) + +1. **Génération automatique des clés** (`apps/users/encryption.py`) + - Génère une paire de clés RSA 2048 bits pour chaque utilisateur + - Clé publique : stockée en clair (partagée avec les autres) + - Clé privée : stockée (à chiffrer avec le mot de passe utilisateur en production) + +2. **Signaux automatiques** (`apps/users/signals.py`) + - `generate_encryption_keys_on_profile_create` : Génère les clés à la création du profil + - `ensure_user_profile_has_keys` : Vérifie que chaque utilisateur a des clés + - Définit automatiquement `is_current=True` sur les profils + +3. **Serializers** (`apps/users/serializers.py`) + - `UserProfileDetailSerializer` : Inclut `public_key` et `encrypted_private_key` + - `UserRegistrationSerializer` : S'assure que `is_current=True` lors de l'inscription + +### Frontend (React/TypeScript) + +1. **Service de chiffrement** (`services/encryption.ts`) + - Récupère les clés depuis le backend en priorité + - Génère localement uniquement en fallback + - Synchronise automatiquement avec le backend + +2. **Initialisation automatique** (`context/AuthContext.tsx`) + - Les clés sont initialisées dès la connexion + - Pas besoin d'attendre l'ouverture du chat + +3. **Chiffrement des messages** (`pages/Chat.tsx`) + - Vérifie que tous les participants ont des clés publiques + - Génère une clé AES unique par message + - Chiffre le contenu avec AES + - Chiffre la clé AES avec la clé publique RSA de chaque participant + +## Commandes de maintenance + +### Générer les clés pour tous les utilisateurs +```bash +python manage.py generate_encryption_keys +``` + +Options : +- `--force` : Régénère les clés même si elles existent + +### Corriger les flags is_current +```bash +python manage.py fix_profile_flags +``` + +## Vérifications + +### Vérifier que tous les profils ont des clés +```bash +python manage.py shell -c " +from apps.users.models import UserProfile +profiles = UserProfile.objects.filter(is_current=True) +print(f'Total: {profiles.count()}') +for p in profiles: + has_keys = bool(p.public_key and p.encrypted_private_key) + print(f'{p.user.email}: {\"✓\" if has_keys else \"✗\"} Clés') +" +``` + +### Vérifier is_current +```bash +python manage.py shell -c " +from apps.users.models import UserProfile +total = UserProfile.objects.count() +current = UserProfile.objects.filter(is_current=True).count() +print(f'Profils actifs: {current}/{total}') +" +``` + +## Flux de chiffrement + +1. **Envoi d'un message** + ``` + User A → Génère clé AES → Chiffre message avec AES + → Chiffre clé AES avec public_key de User B + → Envoie {contenu_chiffré, clés_chiffrées} + ``` + +2. **Réception d'un message** + ``` + User B → Reçoit message chiffré + → Déchiffre la clé AES avec sa private_key + → Déchiffre le contenu avec la clé AES + → Affiche le message + ``` + +## Sécurité + +### Points forts +✅ Chiffrement RSA-2048 (clés asymétriques) +✅ Chiffrement AES-256-CBC (contenu des messages) +✅ Clé AES unique par message +✅ Génération automatique des clés +✅ Clés stockées côté serveur (backup) + +### Améliorations futures +⚠️ Chiffrer la clé privée avec le mot de passe utilisateur +⚠️ Implémenter la rotation des clés +⚠️ Ajouter un système de récupération de clés +⚠️ Perfect Forward Secrecy (PFS) + +## Dépannage + +### "Message non chiffré (Destinataire sans clés)" + +**Causes possibles :** +1. Le profil n'a pas `is_current=True` + - Solution : `python manage.py fix_profile_flags` + +2. Les clés n'ont pas été générées + - Solution : `python manage.py generate_encryption_keys` + +3. Les clés ne sont pas renvoyées par l'API + - Vérifier que `UserProfileDetailSerializer` inclut les clés + - Vérifier le mapping dans `services/messaging.ts` + +### Logs de débogage + +Dans la console du navigateur, lors de l'envoi d'un message : +``` +🔐 Vérification du chiffrement: + - Mes clés: Présentes + - Autres participants: 1 + - Participant 1: [Nom] + - public_key: Présente ✓ + - Chiffrement possible: OUI ✓ +``` + +Si "NON ✗" apparaît, vérifier que le backend renvoie bien les clés. + +## Développé par +Marino ATOHOUN pour Hypee - EduConnect Africa diff --git a/ENCRYPTION_FEATURE.md b/documentation/ENCRYPTION_FEATURE.md similarity index 100% rename from ENCRYPTION_FEATURE.md rename to documentation/ENCRYPTION_FEATURE.md diff --git a/documentation/EXTERNAL_TOOLS.md b/documentation/EXTERNAL_TOOLS.md new file mode 100644 index 0000000000000000000000000000000000000000..267f2f37a9785200eda0ad4fdb2c5faac3558dda --- /dev/null +++ b/documentation/EXTERNAL_TOOLS.md @@ -0,0 +1,47 @@ +# Outils Externes - EduConnect + +## Laboratoire Chimique Virtuel + +Le Laboratoire Chimique est déployé séparément sur Vercel et accessible via un lien externe. + +### Accès +- **URL** : https://virtual-labo-chimique.vercel.app/ +- **Depuis EduConnect** : Outils → Laboratoire Chimique → S'ouvre dans un nouvel onglet + +### Configuration + +Le lien est configuré dans `frontend/pages/LearningTools.tsx` : + +```typescript +else if (toolId === 'chem') { + window.open('https://virtual-labo-chimique.vercel.app/', '_blank'); +} +``` + +### Modification du lien + +Pour changer l'URL du laboratoire : + +1. Ouvrir `frontend/pages/LearningTools.tsx` +2. Chercher `toolId === 'chem'` +3. Modifier l'URL dans `window.open()` + +### Base de données + +L'outil est enregistré dans la table `learning_tools` avec : +- `tool_id` : `'chem'` +- `title` : `'Laboratoire Chimique'` +- `status` : `'available'` + +## Autres outils externes + +Pour ajouter d'autres outils externes, suivre le même pattern : + +```typescript +else if (toolId === 'mon_outil') { + window.open('https://mon-outil.com/', '_blank'); +} +``` + +--- +Développé par Marino ATOHOUN pour Hypee - EduConnect Africa diff --git a/FORMULES_GUIDE.md b/documentation/FORMULES_GUIDE.md similarity index 100% rename from FORMULES_GUIDE.md rename to documentation/FORMULES_GUIDE.md diff --git a/GEMINI_SETUP.md b/documentation/GEMINI_SETUP.md similarity index 100% rename from GEMINI_SETUP.md rename to documentation/GEMINI_SETUP.md diff --git a/INTEGRATION_COMPLETE.md b/documentation/INTEGRATION_COMPLETE.md similarity index 100% rename from INTEGRATION_COMPLETE.md rename to documentation/INTEGRATION_COMPLETE.md diff --git a/INTEGRATION_LOG.md b/documentation/INTEGRATION_LOG.md similarity index 100% rename from INTEGRATION_LOG.md rename to documentation/INTEGRATION_LOG.md diff --git a/MESSAGING_SYSTEM.md b/documentation/MESSAGING_SYSTEM.md similarity index 100% rename from MESSAGING_SYSTEM.md rename to documentation/MESSAGING_SYSTEM.md diff --git a/NOTIFICATIONS_SYSTEM.md b/documentation/NOTIFICATIONS_SYSTEM.md similarity index 100% rename from NOTIFICATIONS_SYSTEM.md rename to documentation/NOTIFICATIONS_SYSTEM.md diff --git a/PROFILE_UPDATE_RESOLUTION.md b/documentation/PROFILE_UPDATE_RESOLUTION.md similarity index 100% rename from PROFILE_UPDATE_RESOLUTION.md rename to documentation/PROFILE_UPDATE_RESOLUTION.md diff --git a/RAPPORT_FORMULES.md b/documentation/RAPPORT_FORMULES.md similarity index 100% rename from RAPPORT_FORMULES.md rename to documentation/RAPPORT_FORMULES.md diff --git a/RAPPORT_INTEGRATION.md b/documentation/RAPPORT_INTEGRATION.md similarity index 100% rename from RAPPORT_INTEGRATION.md rename to documentation/RAPPORT_INTEGRATION.md diff --git a/RAPPORT_MENTORAT.md b/documentation/RAPPORT_MENTORAT.md similarity index 100% rename from RAPPORT_MENTORAT.md rename to documentation/RAPPORT_MENTORAT.md diff --git a/RAPPORT_QUESTIONS_AMELIORATIONS.md b/documentation/RAPPORT_QUESTIONS_AMELIORATIONS.md similarity index 100% rename from RAPPORT_QUESTIONS_AMELIORATIONS.md rename to documentation/RAPPORT_QUESTIONS_AMELIORATIONS.md diff --git a/README.md b/documentation/README.md similarity index 100% rename from README.md rename to documentation/README.md diff --git a/RESTART_SERVER.md b/documentation/RESTART_SERVER.md similarity index 100% rename from RESTART_SERVER.md rename to documentation/RESTART_SERVER.md diff --git a/SETTINGS_FEATURE.md b/documentation/SETTINGS_FEATURE.md similarity index 100% rename from SETTINGS_FEATURE.md rename to documentation/SETTINGS_FEATURE.md diff --git a/TEST_FORMULES.md b/documentation/TEST_FORMULES.md similarity index 100% rename from TEST_FORMULES.md rename to documentation/TEST_FORMULES.md diff --git a/TEST_FORUM.md b/documentation/TEST_FORUM.md similarity index 100% rename from TEST_FORUM.md rename to documentation/TEST_FORUM.md diff --git a/TEST_PROFILES.md b/documentation/TEST_PROFILES.md similarity index 100% rename from TEST_PROFILES.md rename to documentation/TEST_PROFILES.md diff --git a/TEST_QUESTIONS.md b/documentation/TEST_QUESTIONS.md similarity index 100% rename from TEST_QUESTIONS.md rename to documentation/TEST_QUESTIONS.md diff --git a/TRACKING_INTEGRATION.md b/documentation/TRACKING_INTEGRATION.md similarity index 100% rename from TRACKING_INTEGRATION.md rename to documentation/TRACKING_INTEGRATION.md diff --git a/TRACKING_SUMMARY.md b/documentation/TRACKING_SUMMARY.md similarity index 100% rename from TRACKING_SUMMARY.md rename to documentation/TRACKING_SUMMARY.md diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..cd139c5a49dc54b5860b9bf10e5ff8c12bbe552f --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,20 @@ +# Utiliser une image Node.js légère +FROM node:20-slim + +# Définir le répertoire de travail +WORKDIR /app + +# Copier les fichiers de package +COPY package*.json ./ + +# Installer les dépendances +RUN npm install + +# Copier le reste des fichiers du projet +COPY . . + +# Exposer le port par défaut de Vite +EXPOSE 3000 + +# Lancer l'application en mode développement +CMD ["npm", "run", "dev", "--", "--host"] diff --git a/frontend/components/BecomeMentorModal.tsx b/frontend/components/BecomeMentorModal.tsx index 775f2e6a89b54c2270228efefdeae95b3d821f8d..a99caa553fe70286d0a18be93df8aa1cc011e71a 100644 --- a/frontend/components/BecomeMentorModal.tsx +++ b/frontend/components/BecomeMentorModal.tsx @@ -37,6 +37,7 @@ const BecomeMentorModal: React.FC = ({ isOpen, onClose }) => { const [specialties, setSpecialties] = useState([]); const [tagInput, setTagInput] = useState(''); const [cvFile, setCvFile] = useState(null); + const [idCardFile, setIdCardFile] = useState(null); const [availabilitySlots, setAvailabilitySlots] = useState([ { day: 'MONDAY', startTime: '18:00', endTime: '20:00' } ]); @@ -80,11 +81,11 @@ const BecomeMentorModal: React.FC = ({ isOpen, onClose }) => { if (e.target.files && e.target.files[0]) { const file = e.target.files[0]; if (file.type !== 'application/pdf') { - setError("Le fichier doit être un PDF."); + setError("Le fichier CV doit être un PDF."); return; } if (file.size > 5 * 1024 * 1024) { - setError("Le fichier ne doit pas dépasser 5 Mo."); + setError("Le fichier CV ne doit pas dépasser 5 Mo."); return; } setCvFile(file); @@ -92,6 +93,22 @@ const BecomeMentorModal: React.FC = ({ isOpen, onClose }) => { } }; + const handleIdCardChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + const file = e.target.files[0]; + if (!file.type.startsWith('image/')) { + setError("La photo d'identité doit être une image (JPG, PNG)."); + return; + } + if (file.size > 5 * 1024 * 1024) { + setError("La photo d'identité ne doit pas dépasser 5 Mo."); + return; + } + setIdCardFile(file); + setError(null); + } + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); @@ -101,6 +118,11 @@ const BecomeMentorModal: React.FC = ({ isOpen, onClose }) => { return; } + if (!idCardFile) { + setError("Veuillez télécharger votre photo d'identité."); + return; + } + if (availabilitySlots.length === 0) { setError("Veuillez ajouter au moins une disponibilité."); return; @@ -116,6 +138,7 @@ const BecomeMentorModal: React.FC = ({ isOpen, onClose }) => { data.append('twitter', formData.twitter); data.append('website', formData.website); data.append('cv_file', cvFile); + data.append('id_card_photo', idCardFile); // JSON data data.append('specialties', JSON.stringify(specialties)); @@ -231,30 +254,58 @@ const BecomeMentorModal: React.FC = ({ isOpen, onClose }) => { > - {/* CV Upload */} -
- -
- -
- -
- {cvFile ? ( -
-

{cvFile.name}

-

{(cvFile.size / 1024 / 1024).toFixed(2)} MB

+ {/* CV & ID Card Upload */} +
+
+ +
+ +
+
- ) : ( -
-

Cliquez pour télécharger votre CV

-

Format PDF uniquement (Max 5 Mo)

+ {cvFile ? ( +
+

{cvFile.name}

+

{(cvFile.size / 1024 / 1024).toFixed(2)} MB

+
+ ) : ( +
+

Télécharger CV

+

PDF uniquement (Max 5 Mo)

+
+ )} +
+
+ +
+ +
+ +
+
- )} + {idCardFile ? ( +
+

{idCardFile.name}

+

{(idCardFile.size / 1024 / 1024).toFixed(2)} MB

+
+ ) : ( +
+

Télécharger Photo ID

+

Image JPG/PNG (Max 5 Mo)

+
+ )} +
diff --git a/frontend/components/EditProfileModal.tsx b/frontend/components/EditProfileModal.tsx index 68c39a7720d96f22707ef74a6e915e3022e55d4b..0d38e425996e31d93b9153d9ea7dfab7932d512b 100644 --- a/frontend/components/EditProfileModal.tsx +++ b/frontend/components/EditProfileModal.tsx @@ -9,7 +9,7 @@ interface Props { } const EditProfileModal: React.FC = ({ isOpen, onClose }) => { - const { user, updateUser, mentorProfile, updateMentorProfile } = useAuth(); + const { user, updateUser, mentorProfile, updateMentorProfile, refreshUser } = useAuth(); // Basic User Form Data const [userData, setUserData] = useState({ diff --git a/frontend/components/QuestionCard.tsx b/frontend/components/QuestionCard.tsx index c870de008622b3a5d7b461934a86c2c621ac0f42..1b5512ca6a84f7d66300312acdabc622a4e7b887 100644 --- a/frontend/components/QuestionCard.tsx +++ b/frontend/components/QuestionCard.tsx @@ -128,13 +128,19 @@ const QuestionCard: React.FC = ({ question, onAnswerAdded }) => {
- {question.tags.map(tag => ( - - {tag} - - ))} -
+ {question.tags.map((tag, index) => { + const isLevel = ['université', 'lycée', 'collège', 'licence', 'master', 'doctorat', 'primaire', 'l1', 'l2', 'l3', 'm1', 'm2', '6ème', '5ème', '4ème', '3ème', '2nde', '1ère', 'tle', 'terminale'].includes(tag.toLowerCase()); + const tagClass = isLevel + ? 'bg-amber-50 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 border-amber-100 dark:border-amber-800' + : 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-300 border-blue-100 dark:border-blue-800'; + return ( + + {tag} + + ); + })} +
{question.author.name} @@ -281,4 +287,6 @@ const QuestionCard: React.FC = ({ question, onAnswerAdded }) => { ); }; -export default QuestionCard; \ No newline at end of file +export default QuestionCard; + + diff --git a/frontend/context/AuthContext.tsx b/frontend/context/AuthContext.tsx index 6cd69b969e0f81dc6f3e59098fdf687c273a6797..2a72c02216fb640b29daff0f15dbc601a25820a5 100644 --- a/frontend/context/AuthContext.tsx +++ b/frontend/context/AuthContext.tsx @@ -122,6 +122,18 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children setNotifications(prev => [newNotif, ...prev]); }); + // Initialiser le chiffrement automatiquement + const initEncryption = async () => { + try { + const { encryptionService } = await import('../services/encryption'); + await encryptionService.initializeEncryption(); + console.log('Chiffrement initialisé automatiquement'); + } catch (error) { + console.error('Erreur lors de l\'initialisation du chiffrement:', error); + } + }; + initEncryption(); + return () => { socket.close(); }; diff --git a/frontend/pages/Chat.tsx b/frontend/pages/Chat.tsx index 0fcac3042ca8413de86aeba934966ebd1ced836a..463b5c20d2ecae6ac703be36b7168717702079f2 100644 --- a/frontend/pages/Chat.tsx +++ b/frontend/pages/Chat.tsx @@ -16,6 +16,7 @@ const Chat: React.FC = () => { const [loading, setLoading] = useState(true); const [sending, setSending] = useState(false); const [attachments, setAttachments] = useState([]); + const [queuedNotification, setQueuedNotification] = useState(null); // Encryption State const [myKeys, setMyKeys] = useState<{ publicKey: string; privateKey: string } | null>(null); @@ -176,10 +177,19 @@ const Chat: React.FC = () => { // Use string comparison for Hashids const others = participants.filter(p => p.id !== user.id); + console.log('🔐 Vérification du chiffrement:'); + console.log(' - Mes clés:', myKeys ? 'Présentes' : 'Absentes'); + console.log(' - Autres participants:', others.length); + others.forEach((p, i) => { + console.log(` - Participant ${i + 1}:`, p.profile.name); + console.log(` - public_key:`, p.profile.public_key ? 'Présente ✓' : 'Absente ✗'); + }); + // On peut chiffrer si j'ai mes clés et que les autres ont leurs clés publiques // Note: Pour l'instant on ne chiffre que si tout le monde a une clé. // En production, on pourrait avoir un mode hybride ou avertir l'utilisateur. const canEncrypt = others.every(p => p.profile.public_key); + console.log(' - Chiffrement possible:', canEncrypt ? 'OUI ✓' : 'NON ✗'); if (canEncrypt) { const aesKey = encryptionService.generateAESKey(); @@ -231,8 +241,12 @@ const Chat: React.FC = () => { })); // Notification si message en attente - if (newMessage.is_visible_to_recipient === false) { - // Message en attente + if (newMessage.is_visible_to_recipient === false || (newMessage as any).queued) { + const msg = (newMessage as any).queued_message || + "Votre message sera délivré au destinataire lors de votre prochain rendez-vous."; + setQueuedNotification(msg); + // Auto-dismiss après 5 secondes + setTimeout(() => setQueuedNotification(null), 5000); } setMessageInput(''); @@ -292,6 +306,22 @@ const Chat: React.FC = () => { return (
+ {/* Toast notification pour les messages en attente */} + {queuedNotification && ( +
+
+ +

{queuedNotification}

+ +
+
+ )} + {/* Sidebar - List of Conversations */}
diff --git a/frontend/pages/LearningTools.tsx b/frontend/pages/LearningTools.tsx index 37a82d17eb17089fe7d9d420f86d9831a65b594c..27338b591e79d62fda52192e76a53d68604cbeb7 100644 --- a/frontend/pages/LearningTools.tsx +++ b/frontend/pages/LearningTools.tsx @@ -63,6 +63,9 @@ const LearningTools: React.FC = () => { navigate('/tools/coloring'); } else if (toolId === 'calc') { navigate('/tools/calculator'); + } else if (toolId === 'chem') { + // Ouvrir le laboratoire chimique déployé sur Vercel + window.open('https://virtual-labo-chimique.vercel.app/', '_blank'); } else { alert(`Lancement de l'outil : ${toolTitle} (Version démo)`); } diff --git a/frontend/pages/MentorDashboard.tsx b/frontend/pages/MentorDashboard.tsx index 3bfed73a36dadd3417193d5ca8e38989e17cc314..e2b4870f2920371439154f68efdb506fc5108404 100644 --- a/frontend/pages/MentorDashboard.tsx +++ b/frontend/pages/MentorDashboard.tsx @@ -8,7 +8,7 @@ import { Clock, CheckCircle, XCircle, - Video, + MessageSquare as ChatIcon, MoreHorizontal, Star, Award, @@ -24,29 +24,7 @@ import { UserRole, Mentor } from '../types'; import EditProfileModal from '../components/EditProfileModal'; import ManageAvailabilityModal from '../components/ManageAvailabilityModal'; import { mentorService, bookingsService } from '../services'; - -interface Booking { - id: string; - student: { - id: string; - profile?: { - name: string; - avatar?: string; - university?: string; - country?: string; - }; - email: string; - }; - mentor: any; - date: string; - time: string; - status: 'PENDING' | 'CONFIRMED' | 'REJECTED' | 'COMPLETED' | 'CANCELLED'; - status_label: string; - domains: string[]; - expectation: string; - main_question: string; - created_at: string; -} +import type { Booking } from '../services'; const MentorDashboard: React.FC = () => { const { user, isAuthenticated } = useAuth(); @@ -182,14 +160,25 @@ const MentorDashboard: React.FC = () => { }; const STATS = [ - { label: 'Sessions totales', value: stats.totalSessions.toString(), icon:
); + const renderConfirmedCard = (booking: Booking) => ( +
+
+
+ +
+

+ {booking.student.profile?.name || booking.student.email} +

+
+ + Confirmé + + + {booking.domains[0] || "Session"} +
+
+
+
+
{formatDate(booking.date)}
+
{formatTime(booking.time)}
+
+
+ +
+ + + Ouvrir le chat + +
+
+ ); + const renderOverview = () => (
{/* Stats Grid */} @@ -331,23 +362,61 @@ const MentorDashboard: React.FC = () => {
- {/* Pending Requests Preview */} -
-
-

Demandes récentes

- -
+ {/* Main Content Column */} +
+ + {/* Pending Requests Section */} +
+
+
+

Demandes en attente

+ {pendingRequests.length > 0 && ( + + {pendingRequests.length} + + )} +
+ +
-
- {pendingRequests.slice(0, 3).map(req => renderRequestCard(req))} - {pendingRequests.length === 0 && ( -
-
- +
+ {pendingRequests.slice(0, 2).map(req => renderRequestCard(req))} + {pendingRequests.length === 0 && ( +
+
+ +
+

Aucune nouvelle demande.

-

Aucune demande en attente pour le moment.

+ )} +
+
+ + {/* Confirmed Sessions Section */} +
+
+
+

Séances confirmées

+ {upcomingBookings.length > 0 && ( + + {upcomingBookings.length} + + )}
- )} + +
+ +
+ {upcomingBookings.slice(0, 4).map(booking => renderConfirmedCard(booking))} + {upcomingBookings.length === 0 && ( +
+
+ +
+

Aucune séance confirmée à venir.

+
+ )} +
diff --git a/frontend/pages/QuestionDetail.tsx b/frontend/pages/QuestionDetail.tsx index a3b0906d70bcbe7edfae49e0eb701b816a7ae44b..8b34b75d522277a7cff72be9c15144588cadba0a 100644 --- a/frontend/pages/QuestionDetail.tsx +++ b/frontend/pages/QuestionDetail.tsx @@ -185,16 +185,23 @@ const QuestionDetail: React.FC = () => {
{q.tags && q.tags.length > 0 && (
- {q.tags.slice(0, 2).map(tag => ( - - {tag} - - ))} + {q.tags.slice(0, 2).map((tag, index) => { + const isLevel = ['université', 'lycée', 'collège', 'licence', 'master', 'doctorat', 'primaire', 'l1', 'l2', 'l3', 'm1', 'm2', '6ème', '5ème', '4ème', '3ème', '2nde', '1ère', 'tle', 'terminale'].includes(tag.toLowerCase()); + return ( + + {tag} + + ); + })}
)} +
))}
diff --git a/frontend/services/api.ts b/frontend/services/api.ts index dd0b353ad2faa3866a94292afa10a2f4e2e744ee..2a6e8d493f664926ab4d5e7c5a90d2bcd3b8a792 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -11,6 +11,14 @@ const api = axios.create({ }, }); +// Instance pour les requêtes publiques (sans token) +export const publicApi = axios.create({ + baseURL: API_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); + // Intercepteur pour ajouter le token JWT à chaque requête api.interceptors.request.use( (config) => { @@ -30,21 +38,21 @@ api.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config; - + // Si erreur 401 (Non autorisé) et qu'on n'a pas déjà essayé de rafraîchir if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; - + try { const refreshToken = localStorage.getItem('refresh_token'); if (refreshToken) { const response = await axios.post(`${API_URL}token/refresh/`, { refresh: refreshToken }); - + const { access } = response.data; localStorage.setItem('access_token', access); - + // Réessayer la requête originale avec le nouveau token originalRequest.headers['Authorization'] = `Bearer ${access}`; return api(originalRequest); @@ -53,7 +61,8 @@ api.interceptors.response.use( // Si le refresh échoue, on déconnecte l'utilisateur localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); - window.location.href = '/login'; + // Ne pas rediriger automatiquement ici pour éviter les boucles sur les pages publiques + // window.location.href = '/login'; } } return Promise.reject(error); @@ -61,3 +70,4 @@ api.interceptors.response.use( ); export default api; + diff --git a/frontend/services/bookings.ts b/frontend/services/bookings.ts index 57b3990e3d4517e2ef4330d1c24d5f1749a6e8e4..963b9cc6b6bd86e2ee27fcb9856f1a8ea7ef4da8 100644 --- a/frontend/services/bookings.ts +++ b/frontend/services/bookings.ts @@ -11,9 +11,10 @@ export interface Booking { date: string; time: string; status: 'PENDING' | 'CONFIRMED' | 'REJECTED' | 'COMPLETED' | 'CANCELLED'; - domains?: string[]; - expectation?: string; - main_question?: string; + status_label: string; + domains: string[]; + expectation: string; + main_question: string; created_at: string; } @@ -26,28 +27,39 @@ const getAvatarUrl = (avatar: string | null | undefined, name: string) => { }; // Helper to map backend booking to frontend booking -const mapBooking = (b: any): Booking => ({ - id: b.id ? b.id.toString() : '', - student: { - ...b.student, - id: b.student?.id ? b.student.id.toString() : '', - name: b.student?.profile?.name || 'Étudiant', - avatar: getAvatarUrl(b.student?.profile?.avatar, b.student?.profile?.name), - }, - mentor: { - ...b.mentor, - id: b.mentor?.id ? b.mentor.id.toString() : '', - name: b.mentor?.profile?.name || 'Mentor', - avatar: getAvatarUrl(b.mentor?.profile?.avatar, b.mentor?.profile?.name), - }, - date: b.date, - time: b.time, - status: b.status, - domains: Array.isArray(b.domains) ? b.domains : [], - expectation: b.expectation || '', - main_question: b.main_question || '', - created_at: b.created_at -}); +const mapBooking = (b: any): Booking => { + const statusLabels: Record = { + 'PENDING': 'En attente', + 'CONFIRMED': 'Confirmé', + 'REJECTED': 'Refusé', + 'COMPLETED': 'Terminé', + 'CANCELLED': 'Annulé' + }; + + return { + id: b.id ? b.id.toString() : '', + student: { + ...b.student, + id: b.student?.id ? b.student.id.toString() : '', + name: b.student?.profile?.name || 'Étudiant', + avatar: getAvatarUrl(b.student?.profile?.avatar, b.student?.profile?.name), + }, + mentor: { + ...b.mentor, + id: b.mentor?.id ? b.mentor.id.toString() : '', + name: b.mentor?.profile?.name || 'Mentor', + avatar: getAvatarUrl(b.mentor?.profile?.avatar, b.mentor?.profile?.name), + }, + date: b.date, + time: b.time, + status: b.status, + status_label: statusLabels[b.status] || b.status, + domains: Array.isArray(b.domains) ? b.domains : [], + expectation: b.expectation || '', + main_question: b.main_question || '', + created_at: b.created_at + }; +}; export const bookingsService = { // Récupérer mes réservations diff --git a/frontend/services/encryption.ts b/frontend/services/encryption.ts index 0e990268a2742e2b7973f7c5e89efe0c83430d46..985f8521594fe4c618a027f3e33e3114f405df1d 100644 --- a/frontend/services/encryption.ts +++ b/frontend/services/encryption.ts @@ -41,24 +41,49 @@ export const encryptionService = { return null; }, - // Initialiser le chiffrement (Générer si inexistant, et uploader la clé publique) + // Initialiser le chiffrement (Utiliser les clés du backend ou générer si nécessaire) initializeEncryption: async (): Promise => { + // 1. Vérifier si on a des clés en localStorage let keys = encryptionService.getLocalKeys(); - if (!keys) { + if (keys) { + return keys; + } - keys = await encryptionService.generateKeyPair(); - encryptionService.saveKeysLocally(keys); + // 2. Récupérer l'utilisateur pour voir s'il a des clés sur le backend + try { + const user = await authService.getCurrentUser() as any; + + if (user.profile?.public_key && user.profile?.encrypted_private_key) { + // Utiliser les clés du backend + keys = { + publicKey: user.profile.public_key, + privateKey: user.profile.encrypted_private_key + }; + + // Les sauvegarder localement pour les prochaines fois + encryptionService.saveKeysLocally(keys); + console.log('Clés de chiffrement récupérées du backend'); + return keys; + } + } catch (error) { + console.error('Erreur lors de la récupération des clés du backend', error); + } - // Upload public key to server - try { - await authService.updateProfile({ - public_key: keys.publicKey - }); + // 3. Si pas de clés du tout, générer localement (fallback) + console.warn('Génération de clés côté client (fallback)'); + keys = await encryptionService.generateKeyPair(); + encryptionService.saveKeysLocally(keys); - } catch (error) { - console.error('Erreur lors de l\'envoi de la clé publique', error); - } + // Uploader la clé publique au serveur + try { + await authService.updateProfile({ + public_key: keys.publicKey, + encrypted_private_key: keys.privateKey + }); + console.log('Clés générées et envoyées au backend'); + } catch (error) { + console.error('Erreur lors de l\'envoi des clés au backend', error); } return keys; diff --git a/frontend/services/index.ts b/frontend/services/index.ts index 9c0f811d6add120216eab797cae2a266bda211c8..f7d1c4b47b405c09a73332837078ee6140358de9 100644 --- a/frontend/services/index.ts +++ b/frontend/services/index.ts @@ -5,6 +5,7 @@ export { authService } from './auth'; export { forumService } from './forum'; export { mentorService } from './mentors'; export { bookingsService } from './bookings'; +export type { Booking } from './bookings'; export { opportunityService } from './opportunities'; export { notificationService } from './notifications'; export { messagingService } from './messaging'; diff --git a/frontend/services/messaging.ts b/frontend/services/messaging.ts index 03739c94a7085040f9e0a9f75435259016ba062b..5c0c966829927e9fb586e2a26cae1e99bbab09bc 100644 --- a/frontend/services/messaging.ts +++ b/frontend/services/messaging.ts @@ -38,6 +38,7 @@ export interface Conversation { name: string; avatar: string | null; public_key?: string; + encrypted_private_key?: string; }; }>; last_message: Message | null; @@ -62,7 +63,8 @@ const mapConversation = (c: any): Conversation => ({ profile: { ...p.profile, avatar: getAvatarUrl(p.profile?.avatar, p.profile?.name), - public_key: p.profile?.public_key + public_key: p.profile?.public_key, + encrypted_private_key: p.profile?.encrypted_private_key } })), last_message: c.last_message ? mapMessage(c.last_message) : null diff --git a/frontend/services/opportunities.ts b/frontend/services/opportunities.ts index a6303427486d4768b434e85c7fb22e7b36c86369..393812e0c1f2dd27d4fe001c0fb72e984de0feaa 100644 --- a/frontend/services/opportunities.ts +++ b/frontend/services/opportunities.ts @@ -1,4 +1,4 @@ -import api from './api'; +import api, { publicApi } from './api'; import { Opportunity, OpportunityType } from '../types'; // Service Opportunities @@ -33,7 +33,7 @@ export const opportunityService = { type?: string; search?: string; }): Promise<{ count: number; results: Opportunity[] }> => { - const response = await api.get('opportunities/', { params }); + const response = await publicApi.get('opportunities/', { params }); return { count: response.data.count, results: response.data.results.map(mapOpportunity) @@ -42,7 +42,8 @@ export const opportunityService = { // Récupérer une opportunité par ID getOpportunity: async (id: string): Promise => { - const response = await api.get(`opportunities/${id}/`); + const response = await publicApi.get(`opportunities/${id}/`); return mapOpportunity(response.data); } }; + diff --git a/frontend/services/socials.ts b/frontend/services/socials.ts index c65e84c74a8087845d8dc0f41a177efc90ac4e05..99982442b2d2b019f94292f40c447a439b2bd43d 100644 --- a/frontend/services/socials.ts +++ b/frontend/services/socials.ts @@ -1,5 +1,4 @@ - -import api from './api'; +import api, { publicApi } from './api'; export interface SocialLink { id: number; @@ -12,7 +11,8 @@ export interface SocialLink { export const socialLinkService = { getSocialLinks: async (): Promise => { - const response = await api.get('social-links/'); + const response = await publicApi.get('social-links/'); return response.data.results || response.data; } }; + diff --git a/frontend/services/stats.ts b/frontend/services/stats.ts index aabbb4347e6075efb63350cd127f2338a266470d..ca7d56d6d392b0259552933a8a9acd55192efdcb 100644 --- a/frontend/services/stats.ts +++ b/frontend/services/stats.ts @@ -1,4 +1,4 @@ -import api from './api'; +import api, { publicApi } from './api'; export interface PlatformStats { active_students: number; @@ -21,7 +21,7 @@ export const statsService = { * Récupérer les statistiques de la plateforme */ getPlatformStats: async (): Promise => { - const response = await api.get('stats/'); + const response = await publicApi.get('stats/'); return response.data; }, @@ -29,8 +29,9 @@ export const statsService = { * Récupérer les statistiques d'impact configurables */ getImpactStats: async (): Promise => { - const response = await api.get('impact-stats/'); + const response = await publicApi.get('impact-stats/'); // L'API retourne une liste paginée par défaut avec DRF, donc results return response.data.results || response.data; } }; + diff --git a/frontend/services/testimonials.ts b/frontend/services/testimonials.ts index 09259ea1ad255716aae619e21d47d00ec7457f99..5a4990efd8170191f650eae767996a310d1e713b 100644 --- a/frontend/services/testimonials.ts +++ b/frontend/services/testimonials.ts @@ -1,5 +1,4 @@ - -import api from './api'; +import api, { publicApi } from './api'; export interface Testimonial { id: number; @@ -13,7 +12,8 @@ export interface Testimonial { export const testimonialService = { getTestimonials: async (): Promise => { - const response = await api.get('testimonials/'); + const response = await publicApi.get('testimonials/'); return response.data.results || response.data; } }; + diff --git a/frontend/services/tools.ts b/frontend/services/tools.ts index 615c643d2d6ec45dc934f7e96f97d3f283bfca47..6219a6fcdf0ea108f676c2688a67b0348c7f75d4 100644 --- a/frontend/services/tools.ts +++ b/frontend/services/tools.ts @@ -1,5 +1,4 @@ - -import api from './api'; +import api, { publicApi } from './api'; export interface LearningTool { id: number; @@ -19,7 +18,8 @@ export interface LearningTool { export const toolsService = { getTools: async (): Promise => { - const response = await api.get('learning-tools/'); + const response = await publicApi.get('learning-tools/'); return response.data.results || response.data; } }; + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000000000000000000000000000000000000..f651c6c9f1531a747a37b37247275af470ca294e --- /dev/null +++ b/nginx.conf @@ -0,0 +1,41 @@ +server { + listen 7860; + server_name localhost; + + # Frontend - Fichiers statiques + location / { + root /app/frontend_dist; + index index.html; + try_files $uri $uri/ /index.html; + } + + # Backend API + location /api { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # Django Admin et Static (Backend) + location /admin { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + } + + location /static/ { + alias /app/backend/staticfiles/; + } + + location /media/ { + alias /app/backend/media/; + } + + # WebSockets + location /ws { + proxy_pass http://127.0.0.1:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } +} diff --git a/start.sh b/start.sh new file mode 100644 index 0000000000000000000000000000000000000000..9e408b487db25ad5e024618f676ad9746111ea91 --- /dev/null +++ b/start.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Aller dans le dossier backend +cd /app/backend + +# Appliquer les migrations +echo "Applying migrations..." +python manage.py migrate --noinput + +# Collecter les fichiers statiques de Django (Admin, etc.) +echo "Collecting static files..." +python manage.py collectstatic --noinput + +# Initialiser les données si nécessaire (optionnel) +# python init_tools.py + +# Lancer supervisor +echo "Starting all services via Supervisor..." +exec supervisord -c /app/supervisord.conf diff --git a/supervisord.conf b/supervisord.conf new file mode 100644 index 0000000000000000000000000000000000000000..1a1cebb3c64a9986ddcd71d9252beaaf9c9c09f5 --- /dev/null +++ b/supervisord.conf @@ -0,0 +1,35 @@ +[supervisord] +nodaemon=true +user=root +logfile=/tmp/supervisord.log +pidfile=/tmp/supervisord.pid + +[program:redis] +command=redis-server --port 6379 +autostart=true +autorestart=true +stdout_logfile=/tmp/redis.stdout.log +stderr_logfile=/tmp/redis.stderr.log + +[program:backend] +command=daphne -b 127.0.0.1 -p 8000 educonnect.asgi:application +directory=/app/backend +autostart=true +autorestart=true +stdout_logfile=/tmp/backend.stdout.log +stderr_logfile=/tmp/backend.stderr.log + +[program:celery] +command=celery -A educonnect worker -l info +directory=/app/backend +autostart=true +autorestart=true +stdout_logfile=/tmp/celery.stdout.log +stderr_logfile=/tmp/celery.stderr.log + +[program:nginx] +command=nginx -g "daemon off;" +autostart=true +autorestart=true +stdout_logfile=/tmp/nginx.stdout.log +stderr_logfile=/tmp/nginx.stderr.log