Spaces:
Sleeping
Sleeping
Commit ·
d42510a
0
Parent(s):
Initial commit: EduLab Backend for Hugging Face Spaces
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.exemple +53 -0
- .env.production +40 -0
- .gitignore +75 -0
- Dockerfile +42 -0
- GUIDE_COMPLET.MD +640 -0
- README.md +194 -0
- STRUCTURE_FILE.MD +508 -0
- apps/ai_tools/__init__.py +0 -0
- apps/ai_tools/admin.py +19 -0
- apps/ai_tools/apps.py +6 -0
- apps/ai_tools/migrations/0001_initial.py +99 -0
- apps/ai_tools/migrations/0002_initial.py +44 -0
- apps/ai_tools/migrations/__init__.py +0 -0
- apps/ai_tools/models.py +47 -0
- apps/ai_tools/serializers.py +21 -0
- apps/ai_tools/tests.py +3 -0
- apps/ai_tools/urls.py +18 -0
- apps/ai_tools/views.py +200 -0
- apps/analytics/__init__.py +2 -0
- apps/analytics/admin.py +35 -0
- apps/analytics/apps.py +7 -0
- apps/analytics/migrations/0001_initial.py +185 -0
- apps/analytics/migrations/__init__.py +0 -0
- apps/analytics/models.py +135 -0
- apps/analytics/serializers.py +24 -0
- apps/analytics/services.py +144 -0
- apps/analytics/tests.py +3 -0
- apps/analytics/urls.py +9 -0
- apps/analytics/views.py +117 -0
- apps/bookings/__init__.py +0 -0
- apps/bookings/admin.py +29 -0
- apps/bookings/apps.py +9 -0
- apps/bookings/migrations/0001_initial.py +150 -0
- apps/bookings/migrations/0002_initial.py +89 -0
- apps/bookings/migrations/__init__.py +0 -0
- apps/bookings/models.py +69 -0
- apps/bookings/serializers.py +142 -0
- apps/bookings/signals.py +58 -0
- apps/bookings/tasks.py +206 -0
- apps/bookings/tests.py +3 -0
- apps/bookings/urls.py +23 -0
- apps/bookings/views.py +102 -0
- apps/core/__init__.py +0 -0
- apps/core/admin.py +27 -0
- apps/core/apps.py +9 -0
- apps/core/exceptions.py +60 -0
- apps/core/management/commands/create_test_data.py +162 -0
- apps/core/management/commands/init_data.py +327 -0
- apps/core/management/commands/init_db.py +14 -0
- apps/core/middleware.py +38 -0
.env.exemple
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
==============================================
|
| 3 |
+
FICHIERS DE CONFIGURATION
|
| 4 |
+
==============================================
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
# ============================================
|
| 8 |
+
# .env.example
|
| 9 |
+
# ============================================
|
| 10 |
+
|
| 11 |
+
# Django Settings
|
| 12 |
+
SECRET_KEY=your-secret-key-here-change-in-production
|
| 13 |
+
DEBUG=True
|
| 14 |
+
ALLOWED_HOSTS=localhost,127.0.0.1,yourdomain.com
|
| 15 |
+
|
| 16 |
+
# Database
|
| 17 |
+
DB_NAME=educonnect_db
|
| 18 |
+
DB_USER=postgres
|
| 19 |
+
DB_PASSWORD=your_db_password
|
| 20 |
+
DB_HOST=localhost
|
| 21 |
+
DB_PORT=5432
|
| 22 |
+
|
| 23 |
+
# CORS
|
| 24 |
+
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173,https://yourdomain.com
|
| 25 |
+
|
| 26 |
+
# Redis (pour Celery et Channels)
|
| 27 |
+
REDIS_HOST=localhost
|
| 28 |
+
CELERY_BROKER_URL=redis://localhost:6379/0
|
| 29 |
+
CELERY_RESULT_BACKEND=redis://localhost:6379/0
|
| 30 |
+
|
| 31 |
+
# Email Configuration
|
| 32 |
+
EMAIL_HOST=smtp.gmail.com
|
| 33 |
+
EMAIL_PORT=587
|
| 34 |
+
EMAIL_HOST_USER=your-email@gmail.com
|
| 35 |
+
EMAIL_HOST_PASSWORD=your-email-password
|
| 36 |
+
DEFAULT_FROM_EMAIL=noreply@educonnect.africa
|
| 37 |
+
|
| 38 |
+
# AI APIs
|
| 39 |
+
GEMINI_API_KEY=your-gemini-api-key
|
| 40 |
+
OPENAI_API_KEY=your-openai-api-key
|
| 41 |
+
|
| 42 |
+
# Logging
|
| 43 |
+
DJANGO_LOG_LEVEL=INFO
|
| 44 |
+
|
| 45 |
+
# AWS S3 (si utilisé pour les fichiers)
|
| 46 |
+
AWS_ACCESS_KEY_ID=
|
| 47 |
+
AWS_SECRET_ACCESS_KEY=
|
| 48 |
+
AWS_STORAGE_BUCKET_NAME=
|
| 49 |
+
AWS_S3_REGION_NAME=
|
| 50 |
+
|
| 51 |
+
# Sentry (Monitoring - optionnel)
|
| 52 |
+
SENTRY_DSN=
|
| 53 |
+
|
.env.production
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ==============================================
|
| 2 |
+
# CONFIGURATION PRODUCTION - HUGGING FACE SPACES
|
| 3 |
+
# ==============================================
|
| 4 |
+
|
| 5 |
+
# Django Settings
|
| 6 |
+
DEBUG=False
|
| 7 |
+
SECRET_KEY=django-insecure-xdNTqmUzzdJbmHZ8fLr6B6v1NfShmDZM
|
| 8 |
+
ALLOWED_HOSTS=localhost,127.0.0.1,.hf.space
|
| 9 |
+
|
| 10 |
+
# CORS - Autoriser le frontend Hugging Face
|
| 11 |
+
CORS_ALLOWED_ORIGINS=https://rinogeek-edulabfrontend.hf.space
|
| 12 |
+
|
| 13 |
+
# Database - SQLite par défaut (pas de DB_HOST = utilise SQLite)
|
| 14 |
+
# Pour PostgreSQL, décommentez et configurez :
|
| 15 |
+
# DB_NAME=educonnect_db
|
| 16 |
+
# DB_USER=postgres
|
| 17 |
+
# DB_PASSWORD=your_db_password
|
| 18 |
+
# DB_HOST=your_db_host
|
| 19 |
+
# DB_PORT=5432
|
| 20 |
+
|
| 21 |
+
# Redis (pour Celery et Channels) - Désactivé sur HF Spaces gratuit
|
| 22 |
+
# REDIS_HOST=localhost
|
| 23 |
+
# CELERY_BROKER_URL=redis://localhost:6379/0
|
| 24 |
+
# CELERY_RESULT_BACKEND=redis://localhost:6379/0
|
| 25 |
+
|
| 26 |
+
# Email Configuration
|
| 27 |
+
EMAIL_HOST=smtp.gmail.com
|
| 28 |
+
EMAIL_PORT=587
|
| 29 |
+
EMAIL_HOST_USER=
|
| 30 |
+
EMAIL_HOST_PASSWORD=
|
| 31 |
+
DEFAULT_FROM_EMAIL=noreply@edulab.africa
|
| 32 |
+
|
| 33 |
+
# AI APIs
|
| 34 |
+
GEMINI_API_KEY=AIzaSyD-hnYdbQudiK8nfFtML05Mmlo26nLzxWQ
|
| 35 |
+
|
| 36 |
+
# Logging
|
| 37 |
+
DJANGO_LOG_LEVEL=INFO
|
| 38 |
+
|
| 39 |
+
# Security - HF gère SSL au niveau proxy
|
| 40 |
+
SECURE_SSL_REDIRECT=False
|
.gitignore
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / optimized / DLL files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# C extensions
|
| 7 |
+
*.so
|
| 8 |
+
|
| 9 |
+
# Distribution / packaging
|
| 10 |
+
.Python
|
| 11 |
+
build/
|
| 12 |
+
develop-eggs/
|
| 13 |
+
dist/
|
| 14 |
+
downloads/
|
| 15 |
+
eggs/
|
| 16 |
+
.eggs/
|
| 17 |
+
lib/
|
| 18 |
+
lib64/
|
| 19 |
+
parts/
|
| 20 |
+
sdist/
|
| 21 |
+
var/
|
| 22 |
+
wheels/
|
| 23 |
+
*.egg-info/
|
| 24 |
+
.installed.cfg
|
| 25 |
+
*.egg
|
| 26 |
+
|
| 27 |
+
# Django stuff:
|
| 28 |
+
*.log
|
| 29 |
+
*.pot
|
| 30 |
+
*.pyc
|
| 31 |
+
local_settings.py
|
| 32 |
+
db.sqlite3
|
| 33 |
+
db.sqlite3-journal
|
| 34 |
+
|
| 35 |
+
# Media files
|
| 36 |
+
media/
|
| 37 |
+
|
| 38 |
+
# Static files (collectstatic output)
|
| 39 |
+
staticfiles/
|
| 40 |
+
|
| 41 |
+
# Environment
|
| 42 |
+
.env
|
| 43 |
+
.env.local
|
| 44 |
+
venv/
|
| 45 |
+
ENV/
|
| 46 |
+
env/
|
| 47 |
+
.venv/
|
| 48 |
+
|
| 49 |
+
# IDE
|
| 50 |
+
.idea/
|
| 51 |
+
.vscode/
|
| 52 |
+
*.swp
|
| 53 |
+
*.swo
|
| 54 |
+
|
| 55 |
+
# Logs
|
| 56 |
+
logs/
|
| 57 |
+
*.log
|
| 58 |
+
|
| 59 |
+
# OS
|
| 60 |
+
.DS_Store
|
| 61 |
+
Thumbs.db
|
| 62 |
+
|
| 63 |
+
# Celery
|
| 64 |
+
celerybeat-schedule
|
| 65 |
+
celerybeat.pid
|
| 66 |
+
|
| 67 |
+
# Coverage
|
| 68 |
+
.coverage
|
| 69 |
+
htmlcov/
|
| 70 |
+
|
| 71 |
+
# pytest
|
| 72 |
+
.pytest_cache/
|
| 73 |
+
|
| 74 |
+
# mypy
|
| 75 |
+
.mypy_cache/
|
Dockerfile
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONUNBUFFERED=1
|
| 4 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 5 |
+
|
| 6 |
+
WORKDIR /app
|
| 7 |
+
|
| 8 |
+
# Installer les dépendances système
|
| 9 |
+
RUN apt-get update && apt-get install -y \
|
| 10 |
+
postgresql-client \
|
| 11 |
+
gcc \
|
| 12 |
+
libpq-dev \
|
| 13 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 14 |
+
|
| 15 |
+
# Créer un utilisateur non-root pour Hugging Face Spaces (UID 1000)
|
| 16 |
+
RUN useradd -m -u 1000 user
|
| 17 |
+
USER user
|
| 18 |
+
ENV PATH="/home/user/.local/bin:${PATH}"
|
| 19 |
+
|
| 20 |
+
# Définir le répertoire de travail et s'assurer des permissions
|
| 21 |
+
WORKDIR /app
|
| 22 |
+
COPY --chown=user:user requirements.txt /app/
|
| 23 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 24 |
+
pip install --no-cache-dir -r requirements.txt
|
| 25 |
+
|
| 26 |
+
# Copier le reste du projet avec les bonnes permissions
|
| 27 |
+
COPY --chown=user:user . /app/
|
| 28 |
+
|
| 29 |
+
# Créer les dossiers nécessaires et s'assurer des permissions
|
| 30 |
+
RUN mkdir -p /app/logs /app/media /app/staticfiles
|
| 31 |
+
|
| 32 |
+
# Copier le fichier .env.production comme .env pour la production
|
| 33 |
+
RUN cp /app/.env.production /app/.env || true
|
| 34 |
+
|
| 35 |
+
# Collecter les fichiers statiques
|
| 36 |
+
RUN python manage.py collectstatic --noinput
|
| 37 |
+
|
| 38 |
+
# Exposer le port par défaut de Hugging Face Spaces
|
| 39 |
+
EXPOSE 7860
|
| 40 |
+
|
| 41 |
+
# Démarrer avec gunicorn
|
| 42 |
+
CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "4", "educonnect.wsgi:application"]
|
GUIDE_COMPLET.MD
ADDED
|
@@ -0,0 +1,640 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 📘 GUIDE COMPLET - EduConnect Africa API
|
| 2 |
+
|
| 3 |
+
## 🎯 Vue d'Ensemble
|
| 4 |
+
|
| 5 |
+
API REST Django complète pour EduConnect Africa avec traçabilité maximale, soft delete, et versioning des données.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 📁 Architecture du Projet
|
| 10 |
+
|
| 11 |
+
```
|
| 12 |
+
educonnect-api/
|
| 13 |
+
│
|
| 14 |
+
├── educonnect_api/ # Configuration Django
|
| 15 |
+
│ ├── __init__.py
|
| 16 |
+
│ ├── settings.py # Configuration complète
|
| 17 |
+
│ ├── urls.py # Routes principales
|
| 18 |
+
│ ├── asgi.py # Configuration ASGI (WebSockets)
|
| 19 |
+
│ ├── wsgi.py # Configuration WSGI
|
| 20 |
+
│ └── celery.py # Configuration Celery
|
| 21 |
+
│
|
| 22 |
+
├── apps/ # Applications Django
|
| 23 |
+
│ │
|
| 24 |
+
│ ├── core/ # Utilitaires & Base
|
| 25 |
+
│ │ ├── models.py # Mixins (Timestamp, SoftDelete, Versioned)
|
| 26 |
+
│ │ ├── permissions.py # Permissions personnalisées
|
| 27 |
+
│ │ ├── exceptions.py # Gestionnaire d'exceptions
|
| 28 |
+
│ │ ├── middleware.py # Middleware d'activité
|
| 29 |
+
│ │ └── management/
|
| 30 |
+
│ │ └── commands/
|
| 31 |
+
│ │ ├── init_db.py # Initialisation DB
|
| 32 |
+
│ │ └── create_test_data.py # Données de test
|
| 33 |
+
│ │
|
| 34 |
+
│ ├── users/ # Authentification & Utilisateurs
|
| 35 |
+
│ │ ├── models.py # User, UserProfile, UserAvatar, etc.
|
| 36 |
+
│ │ ├── serializers.py # User, Registration, Login, Update
|
| 37 |
+
│ │ ├── views.py # AuthViewSet
|
| 38 |
+
│ │ ├── urls.py
|
| 39 |
+
│ │ └── tests.py
|
| 40 |
+
│ │
|
| 41 |
+
│ ├── mentors/ # Gestion Mentors
|
| 42 |
+
│ │ ├── models.py # MentorProfile, Bio, Specialties, etc.
|
| 43 |
+
│ │ ├── serializers.py # Mentor CRUD
|
| 44 |
+
│ │ ├── views.py # MentorViewSet
|
| 45 |
+
│ │ ├── urls.py
|
| 46 |
+
│ │ └── tests.py
|
| 47 |
+
│ │
|
| 48 |
+
│ ├── bookings/ # Système de Réservation
|
| 49 |
+
│ │ ├── models.py # Booking, Domains, Expectations, etc.
|
| 50 |
+
│ │ ├── serializers.py # Booking CRUD
|
| 51 |
+
│ │ ├── views.py # BookingViewSet
|
| 52 |
+
│ │ ├── urls.py
|
| 53 |
+
│ │ └── tests.py
|
| 54 |
+
│ │
|
| 55 |
+
│ ├── forum/ # Questions & Réponses
|
| 56 |
+
│ │ ├── models.py # Question, Answer (versionné)
|
| 57 |
+
│ │ ├── serializers.py # Forum CRUD
|
| 58 |
+
│ │ ├── views.py # QuestionViewSet, AnswerViewSet
|
| 59 |
+
│ │ ├── urls.py
|
| 60 |
+
│ │ └── tests.py
|
| 61 |
+
│ │
|
| 62 |
+
│ ├── gamification/ # Points & Badges
|
| 63 |
+
│ │ ├── models.py # Badge, UserBadge, PointsHistory
|
| 64 |
+
│ │ ├── serializers.py # Gamification
|
| 65 |
+
│ │ ├── views.py # GamificationViewSet
|
| 66 |
+
│ │ ├── services.py # BadgeService (logique métier)
|
| 67 |
+
│ │ ├── urls.py
|
| 68 |
+
│ │ └── tests.py
|
| 69 |
+
│ │
|
| 70 |
+
│ ├── messaging/ # Chat Temps Réel
|
| 71 |
+
│ │ ├── models.py # Conversation, Message
|
| 72 |
+
│ │ ├── serializers.py # Messaging
|
| 73 |
+
│ │ ├── views.py # ConversationViewSet
|
| 74 |
+
│ │ ├── consumers.py # WebSocket Consumer
|
| 75 |
+
│ │ ├── urls.py
|
| 76 |
+
│ │ └── tests.py
|
| 77 |
+
│ │
|
| 78 |
+
│ ├── notifications/ # Système de Notifications
|
| 79 |
+
│ │ ├── models.py # Notification
|
| 80 |
+
│ │ ├── serializers.py # Notification
|
| 81 |
+
│ │ ├── views.py # NotificationViewSet
|
| 82 |
+
│ │ ├── services.py # NotificationService
|
| 83 |
+
│ │ ├── urls.py
|
| 84 |
+
│ │ └── tests.py
|
| 85 |
+
│ │
|
| 86 |
+
│ ├── opportunities/ # Opportunités (Bourses, Stages)
|
| 87 |
+
│ │ ├── models.py # Opportunity
|
| 88 |
+
│ │ ├── serializers.py # Opportunity
|
| 89 |
+
│ │ ├── views.py # OpportunityViewSet
|
| 90 |
+
│ │ ├── urls.py
|
| 91 |
+
│ │ └── tests.py
|
| 92 |
+
│ │
|
| 93 |
+
│ ├── ai_tools/ # Tuteur IA
|
| 94 |
+
│ │ ├── models.py # AITutorSession, CodeSnippet
|
| 95 |
+
│ │ ├── serializers.py # AI Request/Response
|
| 96 |
+
│ │ ├── views.py # AIToolsViewSet
|
| 97 |
+
│ │ ├── urls.py
|
| 98 |
+
│ │ └── tests.py
|
| 99 |
+
│ │
|
| 100 |
+
│ └── analytics/ # Analytics (Optionnel)
|
| 101 |
+
│ ├── models.py # UserActivity, SystemMetric
|
| 102 |
+
│ └── views.py # AnalyticsViewSet
|
| 103 |
+
│
|
| 104 |
+
├── logs/ # Logs application
|
| 105 |
+
├── media/ # Fichiers uploadés
|
| 106 |
+
├── staticfiles/ # Fichiers statiques collectés
|
| 107 |
+
├── templates/ # Templates Django
|
| 108 |
+
│
|
| 109 |
+
├── requirements.txt # Dépendances Python
|
| 110 |
+
├── pytest.ini # Configuration pytest
|
| 111 |
+
├── conftest.py # Fixtures pytest
|
| 112 |
+
├── .env.example # Variables d'environnement
|
| 113 |
+
├── Dockerfile # Docker image
|
| 114 |
+
├── docker-compose.yml # Docker services
|
| 115 |
+
├── deploy.sh # Script de déploiement
|
| 116 |
+
├── manage.py # CLI Django
|
| 117 |
+
└── README.md # Documentation
|
| 118 |
+
```
|
| 119 |
+
|
| 120 |
+
---
|
| 121 |
+
|
| 122 |
+
## 🚀 Installation & Démarrage
|
| 123 |
+
|
| 124 |
+
### Option 1: Installation Locale
|
| 125 |
+
|
| 126 |
+
```bash
|
| 127 |
+
# 1. Cloner le projet
|
| 128 |
+
git clone <repo-url>
|
| 129 |
+
cd educonnect-api
|
| 130 |
+
|
| 131 |
+
# 2. Environnement virtuel
|
| 132 |
+
python3.11 -m venv venv
|
| 133 |
+
source venv/bin/activate # Linux/Mac
|
| 134 |
+
# venv\Scripts\activate # Windows
|
| 135 |
+
|
| 136 |
+
# 3. Installer les dépendances
|
| 137 |
+
pip install -r requirements.txt
|
| 138 |
+
|
| 139 |
+
# 4. Configuration
|
| 140 |
+
cp .env.example .env
|
| 141 |
+
# Éditer .env avec vos paramètres
|
| 142 |
+
|
| 143 |
+
# 5. Base de données PostgreSQL
|
| 144 |
+
createdb educonnect_db
|
| 145 |
+
|
| 146 |
+
# 6. Migrations
|
| 147 |
+
python manage.py makemigrations
|
| 148 |
+
python manage.py migrate
|
| 149 |
+
|
| 150 |
+
# 7. Initialiser les données
|
| 151 |
+
python manage.py init_db
|
| 152 |
+
|
| 153 |
+
# 8. Créer un superuser
|
| 154 |
+
python manage.py createsuperuser
|
| 155 |
+
|
| 156 |
+
# 9. (Optionnel) Données de test
|
| 157 |
+
python manage.py create_test_data --users 50
|
| 158 |
+
|
| 159 |
+
# 10. Démarrer le serveur
|
| 160 |
+
python manage.py runserver
|
| 161 |
+
|
| 162 |
+
# 11. Démarrer Celery (dans un autre terminal)
|
| 163 |
+
celery -A educonnect_api worker -l info
|
| 164 |
+
|
| 165 |
+
# 12. Démarrer Channels/Daphne (dans un autre terminal)
|
| 166 |
+
daphne -b 0.0.0.0 -p 8001 educonnect_api.asgi:application
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
+
### Option 2: Avec Docker
|
| 170 |
+
|
| 171 |
+
```bash
|
| 172 |
+
# Build et démarrer tous les services
|
| 173 |
+
docker-compose up --build
|
| 174 |
+
|
| 175 |
+
# Migrations
|
| 176 |
+
docker-compose exec web python manage.py migrate
|
| 177 |
+
|
| 178 |
+
# Initialiser
|
| 179 |
+
docker-compose exec web python manage.py init_db
|
| 180 |
+
|
| 181 |
+
# Créer superuser
|
| 182 |
+
docker-compose exec web python manage.py createsuperuser
|
| 183 |
+
|
| 184 |
+
# Voir les logs
|
| 185 |
+
docker-compose logs -f web
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
---
|
| 189 |
+
|
| 190 |
+
## 📚 Endpoints API Complets
|
| 191 |
+
|
| 192 |
+
### 🔐 Authentification `/api/auth/`
|
| 193 |
+
|
| 194 |
+
| Méthode | Endpoint | Description | Auth |
|
| 195 |
+
|---------|----------|-------------|------|
|
| 196 |
+
| POST | `/register/` | Inscription utilisateur | Non |
|
| 197 |
+
| POST | `/login/` | Connexion | Non |
|
| 198 |
+
| POST | `/token/refresh/` | Rafraîchir token | Non |
|
| 199 |
+
| GET | `/me/` | Profil actuel | Oui |
|
| 200 |
+
| PATCH | `/update_profile/` | Mise à jour profil | Oui |
|
| 201 |
+
|
| 202 |
+
**Exemple Inscription:**
|
| 203 |
+
```json
|
| 204 |
+
POST /api/auth/register/
|
| 205 |
+
{
|
| 206 |
+
"email": "student@educonnect.africa",
|
| 207 |
+
"password": "SecurePass123!",
|
| 208 |
+
"password_confirm": "SecurePass123!",
|
| 209 |
+
"name": "Jean Dupont",
|
| 210 |
+
"role": "STUDENT",
|
| 211 |
+
"country": "Bénin",
|
| 212 |
+
"university": "Université d'Abomey-Calavi"
|
| 213 |
+
}
|
| 214 |
+
```
|
| 215 |
+
|
| 216 |
+
**Réponse:**
|
| 217 |
+
```json
|
| 218 |
+
{
|
| 219 |
+
"user": {
|
| 220 |
+
"id": 1,
|
| 221 |
+
"email": "student@educonnect.africa",
|
| 222 |
+
"role": "STUDENT",
|
| 223 |
+
"points": 0,
|
| 224 |
+
"profile": {
|
| 225 |
+
"name": "Jean Dupont",
|
| 226 |
+
"avatar": null,
|
| 227 |
+
"country": "Bénin",
|
| 228 |
+
"university": "Université d'Abomey-Calavi"
|
| 229 |
+
}
|
| 230 |
+
},
|
| 231 |
+
"tokens": {
|
| 232 |
+
"refresh": "eyJ0eXAiOiJKV1QiLCJhbGc...",
|
| 233 |
+
"access": "eyJ0eXAiOiJKV1QiLCJhbGc..."
|
| 234 |
+
}
|
| 235 |
+
}
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
---
|
| 239 |
+
|
| 240 |
+
### 👨🏫 Mentors `/api/mentors/`
|
| 241 |
+
|
| 242 |
+
| Méthode | Endpoint | Description | Filtres |
|
| 243 |
+
|---------|----------|-------------|---------|
|
| 244 |
+
| GET | `/` | Liste mentors | ?search=, ?country=, ?specialty=, ?ordering= |
|
| 245 |
+
| GET | `/{id}/` | Détail mentor | - |
|
| 246 |
+
| GET | `/my_profile/` | Mon profil mentor | - |
|
| 247 |
+
| PATCH | `/my_profile/` | Mettre à jour | - |
|
| 248 |
+
| GET | `/{id}/reviews/` | Avis mentor | - |
|
| 249 |
+
|
| 250 |
+
**Exemple Liste:**
|
| 251 |
+
```bash
|
| 252 |
+
GET /api/mentors/?country=Bénin&specialty=Informatique&ordering=-rating
|
| 253 |
+
```
|
| 254 |
+
|
| 255 |
+
---
|
| 256 |
+
|
| 257 |
+
### 📅 Réservations `/api/bookings/`
|
| 258 |
+
|
| 259 |
+
| Méthode | Endpoint | Description |
|
| 260 |
+
|---------|----------|-------------|
|
| 261 |
+
| GET | `/` | Mes réservations |
|
| 262 |
+
| POST | `/` | Créer réservation |
|
| 263 |
+
| GET | `/{id}/` | Détail |
|
| 264 |
+
| PATCH | `/{id}/update_status/` | Accepter/Refuser |
|
| 265 |
+
| GET | `/mentor_requests/` | Demandes reçues (mentor) |
|
| 266 |
+
|
| 267 |
+
**Exemple Création:**
|
| 268 |
+
```json
|
| 269 |
+
POST /api/bookings/
|
| 270 |
+
{
|
| 271 |
+
"mentor_id": 5,
|
| 272 |
+
"date": "2025-12-15",
|
| 273 |
+
"time": "14:00:00",
|
| 274 |
+
"domains": ["Informatique", "Programmation"],
|
| 275 |
+
"expectations": "Je souhaite apprendre les bases de Django",
|
| 276 |
+
"main_question": "Comment structurer un projet Django professionnel?"
|
| 277 |
+
}
|
| 278 |
+
```
|
| 279 |
+
|
| 280 |
+
---
|
| 281 |
+
|
| 282 |
+
### 💬 Forum `/api/forum/`
|
| 283 |
+
|
| 284 |
+
#### Questions
|
| 285 |
+
|
| 286 |
+
| Méthode | Endpoint | Description | Filtres |
|
| 287 |
+
|---------|----------|-------------|---------|
|
| 288 |
+
| GET | `/questions/` | Liste | ?search=, ?filter=solved/unsolved, ?tag= |
|
| 289 |
+
| POST | `/questions/` | Créer | - |
|
| 290 |
+
| GET | `/questions/{id}/` | Détail | - |
|
| 291 |
+
| PATCH | `/questions/{id}/` | Modifier | - |
|
| 292 |
+
| DELETE | `/questions/{id}/` | Supprimer (soft) | - |
|
| 293 |
+
| POST | `/questions/{id}/vote/` | Voter | - |
|
| 294 |
+
| GET | `/questions/{id}/answers/` | Réponses | - |
|
| 295 |
+
| POST | `/questions/{id}/answers/` | Répondre | - |
|
| 296 |
+
|
| 297 |
+
**Exemple Création Question:**
|
| 298 |
+
```json
|
| 299 |
+
POST /api/forum/questions/
|
| 300 |
+
{
|
| 301 |
+
"title": "Comment calculer une intégrale triple?",
|
| 302 |
+
"content": "Je bloque sur cet exercice: ∫∫∫ xyz dV...",
|
| 303 |
+
"tags": ["mathématiques", "intégrales", "calcul"]
|
| 304 |
+
}
|
| 305 |
+
```
|
| 306 |
+
|
| 307 |
+
#### Réponses
|
| 308 |
+
|
| 309 |
+
| Méthode | Endpoint | Description |
|
| 310 |
+
|---------|----------|-------------|
|
| 311 |
+
| POST | `/answers/{id}/vote/` | Voter |
|
| 312 |
+
| POST | `/answers/{id}/accept/` | Accepter (auteur question) |
|
| 313 |
+
|
| 314 |
+
---
|
| 315 |
+
|
| 316 |
+
### 🎮 Gamification `/api/gamification/`
|
| 317 |
+
|
| 318 |
+
| Méthode | Endpoint | Description |
|
| 319 |
+
|---------|----------|-------------|
|
| 320 |
+
| GET | `/leaderboard/` | Classement |
|
| 321 |
+
| GET | `/my_badges/` | Mes badges |
|
| 322 |
+
| GET | `/all_badges/` | Tous les badges |
|
| 323 |
+
| GET | `/points_history/` | Historique points |
|
| 324 |
+
| GET | `/stats/` | Statistiques perso |
|
| 325 |
+
|
| 326 |
+
**Système de Points:**
|
| 327 |
+
- Question postée: **+10 points**
|
| 328 |
+
- Réponse postée: **+15 points**
|
| 329 |
+
- Réponse acceptée: **+50 points**
|
| 330 |
+
- Upvote reçu: **+5 points**
|
| 331 |
+
- Devenir mentor: **+100 points**
|
| 332 |
+
- Session complétée: **+25 points**
|
| 333 |
+
|
| 334 |
+
**Badges Disponibles:**
|
| 335 |
+
1. 🌱 **Premier Pas** - Première question
|
| 336 |
+
2. 🔍 **Curieux** - 100 points
|
| 337 |
+
3. ⭐ **Engagé** - 500 points
|
| 338 |
+
4. 🏆 **Expert** - 1000 points
|
| 339 |
+
5. 👑 **Maître** - 2500 points
|
| 340 |
+
6. 💎 **Légende** - 5000 points
|
| 341 |
+
|
| 342 |
+
---
|
| 343 |
+
|
| 344 |
+
### 💬 Messagerie `/api/messages/`
|
| 345 |
+
|
| 346 |
+
| Méthode | Endpoint | Description |
|
| 347 |
+
|---------|----------|-------------|
|
| 348 |
+
| GET | `/conversations/` | Mes conversations |
|
| 349 |
+
| POST | `/conversations/` | Créer conversation |
|
| 350 |
+
| GET | `/conversations/{id}/` | Détail |
|
| 351 |
+
| GET | `/conversations/{id}/messages/` | Messages |
|
| 352 |
+
| POST | `/conversations/{id}/send_message/` | Envoyer |
|
| 353 |
+
|
| 354 |
+
**WebSocket (Temps Réel):**
|
| 355 |
+
```javascript
|
| 356 |
+
ws://localhost:8001/ws/chat/{conversation_id}/
|
| 357 |
+
```
|
| 358 |
+
|
| 359 |
+
---
|
| 360 |
+
|
| 361 |
+
### 🔔 Notifications `/api/notifications/`
|
| 362 |
+
|
| 363 |
+
| Méthode | Endpoint | Description |
|
| 364 |
+
|---------|----------|-------------|
|
| 365 |
+
| GET | `/` | Mes notifications |
|
| 366 |
+
| GET | `/{id}/` | Détail |
|
| 367 |
+
| POST | `/mark_all_read/` | Tout marquer lu |
|
| 368 |
+
| POST | `/{id}/mark_read/` | Marquer lu |
|
| 369 |
+
| DELETE | `/{id}/delete_notification/` | Supprimer |
|
| 370 |
+
| GET | `/unread_count/` | Nombre non lues |
|
| 371 |
+
|
| 372 |
+
---
|
| 373 |
+
|
| 374 |
+
### 🎓 Opportunités `/api/opportunities/`
|
| 375 |
+
|
| 376 |
+
| Méthode | Endpoint | Description | Filtres |
|
| 377 |
+
|---------|----------|-------------|---------|
|
| 378 |
+
| GET | `/` | Liste | ?type=SCHOLARSHIP/CONTEST/INTERNSHIP/TRAINING |
|
| 379 |
+
| GET | `/{id}/` | Détail | - |
|
| 380 |
+
|
| 381 |
+
---
|
| 382 |
+
|
| 383 |
+
### 🤖 IA `/api/ai/`
|
| 384 |
+
|
| 385 |
+
| Méthode | Endpoint | Description |
|
| 386 |
+
|---------|----------|-------------|
|
| 387 |
+
| POST | `/tutor/` | Tuteur IA |
|
| 388 |
+
|
| 389 |
+
**Exemple:**
|
| 390 |
+
```json
|
| 391 |
+
POST /api/ai/tutor/
|
| 392 |
+
{
|
| 393 |
+
"question": "Peux-tu m'expliquer le théorème de Pythagore?",
|
| 394 |
+
"subject": "Mathématiques",
|
| 395 |
+
"level": "Lycée"
|
| 396 |
+
}
|
| 397 |
+
```
|
| 398 |
+
|
| 399 |
+
---
|
| 400 |
+
|
| 401 |
+
## 🧪 Tests
|
| 402 |
+
|
| 403 |
+
```bash
|
| 404 |
+
# Lancer tous les tests
|
| 405 |
+
pytest
|
| 406 |
+
|
| 407 |
+
# Tests avec coverage
|
| 408 |
+
pytest --cov=apps --cov-report=html
|
| 409 |
+
|
| 410 |
+
# Tests spécifiques
|
| 411 |
+
pytest apps/users/tests.py
|
| 412 |
+
pytest apps/forum/tests.py::TestForum::test_create_question
|
| 413 |
+
|
| 414 |
+
# Tests verbeux
|
| 415 |
+
pytest -v
|
| 416 |
+
|
| 417 |
+
# Voir la sortie print
|
| 418 |
+
pytest -s
|
| 419 |
+
```
|
| 420 |
+
|
| 421 |
+
---
|
| 422 |
+
|
| 423 |
+
## 🔒 Sécurité
|
| 424 |
+
|
| 425 |
+
### Headers de Sécurité (Production)
|
| 426 |
+
|
| 427 |
+
```python
|
| 428 |
+
SECURE_SSL_REDIRECT = True
|
| 429 |
+
SESSION_COOKIE_SECURE = True
|
| 430 |
+
CSRF_COOKIE_SECURE = True
|
| 431 |
+
SECURE_HSTS_SECONDS = 31536000
|
| 432 |
+
X_FRAME_OPTIONS = 'DENY'
|
| 433 |
+
```
|
| 434 |
+
|
| 435 |
+
### Rate Limiting (À implémenter)
|
| 436 |
+
|
| 437 |
+
```python
|
| 438 |
+
# settings.py
|
| 439 |
+
REST_FRAMEWORK = {
|
| 440 |
+
'DEFAULT_THROTTLE_CLASSES': [
|
| 441 |
+
'rest_framework.throttling.AnonRateThrottle',
|
| 442 |
+
'rest_framework.throttling.UserRateThrottle'
|
| 443 |
+
],
|
| 444 |
+
'DEFAULT_THROTTLE_RATES': {
|
| 445 |
+
'anon': '100/hour',
|
| 446 |
+
'user': '1000/hour'
|
| 447 |
+
}
|
| 448 |
+
}
|
| 449 |
+
```
|
| 450 |
+
|
| 451 |
+
---
|
| 452 |
+
|
| 453 |
+
## 📊 Monitoring & Logs
|
| 454 |
+
|
| 455 |
+
### Logs
|
| 456 |
+
|
| 457 |
+
```bash
|
| 458 |
+
# Logs Django
|
| 459 |
+
tail -f logs/django.log
|
| 460 |
+
|
| 461 |
+
# Logs Gunicorn
|
| 462 |
+
tail -f /var/log/gunicorn/error.log
|
| 463 |
+
|
| 464 |
+
# Logs Nginx
|
| 465 |
+
tail -f /var/log/nginx/educonnect_error.log
|
| 466 |
+
|
| 467 |
+
# Logs Celery
|
| 468 |
+
tail -f /var/log/celery/worker.log
|
| 469 |
+
```
|
| 470 |
+
|
| 471 |
+
### Sentry (Monitoring Erreurs)
|
| 472 |
+
|
| 473 |
+
```python
|
| 474 |
+
# settings.py
|
| 475 |
+
import sentry_sdk
|
| 476 |
+
|
| 477 |
+
sentry_sdk.init(
|
| 478 |
+
dsn="your-sentry-dsn",
|
| 479 |
+
environment="production",
|
| 480 |
+
traces_sample_rate=0.1
|
| 481 |
+
)
|
| 482 |
+
```
|
| 483 |
+
|
| 484 |
+
---
|
| 485 |
+
|
| 486 |
+
## 🚀 Déploiement Production
|
| 487 |
+
|
| 488 |
+
### 1. Préparer le Serveur
|
| 489 |
+
|
| 490 |
+
```bash
|
| 491 |
+
# Installer dépendances système
|
| 492 |
+
sudo apt update
|
| 493 |
+
sudo apt install python3.11 python3.11-venv postgresql redis nginx
|
| 494 |
+
|
| 495 |
+
# Créer utilisateur
|
| 496 |
+
sudo useradd -m -s /bin/bash educonnect
|
| 497 |
+
```
|
| 498 |
+
|
| 499 |
+
### 2. Déployer l'Application
|
| 500 |
+
|
| 501 |
+
```bash
|
| 502 |
+
# Cloner
|
| 503 |
+
cd /var/www
|
| 504 |
+
sudo git clone <repo> educonnect-api
|
| 505 |
+
sudo chown -R educonnect:educonnect educonnect-api
|
| 506 |
+
|
| 507 |
+
# Setup
|
| 508 |
+
cd educonnect-api
|
| 509 |
+
python3.11 -m venv venv
|
| 510 |
+
source venv/bin/activate
|
| 511 |
+
pip install -r requirements.txt
|
| 512 |
+
|
| 513 |
+
# Configuration
|
| 514 |
+
cp .env.example .env
|
| 515 |
+
# Éditer .env avec les vraies valeurs
|
| 516 |
+
|
| 517 |
+
# Migrations
|
| 518 |
+
python manage.py migrate
|
| 519 |
+
python manage.py collectstatic --noinput
|
| 520 |
+
```
|
| 521 |
+
|
| 522 |
+
### 3. Configurer Systemd
|
| 523 |
+
|
| 524 |
+
```bash
|
| 525 |
+
# Copier les fichiers service
|
| 526 |
+
sudo cp systemd/gunicorn.service /etc/systemd/system/
|
| 527 |
+
sudo cp systemd/celery.service /etc/systemd/system/
|
| 528 |
+
|
| 529 |
+
# Activer et démarrer
|
| 530 |
+
sudo systemctl enable gunicorn celery
|
| 531 |
+
sudo systemctl start gunicorn celery
|
| 532 |
+
```
|
| 533 |
+
|
| 534 |
+
### 4. Configurer Nginx
|
| 535 |
+
|
| 536 |
+
```bash
|
| 537 |
+
# Copier la config
|
| 538 |
+
sudo cp nginx/educonnect.conf /etc/nginx/sites-available/
|
| 539 |
+
sudo ln -s /etc/nginx/sites-available/educonnect.conf /etc/nginx/sites-enabled/
|
| 540 |
+
|
| 541 |
+
# Tester et recharger
|
| 542 |
+
sudo nginx -t
|
| 543 |
+
sudo systemctl reload nginx
|
| 544 |
+
```
|
| 545 |
+
|
| 546 |
+
### 5. SSL avec Let's Encrypt
|
| 547 |
+
|
| 548 |
+
```bash
|
| 549 |
+
sudo apt install certbot python3-certbot-nginx
|
| 550 |
+
sudo certbot --nginx -d api.educonnect.africa
|
| 551 |
+
```
|
| 552 |
+
|
| 553 |
+
### 6. Script de Déploiement Automatique
|
| 554 |
+
|
| 555 |
+
```bash
|
| 556 |
+
chmod +x deploy.sh
|
| 557 |
+
sudo ./deploy.sh
|
| 558 |
+
```
|
| 559 |
+
|
| 560 |
+
---
|
| 561 |
+
|
| 562 |
+
## 🔄 Maintenance
|
| 563 |
+
|
| 564 |
+
### Backup Base de Données
|
| 565 |
+
|
| 566 |
+
```bash
|
| 567 |
+
# Backup manuel
|
| 568 |
+
pg_dump educonnect_db > backup_$(date +%Y%m%d).sql
|
| 569 |
+
|
| 570 |
+
# Backup automatique (cron)
|
| 571 |
+
0 2 * * * pg_dump educonnect_db > /var/backups/educonnect/db_$(date +\%Y\%m\%d).sql
|
| 572 |
+
```
|
| 573 |
+
|
| 574 |
+
### Nettoyage
|
| 575 |
+
|
| 576 |
+
```bash
|
| 577 |
+
# Supprimer anciennes versions (soft deleted)
|
| 578 |
+
python manage.py shell
|
| 579 |
+
>>> from django.utils import timezone
|
| 580 |
+
>>> from datetime import timedelta
|
| 581 |
+
>>> cutoff = timezone.now() - timedelta(days=90)
|
| 582 |
+
>>> # Archiver les données anciennes...
|
| 583 |
+
```
|
| 584 |
+
|
| 585 |
+
---
|
| 586 |
+
|
| 587 |
+
## 📖 Documentation Interactive
|
| 588 |
+
|
| 589 |
+
Une fois l'API lancée:
|
| 590 |
+
|
| 591 |
+
- **Swagger UI**: http://localhost:8000/api/docs/
|
| 592 |
+
- **ReDoc**: http://localhost:8000/api/redoc/
|
| 593 |
+
- **Admin Django**: http://localhost:8000/admin/
|
| 594 |
+
|
| 595 |
+
---
|
| 596 |
+
|
| 597 |
+
## 🤝 Contribution
|
| 598 |
+
|
| 599 |
+
1. Fork le projet
|
| 600 |
+
2. Créer une branche feature (`git checkout -b feature/AmazingFeature`)
|
| 601 |
+
3. Commit (`git commit -m 'Add AmazingFeature'`)
|
| 602 |
+
4. Push (`git push origin feature/AmazingFeature`)
|
| 603 |
+
5. Pull Request
|
| 604 |
+
|
| 605 |
+
---
|
| 606 |
+
|
| 607 |
+
## 📝 Notes Importantes
|
| 608 |
+
|
| 609 |
+
### Traçabilité des Données
|
| 610 |
+
|
| 611 |
+
- **Aucune suppression physique** : Toutes les suppressions sont des soft deletes
|
| 612 |
+
- **Versioning** : Chaque modification crée un nouvel enregistrement
|
| 613 |
+
- **Audit Trail** : Timestamps et historiques complets
|
| 614 |
+
- **Récupération** : Possibilité de restaurer les données
|
| 615 |
+
|
| 616 |
+
### Performance
|
| 617 |
+
|
| 618 |
+
- **Indexes** : Sur tous les champs fréquemment filtrés
|
| 619 |
+
- **Pagination** : 20 items par page par défaut
|
| 620 |
+
- **Caching Redis** : Pour sessions et Celery
|
| 621 |
+
- **Connection Pooling** : PostgreSQL avec `CONN_MAX_AGE=600`
|
| 622 |
+
|
| 623 |
+
### Scalabilité
|
| 624 |
+
|
| 625 |
+
- **Horizontal** : Ajouter des workers Gunicorn/Celery
|
| 626 |
+
- **Vertical** : Augmenter ressources serveur
|
| 627 |
+
- **Database** : PostgreSQL supporte des millions de rows
|
| 628 |
+
- **CDN** : Pour fichiers statiques/media
|
| 629 |
+
|
| 630 |
+
---
|
| 631 |
+
|
| 632 |
+
## 📞 Support
|
| 633 |
+
|
| 634 |
+
- **Email**: support@educonnect.africa
|
| 635 |
+
- **Documentation**: https://docs.educonnect.africa
|
| 636 |
+
- **Issues**: https://github.com/hypee/educonnect-api/issues
|
| 637 |
+
|
| 638 |
+
---
|
| 639 |
+
|
| 640 |
+
**Développé avec ❤️ par Initiative Hypee - Bénin 🇧🇯**
|
README.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# EduConnect Africa API
|
| 2 |
+
|
| 3 |
+
API REST Django pour la plateforme EduConnect Africa - Initiative Hypee (Bénin)
|
| 4 |
+
|
| 5 |
+
## 🚀 Technologies
|
| 6 |
+
|
| 7 |
+
- **Django 4.2** + **Django REST Framework 3.14**
|
| 8 |
+
- **PostgreSQL 15** (Base de données)
|
| 9 |
+
- **Redis** (Cache & Celery)
|
| 10 |
+
- **Channels** (WebSockets pour le chat)
|
| 11 |
+
- **JWT** (Authentification)
|
| 12 |
+
- **Celery** (Tâches asynchrones)
|
| 13 |
+
- **Docker** (Containerisation)
|
| 14 |
+
|
| 15 |
+
## 📋 Prérequis
|
| 16 |
+
|
| 17 |
+
- Python 3.11+
|
| 18 |
+
- PostgreSQL 15+
|
| 19 |
+
- Redis 7+
|
| 20 |
+
- Docker & Docker Compose (optionnel)
|
| 21 |
+
|
| 22 |
+
## ⚙️ Installation
|
| 23 |
+
|
| 24 |
+
### Méthode 1: Installation locale
|
| 25 |
+
|
| 26 |
+
1. **Cloner le projet**
|
| 27 |
+
```bash
|
| 28 |
+
git clone <repo-url>
|
| 29 |
+
cd educonnect-api
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
2. **Créer un environnement virtuel**
|
| 33 |
+
```bash
|
| 34 |
+
python -m venv venv
|
| 35 |
+
source venv/bin/activate # Linux/Mac
|
| 36 |
+
# ou
|
| 37 |
+
venv\\Scripts\\activate # Windows
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
3. **Installer les dépendances**
|
| 41 |
+
```bash
|
| 42 |
+
pip install -r requirements.txt
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
4. **Configuration**
|
| 46 |
+
```bash
|
| 47 |
+
cp .env.example .env
|
| 48 |
+
# Éditer .env avec vos paramètres
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
5. **Créer la base de données**
|
| 52 |
+
```bash
|
| 53 |
+
createdb educonnect_db
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
6. **Migrations**
|
| 57 |
+
```bash
|
| 58 |
+
python manage.py makemigrations
|
| 59 |
+
python manage.py migrate
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
7. **Initialiser les données**
|
| 63 |
+
```bash
|
| 64 |
+
python manage.py init_db
|
| 65 |
+
python manage.py create_test_data --users 20
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
8. **Créer un superuser**
|
| 69 |
+
```bash
|
| 70 |
+
python manage.py createsuperuser
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
9. **Lancer le serveur**
|
| 74 |
+
```bash
|
| 75 |
+
python manage.py runserver
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
### Méthode 2: Avec Docker
|
| 79 |
+
|
| 80 |
+
```bash
|
| 81 |
+
docker-compose up --build
|
| 82 |
+
docker-compose exec web python manage.py migrate
|
| 83 |
+
docker-compose exec web python manage.py init_db
|
| 84 |
+
docker-compose exec web python manage.py createsuperuser
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
## 📚 Documentation API
|
| 88 |
+
|
| 89 |
+
Une fois le serveur lancé:
|
| 90 |
+
- **Swagger UI**: http://localhost:8000/api/docs/
|
| 91 |
+
- **ReDoc**: http://localhost:8000/api/redoc/
|
| 92 |
+
- **Admin Django**: http://localhost:8000/admin/
|
| 93 |
+
|
| 94 |
+
## 🔑 Endpoints Principaux
|
| 95 |
+
|
| 96 |
+
### Authentification
|
| 97 |
+
- `POST /api/auth/register/` - Inscription
|
| 98 |
+
- `POST /api/auth/login/` - Connexion
|
| 99 |
+
- `GET /api/auth/me/` - Profil actuel
|
| 100 |
+
- `PATCH /api/auth/update_profile/` - Mise à jour profil
|
| 101 |
+
|
| 102 |
+
### Mentors
|
| 103 |
+
- `GET /api/mentors/` - Liste des mentors
|
| 104 |
+
- `GET /api/mentors/{id}/` - Détail mentor
|
| 105 |
+
- `PATCH /api/mentors/my_profile/` - Mise à jour profil mentor
|
| 106 |
+
|
| 107 |
+
### Forum
|
| 108 |
+
- `GET /api/forum/questions/` - Liste questions
|
| 109 |
+
- `POST /api/forum/questions/` - Créer question
|
| 110 |
+
- `POST /api/forum/questions/{id}/vote/` - Voter
|
| 111 |
+
- `POST /api/forum/questions/{id}/answers/` - Répondre
|
| 112 |
+
|
| 113 |
+
### Réservations
|
| 114 |
+
- `POST /api/bookings/` - Créer réservation
|
| 115 |
+
- `GET /api/bookings/mentor_requests/` - Demandes reçues
|
| 116 |
+
- `PATCH /api/bookings/{id}/update_status/` - Accepter/Refuser
|
| 117 |
+
|
| 118 |
+
### Gamification
|
| 119 |
+
- `GET /api/gamification/leaderboard/` - Classement
|
| 120 |
+
- `GET /api/gamification/my_badges/` - Mes badges
|
| 121 |
+
- `GET /api/gamification/stats/` - Statistiques
|
| 122 |
+
|
| 123 |
+
### Messagerie
|
| 124 |
+
- `GET /api/messages/conversations/` - Conversations
|
| 125 |
+
- `POST /api/messages/conversations/{id}/send_message/` - Envoyer message
|
| 126 |
+
- WebSocket: `ws://localhost:8001/ws/chat/{conversation_id}/`
|
| 127 |
+
|
| 128 |
+
### Notifications
|
| 129 |
+
- `GET /api/notifications/` - Mes notifications
|
| 130 |
+
- `POST /api/notifications/mark_all_read/` - Tout marquer lu
|
| 131 |
+
|
| 132 |
+
### Opportunités
|
| 133 |
+
- `GET /api/opportunities/` - Liste opportunités
|
| 134 |
+
|
| 135 |
+
### IA
|
| 136 |
+
- `POST /api/ai/tutor/` - Tuteur IA
|
| 137 |
+
|
| 138 |
+
## 🧪 Tests
|
| 139 |
+
|
| 140 |
+
```bash
|
| 141 |
+
pytest
|
| 142 |
+
pytest --cov=apps
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
## 🏗️ Structure du Projet
|
| 146 |
+
|
| 147 |
+
```
|
| 148 |
+
educonnect-api/
|
| 149 |
+
├── apps/
|
| 150 |
+
│ ├── users/ # Authentification & Utilisateurs
|
| 151 |
+
│ ├── mentors/ # Gestion mentors
|
| 152 |
+
│ ├── bookings/ # Système de réservation
|
| 153 |
+
│ ├── forum/ # Questions & Réponses
|
| 154 |
+
│ ├── gamification/ # Points & Badges
|
| 155 |
+
│ ├── messaging/ # Chat en temps réel
|
| 156 |
+
│ ├── notifications/ # Notifications
|
| 157 |
+
│ ├── opportunities/ # Opportunités
|
| 158 |
+
│ ├── ai_tools/ # Tuteur IA
|
| 159 |
+
│ ├── analytics/ # Analytics
|
| 160 |
+
│ └── core/ # Mixins, utilities
|
| 161 |
+
├── educonnect_api/ # Configuration Django
|
| 162 |
+
├── requirements.txt
|
| 163 |
+
├── Dockerfile
|
| 164 |
+
└── docker-compose.yml
|
| 165 |
+
```
|
| 166 |
+
|
| 167 |
+
## 🔒 Sécurité
|
| 168 |
+
|
| 169 |
+
- Tokens JWT avec refresh
|
| 170 |
+
- Rate limiting
|
| 171 |
+
- CORS configuré
|
| 172 |
+
- Validation des entrées
|
| 173 |
+
- Soft delete pour traçabilité
|
| 174 |
+
- HTTPS obligatoire en production
|
| 175 |
+
|
| 176 |
+
## 📊 Monitoring
|
| 177 |
+
|
| 178 |
+
Intégration Sentry pour le monitoring des erreurs en production.
|
| 179 |
+
|
| 180 |
+
## 🤝 Contribution
|
| 181 |
+
|
| 182 |
+
1. Fork le projet
|
| 183 |
+
2. Créer une branche (`git checkout -b feature/AmazingFeature`)
|
| 184 |
+
3. Commit (`git commit -m 'Add AmazingFeature'`)
|
| 185 |
+
4. Push (`git push origin feature/AmazingFeature`)
|
| 186 |
+
5. Pull Request
|
| 187 |
+
|
| 188 |
+
## 📝 Licence
|
| 189 |
+
|
| 190 |
+
Propriétaire - Initiative Hypee Bénin
|
| 191 |
+
|
| 192 |
+
## 👥 Équipe
|
| 193 |
+
|
| 194 |
+
Initiative Hypee - Bénin
|
STRUCTURE_FILE.MD
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 📂 STRUCTURE COMPLÈTE DES FICHIERS - EduConnect Africa API
|
| 2 |
+
|
| 3 |
+
## Checklist de Création des Fichiers
|
| 4 |
+
|
| 5 |
+
### 🔧 Configuration Racine
|
| 6 |
+
|
| 7 |
+
```
|
| 8 |
+
educonnect-api/
|
| 9 |
+
├── 📄 manage.py
|
| 10 |
+
├── 📄 requirements.txt
|
| 11 |
+
├── 📄 .env.example
|
| 12 |
+
├── 📄 .gitignore
|
| 13 |
+
├── 📄 README.md
|
| 14 |
+
├── 📄 pytest.ini
|
| 15 |
+
├── 📄 conftest.py
|
| 16 |
+
├── 📄 Dockerfile
|
| 17 |
+
├── 📄 docker-compose.yml
|
| 18 |
+
├── 📄 deploy.sh
|
| 19 |
+
└── 📄 .flake8
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
### 📦 Configuration Django (`educonnect_api/`)
|
| 23 |
+
|
| 24 |
+
```
|
| 25 |
+
educonnect_api/
|
| 26 |
+
├── 📄 __init__.py
|
| 27 |
+
├── 📄 settings.py ✅ Créé
|
| 28 |
+
├── 📄 urls.py ✅ Créé
|
| 29 |
+
├── 📄 wsgi.py ✅ Créé
|
| 30 |
+
├── 📄 asgi.py ✅ Créé
|
| 31 |
+
└── 📄 celery.py ✅ Créé
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
### 🎯 App: Core (`apps/core/`)
|
| 35 |
+
|
| 36 |
+
```
|
| 37 |
+
apps/core/
|
| 38 |
+
├── 📄 __init__.py
|
| 39 |
+
├── 📄 apps.py
|
| 40 |
+
├── 📄 models.py ✅ Créé (Mixins)
|
| 41 |
+
├── 📄 permissions.py ✅ Créé
|
| 42 |
+
├── 📄 exceptions.py ⚠️ À créer
|
| 43 |
+
├── 📄 middleware.py ✅ Créé
|
| 44 |
+
├── 📄 admin.py
|
| 45 |
+
├── management/
|
| 46 |
+
│ └── commands/
|
| 47 |
+
│ ├── 📄 __init__.py
|
| 48 |
+
│ ├── 📄 init_db.py ✅ Créé
|
| 49 |
+
│ └── 📄 create_test_data.py ✅ Créé
|
| 50 |
+
└── tests/
|
| 51 |
+
└── 📄 test_models.py
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
### 👤 App: Users (`apps/users/`)
|
| 55 |
+
|
| 56 |
+
```
|
| 57 |
+
apps/users/
|
| 58 |
+
├── 📄 __init__.py
|
| 59 |
+
├── 📄 apps.py
|
| 60 |
+
├── 📄 models.py ✅ Créé
|
| 61 |
+
├── 📄 serializers.py ✅ Créé
|
| 62 |
+
├── 📄 views.py ✅ Créé
|
| 63 |
+
├── 📄 urls.py ✅ Créé
|
| 64 |
+
├── 📄 admin.py ⚠️ À créer
|
| 65 |
+
├── 📄 tests.py ✅ Créé
|
| 66 |
+
└── migrations/
|
| 67 |
+
└── 📄 __init__.py
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
### 👨🏫 App: Mentors (`apps/mentors/`)
|
| 71 |
+
|
| 72 |
+
```
|
| 73 |
+
apps/mentors/
|
| 74 |
+
├── 📄 __init__.py
|
| 75 |
+
├── 📄 apps.py
|
| 76 |
+
├── 📄 models.py ✅ Créé
|
| 77 |
+
├── 📄 serializers.py ✅ Créé
|
| 78 |
+
├── 📄 views.py ✅ Créé
|
| 79 |
+
├── 📄 urls.py ✅ Créé
|
| 80 |
+
├── 📄 admin.py ⚠️ À créer
|
| 81 |
+
├── 📄 tests.py ⚠️ À créer
|
| 82 |
+
└── migrations/
|
| 83 |
+
└── 📄 __init__.py
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
### 📅 App: Bookings (`apps/bookings/`)
|
| 87 |
+
|
| 88 |
+
```
|
| 89 |
+
apps/bookings/
|
| 90 |
+
├── 📄 __init__.py
|
| 91 |
+
├── 📄 apps.py
|
| 92 |
+
├── 📄 models.py ✅ Créé
|
| 93 |
+
├── 📄 serializers.py ✅ Créé
|
| 94 |
+
├── 📄 views.py ✅ Créé
|
| 95 |
+
├── 📄 urls.py ✅ Créé
|
| 96 |
+
├── 📄 admin.py ⚠️ À créer
|
| 97 |
+
├── 📄 tests.py ⚠️ À créer
|
| 98 |
+
└── migrations/
|
| 99 |
+
└── 📄 __init__.py
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
### 💬 App: Forum (`apps/forum/`)
|
| 103 |
+
|
| 104 |
+
```
|
| 105 |
+
apps/forum/
|
| 106 |
+
├── 📄 __init__.py
|
| 107 |
+
├── 📄 apps.py
|
| 108 |
+
├── 📄 models.py ✅ Créé
|
| 109 |
+
├── 📄 serializers.py ✅ Créé
|
| 110 |
+
├── 📄 views.py ✅ Créé
|
| 111 |
+
├── 📄 urls.py ✅ Créé
|
| 112 |
+
├── 📄 admin.py ⚠️ À créer
|
| 113 |
+
├── 📄 tests.py ✅ Créé
|
| 114 |
+
└── migrations/
|
| 115 |
+
└── 📄 __init__.py
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
### 🎮 App: Gamification (`apps/gamification/`)
|
| 119 |
+
|
| 120 |
+
```
|
| 121 |
+
apps/gamification/
|
| 122 |
+
├── 📄 __init__.py
|
| 123 |
+
├── 📄 apps.py
|
| 124 |
+
├── 📄 models.py ✅ Créé
|
| 125 |
+
├── 📄 serializers.py ✅ Créé
|
| 126 |
+
├── 📄 views.py ✅ Créé
|
| 127 |
+
├── 📄 services.py ✅ Créé
|
| 128 |
+
├── 📄 urls.py ✅ Créé
|
| 129 |
+
├── 📄 admin.py ⚠️ À créer
|
| 130 |
+
├── 📄 tests.py ✅ Créé
|
| 131 |
+
└── migrations/
|
| 132 |
+
└── 📄 __init__.py
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
### 💬 App: Messaging (`apps/messaging/`)
|
| 136 |
+
|
| 137 |
+
```
|
| 138 |
+
apps/messaging/
|
| 139 |
+
├── 📄 __init__.py
|
| 140 |
+
├── 📄 apps.py
|
| 141 |
+
├── 📄 models.py ✅ Créé
|
| 142 |
+
├── 📄 serializers.py ✅ Créé
|
| 143 |
+
├── 📄 views.py ✅ Créé
|
| 144 |
+
├── 📄 consumers.py ✅ Créé (WebSocket)
|
| 145 |
+
├── 📄 urls.py ✅ Créé
|
| 146 |
+
├── 📄 admin.py ⚠️ À créer
|
| 147 |
+
├── 📄 tests.py ⚠️ À créer
|
| 148 |
+
└── migrations/
|
| 149 |
+
└── 📄 __init__.py
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
### 🔔 App: Notifications (`apps/notifications/`)
|
| 153 |
+
|
| 154 |
+
```
|
| 155 |
+
apps/notifications/
|
| 156 |
+
├── 📄 __init__.py
|
| 157 |
+
├── 📄 apps.py
|
| 158 |
+
├── 📄 models.py ✅ Créé
|
| 159 |
+
├── 📄 serializers.py ✅ Créé
|
| 160 |
+
├── 📄 views.py ✅ Créé
|
| 161 |
+
├── 📄 services.py ✅ Créé
|
| 162 |
+
├── 📄 urls.py ✅ Créé
|
| 163 |
+
├── 📄 admin.py ⚠️ À créer
|
| 164 |
+
├── 📄 tests.py ⚠️ À créer
|
| 165 |
+
└── migrations/
|
| 166 |
+
└── 📄 __init__.py
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
+
### 🎓 App: Opportunities (`apps/opportunities/`)
|
| 170 |
+
|
| 171 |
+
```
|
| 172 |
+
apps/opportunities/
|
| 173 |
+
├── 📄 __init__.py
|
| 174 |
+
├── 📄 apps.py
|
| 175 |
+
├── 📄 models.py ✅ Créé
|
| 176 |
+
├── 📄 serializers.py ✅ Créé
|
| 177 |
+
├── 📄 views.py ✅ Créé
|
| 178 |
+
├── 📄 urls.py ✅ Créé
|
| 179 |
+
├── 📄 admin.py ⚠️ À créer
|
| 180 |
+
├── 📄 tests.py ⚠️ À créer
|
| 181 |
+
└── migrations/
|
| 182 |
+
└── 📄 __init__.py
|
| 183 |
+
```
|
| 184 |
+
|
| 185 |
+
### 🤖 App: AI Tools (`apps/ai_tools/`)
|
| 186 |
+
|
| 187 |
+
```
|
| 188 |
+
apps/ai_tools/
|
| 189 |
+
├── 📄 __init__.py
|
| 190 |
+
├── 📄 apps.py
|
| 191 |
+
├── 📄 models.py ✅ Créé
|
| 192 |
+
├── 📄 serializers.py ✅ Créé
|
| 193 |
+
├── 📄 views.py ✅ Créé
|
| 194 |
+
├── 📄 urls.py ✅ Créé
|
| 195 |
+
├── 📄 admin.py ⚠️ À créer
|
| 196 |
+
├── 📄 tests.py ⚠️ À créer
|
| 197 |
+
└── migrations/
|
| 198 |
+
└── 📄 __init__.py
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
### 📊 App: Analytics (`apps/analytics/`)
|
| 202 |
+
|
| 203 |
+
```
|
| 204 |
+
apps/analytics/
|
| 205 |
+
├── 📄 __init__.py
|
| 206 |
+
├── 📄 apps.py
|
| 207 |
+
├── 📄 models.py ✅ Créé
|
| 208 |
+
├── 📄 views.py ✅ Créé
|
| 209 |
+
├── 📄 urls.py ⚠️ À créer
|
| 210 |
+
├── 📄 admin.py ⚠️ À créer
|
| 211 |
+
└── migrations/
|
| 212 |
+
└── 📄 __init__.py
|
| 213 |
+
```
|
| 214 |
+
|
| 215 |
+
### ⚙️ Systemd Services (`systemd/`)
|
| 216 |
+
|
| 217 |
+
```
|
| 218 |
+
systemd/
|
| 219 |
+
├── 📄 gunicorn.service ✅ Créé
|
| 220 |
+
└── 📄 celery.service ✅ Créé
|
| 221 |
+
```
|
| 222 |
+
|
| 223 |
+
### 🌐 Nginx Configuration (`nginx/`)
|
| 224 |
+
|
| 225 |
+
```
|
| 226 |
+
nginx/
|
| 227 |
+
└── 📄 educonnect.conf ✅ Créé
|
| 228 |
+
```
|
| 229 |
+
|
| 230 |
+
### 🧪 GitHub Actions (`.github/workflows/`)
|
| 231 |
+
|
| 232 |
+
```
|
| 233 |
+
.github/workflows/
|
| 234 |
+
└── 📄 ci.yml ✅ Créé
|
| 235 |
+
```
|
| 236 |
+
|
| 237 |
+
---
|
| 238 |
+
|
| 239 |
+
## 📝 Fichiers Manquants à Créer
|
| 240 |
+
|
| 241 |
+
### 1. `.gitignore`
|
| 242 |
+
|
| 243 |
+
```gitignore
|
| 244 |
+
# Python
|
| 245 |
+
__pycache__/
|
| 246 |
+
*.py[cod]
|
| 247 |
+
*$py.class
|
| 248 |
+
*.so
|
| 249 |
+
.Python
|
| 250 |
+
venv/
|
| 251 |
+
env/
|
| 252 |
+
ENV/
|
| 253 |
+
*.egg-info/
|
| 254 |
+
dist/
|
| 255 |
+
build/
|
| 256 |
+
|
| 257 |
+
# Django
|
| 258 |
+
*.log
|
| 259 |
+
local_settings.py
|
| 260 |
+
db.sqlite3
|
| 261 |
+
media/
|
| 262 |
+
staticfiles/
|
| 263 |
+
|
| 264 |
+
# Environment
|
| 265 |
+
.env
|
| 266 |
+
.env.local
|
| 267 |
+
|
| 268 |
+
# IDE
|
| 269 |
+
.vscode/
|
| 270 |
+
.idea/
|
| 271 |
+
*.swp
|
| 272 |
+
*.swo
|
| 273 |
+
*~
|
| 274 |
+
|
| 275 |
+
# OS
|
| 276 |
+
.DS_Store
|
| 277 |
+
Thumbs.db
|
| 278 |
+
|
| 279 |
+
# Testing
|
| 280 |
+
.coverage
|
| 281 |
+
htmlcov/
|
| 282 |
+
.pytest_cache/
|
| 283 |
+
|
| 284 |
+
# Celery
|
| 285 |
+
celerybeat-schedule
|
| 286 |
+
celerybeat.pid
|
| 287 |
+
```
|
| 288 |
+
|
| 289 |
+
### 2. `.flake8`
|
| 290 |
+
|
| 291 |
+
```ini
|
| 292 |
+
[flake8]
|
| 293 |
+
max-line-length = 127
|
| 294 |
+
exclude =
|
| 295 |
+
.git,
|
| 296 |
+
__pycache__,
|
| 297 |
+
venv,
|
| 298 |
+
migrations,
|
| 299 |
+
*/migrations/*
|
| 300 |
+
ignore = E203, E266, W503
|
| 301 |
+
```
|
| 302 |
+
|
| 303 |
+
### 3. Fichiers `apps.py` pour chaque app
|
| 304 |
+
|
| 305 |
+
**Exemple `apps/users/apps.py`:**
|
| 306 |
+
|
| 307 |
+
```python
|
| 308 |
+
from django.apps import AppConfig
|
| 309 |
+
|
| 310 |
+
class UsersConfig(AppConfig):
|
| 311 |
+
default_auto_field = 'django.db.models.BigAutoField'
|
| 312 |
+
name = 'apps.users'
|
| 313 |
+
|
| 314 |
+
def ready(self):
|
| 315 |
+
import apps.core.signals # Charger les signals
|
| 316 |
+
```
|
| 317 |
+
|
| 318 |
+
### 4. Fichiers `admin.py` pour chaque app
|
| 319 |
+
|
| 320 |
+
**Exemple `apps/users/admin.py`:**
|
| 321 |
+
|
| 322 |
+
```python
|
| 323 |
+
from django.contrib import admin
|
| 324 |
+
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
| 325 |
+
from apps.users.models import User, UserProfile
|
| 326 |
+
|
| 327 |
+
@admin.register(User)
|
| 328 |
+
class UserAdmin(BaseUserAdmin):
|
| 329 |
+
list_display = ['email', 'role', 'points', 'is_active', 'created_at']
|
| 330 |
+
list_filter = ['role', 'is_active', 'created_at']
|
| 331 |
+
search_fields = ['email']
|
| 332 |
+
ordering = ['-created_at']
|
| 333 |
+
|
| 334 |
+
fieldsets = (
|
| 335 |
+
(None, {'fields': ('email', 'password')}),
|
| 336 |
+
('Informations', {'fields': ('role', 'points')}),
|
| 337 |
+
('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser')}),
|
| 338 |
+
('Dates', {'fields': ('created_at', 'updated_at')}),
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
readonly_fields = ['created_at', 'updated_at']
|
| 342 |
+
|
| 343 |
+
add_fieldsets = (
|
| 344 |
+
(None, {
|
| 345 |
+
'classes': ('wide',),
|
| 346 |
+
'fields': ('email', 'password1', 'password2', 'role'),
|
| 347 |
+
}),
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
@admin.register(UserProfile)
|
| 351 |
+
class UserProfileAdmin(admin.ModelAdmin):
|
| 352 |
+
list_display = ['user', 'name', 'is_current', 'created_at']
|
| 353 |
+
list_filter = ['is_current', 'created_at']
|
| 354 |
+
search_fields = ['user__email', 'name']
|
| 355 |
+
```
|
| 356 |
+
|
| 357 |
+
### 5. `apps/core/exceptions.py`
|
| 358 |
+
|
| 359 |
+
```python
|
| 360 |
+
from rest_framework.views import exception_handler
|
| 361 |
+
from rest_framework.response import Response
|
| 362 |
+
from rest_framework import status
|
| 363 |
+
import logging
|
| 364 |
+
|
| 365 |
+
logger = logging.getLogger(__name__)
|
| 366 |
+
|
| 367 |
+
def custom_exception_handler(exc, context):
|
| 368 |
+
"""Gestionnaire d'exceptions personnalisé"""
|
| 369 |
+
|
| 370 |
+
# Appeler le gestionnaire par défaut
|
| 371 |
+
response = exception_handler(exc, context)
|
| 372 |
+
|
| 373 |
+
# Logger l'erreur
|
| 374 |
+
logger.error(f"Exception: {exc}", exc_info=True)
|
| 375 |
+
|
| 376 |
+
if response is not None:
|
| 377 |
+
# Personnaliser le format de la réponse
|
| 378 |
+
custom_response_data = {
|
| 379 |
+
'error': True,
|
| 380 |
+
'message': str(exc),
|
| 381 |
+
'status_code': response.status_code
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
if hasattr(exc, 'detail'):
|
| 385 |
+
custom_response_data['details'] = exc.detail
|
| 386 |
+
|
| 387 |
+
response.data = custom_response_data
|
| 388 |
+
|
| 389 |
+
return response
|
| 390 |
+
```
|
| 391 |
+
|
| 392 |
+
### 6. `apps/__init__.py`
|
| 393 |
+
|
| 394 |
+
Créer un fichier `__init__.py` vide dans chaque dossier d'app.
|
| 395 |
+
|
| 396 |
+
---
|
| 397 |
+
|
| 398 |
+
## 🚀 Script de Création Automatique
|
| 399 |
+
|
| 400 |
+
Pour créer tous les fichiers manquants automatiquement:
|
| 401 |
+
|
| 402 |
+
```bash
|
| 403 |
+
#!/bin/bash
|
| 404 |
+
|
| 405 |
+
# create_structure.sh
|
| 406 |
+
|
| 407 |
+
echo "Création de la structure EduConnect Africa API..."
|
| 408 |
+
|
| 409 |
+
# Apps list
|
| 410 |
+
APPS=("users" "mentors" "bookings" "forum" "gamification" "messaging" "notifications" "opportunities" "ai_tools" "analytics" "core")
|
| 411 |
+
|
| 412 |
+
# Créer les dossiers principaux
|
| 413 |
+
mkdir -p apps logs media staticfiles templates systemd nginx .github/workflows
|
| 414 |
+
|
| 415 |
+
# Créer la structure pour chaque app
|
| 416 |
+
for app in "${APPS[@]}"; do
|
| 417 |
+
echo "Création de apps/$app..."
|
| 418 |
+
mkdir -p apps/$app/migrations
|
| 419 |
+
touch apps/$app/__init__.py
|
| 420 |
+
touch apps/$app/apps.py
|
| 421 |
+
touch apps/$app/models.py
|
| 422 |
+
touch apps/$app/serializers.py
|
| 423 |
+
touch apps/$app/views.py
|
| 424 |
+
touch apps/$app/urls.py
|
| 425 |
+
touch apps/$app/admin.py
|
| 426 |
+
touch apps/$app/tests.py
|
| 427 |
+
touch apps/$app/migrations/__init__.py
|
| 428 |
+
done
|
| 429 |
+
|
| 430 |
+
# Créer management commands
|
| 431 |
+
mkdir -p apps/core/management/commands
|
| 432 |
+
touch apps/core/management/__init__.py
|
| 433 |
+
touch apps/core/management/commands/__init__.py
|
| 434 |
+
|
| 435 |
+
# Configuration Django
|
| 436 |
+
mkdir -p educonnect_api
|
| 437 |
+
touch educonnect_api/__init__.py
|
| 438 |
+
|
| 439 |
+
# Fichiers racine
|
| 440 |
+
touch manage.py
|
| 441 |
+
touch pytest.ini
|
| 442 |
+
touch conftest.py
|
| 443 |
+
touch .gitignore
|
| 444 |
+
touch .flake8
|
| 445 |
+
touch README.md
|
| 446 |
+
|
| 447 |
+
echo "✅ Structure créée avec succès!"
|
| 448 |
+
echo "📝 N'oubliez pas de remplir les fichiers avec le code approprié"
|
| 449 |
+
```
|
| 450 |
+
|
| 451 |
+
Rendre exécutable:
|
| 452 |
+
```bash
|
| 453 |
+
chmod +x create_structure.sh
|
| 454 |
+
./create_structure.sh
|
| 455 |
+
```
|
| 456 |
+
|
| 457 |
+
---
|
| 458 |
+
|
| 459 |
+
## ✅ Checklist de Vérification
|
| 460 |
+
|
| 461 |
+
Avant de lancer l'application, vérifier:
|
| 462 |
+
|
| 463 |
+
- [ ] Tous les fichiers `__init__.py` sont créés
|
| 464 |
+
- [ ] `.env` est configuré avec les vraies valeurs
|
| 465 |
+
- [ ] PostgreSQL est installé et la DB créée
|
| 466 |
+
- [ ] Redis est installé et lancé
|
| 467 |
+
- [ ] Tous les fichiers de migrations sont présents
|
| 468 |
+
- [ ] Les fichiers `apps.py` sont configurés
|
| 469 |
+
- [ ] Les `admin.py` sont remplis
|
| 470 |
+
- [ ] Les tests passent (`pytest`)
|
| 471 |
+
- [ ] Le serveur démarre (`python manage.py runserver`)
|
| 472 |
+
- [ ] Celery fonctionne
|
| 473 |
+
- [ ] Les WebSockets fonctionnent (Channels/Daphne)
|
| 474 |
+
|
| 475 |
+
---
|
| 476 |
+
|
| 477 |
+
## 🔄 Ordre de Création Recommandé
|
| 478 |
+
|
| 479 |
+
1. **Configuration de base**
|
| 480 |
+
- `settings.py`
|
| 481 |
+
- `urls.py`
|
| 482 |
+
- `.env`
|
| 483 |
+
|
| 484 |
+
2. **Core & Users**
|
| 485 |
+
- `apps/core/models.py` (mixins)
|
| 486 |
+
- `apps/users/models.py`
|
| 487 |
+
- `apps/users/serializers.py`
|
| 488 |
+
- `apps/users/views.py`
|
| 489 |
+
|
| 490 |
+
3. **Autres apps une par une**
|
| 491 |
+
- Models → Serializers → Views → URLs
|
| 492 |
+
|
| 493 |
+
4. **Services & Signals**
|
| 494 |
+
- `apps/gamification/services.py`
|
| 495 |
+
- `apps/notifications/services.py`
|
| 496 |
+
- `apps/core/signals.py`
|
| 497 |
+
|
| 498 |
+
5. **Tests**
|
| 499 |
+
- Écrire les tests au fur et à mesure
|
| 500 |
+
|
| 501 |
+
6. **Configuration serveur**
|
| 502 |
+
- Systemd services
|
| 503 |
+
- Nginx
|
| 504 |
+
- SSL
|
| 505 |
+
|
| 506 |
+
---
|
| 507 |
+
|
| 508 |
+
**🎯 Objectif: API REST complète, sécurisée, scalable et maintenable!**
|
apps/ai_tools/__init__.py
ADDED
|
File without changes
|
apps/ai_tools/admin.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from django.contrib import admin
|
| 2 |
+
from .models import AITutorSession, AITutorQuestion, CodeSnippet
|
| 3 |
+
|
| 4 |
+
class AITutorQuestionInline(admin.StackedInline):
|
| 5 |
+
model = AITutorQuestion
|
| 6 |
+
extra = 0
|
| 7 |
+
|
| 8 |
+
@admin.register(AITutorSession)
|
| 9 |
+
class AITutorSessionAdmin(admin.ModelAdmin):
|
| 10 |
+
list_display = ('user', 'subject', 'level', 'total_questions', 'created_at')
|
| 11 |
+
list_filter = ('subject', 'level', 'created_at')
|
| 12 |
+
search_fields = ('user__email', 'subject')
|
| 13 |
+
inlines = [AITutorQuestionInline]
|
| 14 |
+
|
| 15 |
+
@admin.register(CodeSnippet)
|
| 16 |
+
class CodeSnippetAdmin(admin.ModelAdmin):
|
| 17 |
+
list_display = ('user', 'title', 'language', 'is_public', 'created_at')
|
| 18 |
+
list_filter = ('language', 'is_public', 'created_at')
|
| 19 |
+
search_fields = ('user__email', 'title', 'code')
|
apps/ai_tools/apps.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from django.apps import AppConfig
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class AiToolsConfig(AppConfig):
|
| 5 |
+
default_auto_field = 'django.db.models.BigAutoField'
|
| 6 |
+
name = 'apps.ai_tools'
|
apps/ai_tools/migrations/0001_initial.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Generated by Django 4.2.7 on 2025-11-29 13:38
|
| 2 |
+
|
| 3 |
+
from django.db import migrations, models
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class Migration(migrations.Migration):
|
| 7 |
+
initial = True
|
| 8 |
+
|
| 9 |
+
dependencies = []
|
| 10 |
+
|
| 11 |
+
operations = [
|
| 12 |
+
migrations.CreateModel(
|
| 13 |
+
name="AITutorQuestion",
|
| 14 |
+
fields=[
|
| 15 |
+
(
|
| 16 |
+
"id",
|
| 17 |
+
models.BigAutoField(
|
| 18 |
+
auto_created=True,
|
| 19 |
+
primary_key=True,
|
| 20 |
+
serialize=False,
|
| 21 |
+
verbose_name="ID",
|
| 22 |
+
),
|
| 23 |
+
),
|
| 24 |
+
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
|
| 25 |
+
("updated_at", models.DateTimeField(auto_now=True)),
|
| 26 |
+
("question", models.TextField()),
|
| 27 |
+
("answer", models.TextField()),
|
| 28 |
+
("model_used", models.CharField(max_length=50)),
|
| 29 |
+
("tokens_used", models.IntegerField(blank=True, null=True)),
|
| 30 |
+
("response_time", models.FloatField(blank=True, null=True)),
|
| 31 |
+
],
|
| 32 |
+
options={
|
| 33 |
+
"db_table": "ai_tutor_questions",
|
| 34 |
+
"ordering": ["created_at"],
|
| 35 |
+
},
|
| 36 |
+
),
|
| 37 |
+
migrations.CreateModel(
|
| 38 |
+
name="AITutorSession",
|
| 39 |
+
fields=[
|
| 40 |
+
(
|
| 41 |
+
"id",
|
| 42 |
+
models.BigAutoField(
|
| 43 |
+
auto_created=True,
|
| 44 |
+
primary_key=True,
|
| 45 |
+
serialize=False,
|
| 46 |
+
verbose_name="ID",
|
| 47 |
+
),
|
| 48 |
+
),
|
| 49 |
+
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
|
| 50 |
+
("updated_at", models.DateTimeField(auto_now=True)),
|
| 51 |
+
(
|
| 52 |
+
"deleted_at",
|
| 53 |
+
models.DateTimeField(blank=True, db_index=True, null=True),
|
| 54 |
+
),
|
| 55 |
+
("is_active", models.BooleanField(db_index=True, default=True)),
|
| 56 |
+
("subject", models.CharField(blank=True, max_length=100, null=True)),
|
| 57 |
+
("level", models.CharField(blank=True, max_length=50, null=True)),
|
| 58 |
+
("total_questions", models.IntegerField(default=0)),
|
| 59 |
+
],
|
| 60 |
+
options={
|
| 61 |
+
"verbose_name": "Session Tuteur IA",
|
| 62 |
+
"verbose_name_plural": "Sessions Tuteur IA",
|
| 63 |
+
"db_table": "ai_tutor_sessions",
|
| 64 |
+
"ordering": ["-created_at"],
|
| 65 |
+
},
|
| 66 |
+
),
|
| 67 |
+
migrations.CreateModel(
|
| 68 |
+
name="CodeSnippet",
|
| 69 |
+
fields=[
|
| 70 |
+
(
|
| 71 |
+
"id",
|
| 72 |
+
models.BigAutoField(
|
| 73 |
+
auto_created=True,
|
| 74 |
+
primary_key=True,
|
| 75 |
+
serialize=False,
|
| 76 |
+
verbose_name="ID",
|
| 77 |
+
),
|
| 78 |
+
),
|
| 79 |
+
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
|
| 80 |
+
("updated_at", models.DateTimeField(auto_now=True)),
|
| 81 |
+
(
|
| 82 |
+
"deleted_at",
|
| 83 |
+
models.DateTimeField(blank=True, db_index=True, null=True),
|
| 84 |
+
),
|
| 85 |
+
("is_active", models.BooleanField(db_index=True, default=True)),
|
| 86 |
+
("title", models.CharField(max_length=255)),
|
| 87 |
+
("language", models.CharField(max_length=50)),
|
| 88 |
+
("code", models.TextField()),
|
| 89 |
+
("description", models.TextField(blank=True, null=True)),
|
| 90 |
+
("is_public", models.BooleanField(default=False)),
|
| 91 |
+
],
|
| 92 |
+
options={
|
| 93 |
+
"verbose_name": "Snippet de Code",
|
| 94 |
+
"verbose_name_plural": "Snippets de Code",
|
| 95 |
+
"db_table": "code_snippets",
|
| 96 |
+
"ordering": ["-created_at"],
|
| 97 |
+
},
|
| 98 |
+
),
|
| 99 |
+
]
|
apps/ai_tools/migrations/0002_initial.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Generated by Django 4.2.7 on 2025-11-29 13:38
|
| 2 |
+
|
| 3 |
+
from django.conf import settings
|
| 4 |
+
from django.db import migrations, models
|
| 5 |
+
import django.db.models.deletion
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class Migration(migrations.Migration):
|
| 9 |
+
initial = True
|
| 10 |
+
|
| 11 |
+
dependencies = [
|
| 12 |
+
("ai_tools", "0001_initial"),
|
| 13 |
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
operations = [
|
| 17 |
+
migrations.AddField(
|
| 18 |
+
model_name="codesnippet",
|
| 19 |
+
name="user",
|
| 20 |
+
field=models.ForeignKey(
|
| 21 |
+
on_delete=django.db.models.deletion.CASCADE,
|
| 22 |
+
related_name="code_snippets",
|
| 23 |
+
to=settings.AUTH_USER_MODEL,
|
| 24 |
+
),
|
| 25 |
+
),
|
| 26 |
+
migrations.AddField(
|
| 27 |
+
model_name="aitutorsession",
|
| 28 |
+
name="user",
|
| 29 |
+
field=models.ForeignKey(
|
| 30 |
+
on_delete=django.db.models.deletion.CASCADE,
|
| 31 |
+
related_name="ai_sessions",
|
| 32 |
+
to=settings.AUTH_USER_MODEL,
|
| 33 |
+
),
|
| 34 |
+
),
|
| 35 |
+
migrations.AddField(
|
| 36 |
+
model_name="aitutorquestion",
|
| 37 |
+
name="session",
|
| 38 |
+
field=models.ForeignKey(
|
| 39 |
+
on_delete=django.db.models.deletion.CASCADE,
|
| 40 |
+
related_name="questions",
|
| 41 |
+
to="ai_tools.aitutorsession",
|
| 42 |
+
),
|
| 43 |
+
),
|
| 44 |
+
]
|
apps/ai_tools/migrations/__init__.py
ADDED
|
File without changes
|
apps/ai_tools/models.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================
|
| 2 |
+
# apps/ai_tools/models.py - Historique IA
|
| 3 |
+
# ============================================
|
| 4 |
+
from django.db import models
|
| 5 |
+
from apps.core.models import TimestampMixin, SoftDeleteMixin
|
| 6 |
+
from apps.users.models import User
|
| 7 |
+
|
| 8 |
+
class AITutorSession(TimestampMixin, SoftDeleteMixin):
|
| 9 |
+
"""Session de tuteur IA"""
|
| 10 |
+
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ai_sessions')
|
| 11 |
+
subject = models.CharField(max_length=100, null=True, blank=True)
|
| 12 |
+
level = models.CharField(max_length=50, null=True, blank=True)
|
| 13 |
+
total_questions = models.IntegerField(default=0)
|
| 14 |
+
|
| 15 |
+
class Meta:
|
| 16 |
+
db_table = 'ai_tutor_sessions'
|
| 17 |
+
verbose_name = 'Session Tuteur IA'
|
| 18 |
+
verbose_name_plural = 'Sessions Tuteur IA'
|
| 19 |
+
ordering = ['-created_at']
|
| 20 |
+
|
| 21 |
+
class AITutorQuestion(TimestampMixin):
|
| 22 |
+
"""Question posée au tuteur IA"""
|
| 23 |
+
session = models.ForeignKey(AITutorSession, on_delete=models.CASCADE, related_name='questions')
|
| 24 |
+
question = models.TextField()
|
| 25 |
+
answer = models.TextField()
|
| 26 |
+
model_used = models.CharField(max_length=50) # gemini, gpt-4, etc.
|
| 27 |
+
tokens_used = models.IntegerField(null=True, blank=True)
|
| 28 |
+
response_time = models.FloatField(null=True, blank=True) # en secondes
|
| 29 |
+
|
| 30 |
+
class Meta:
|
| 31 |
+
db_table = 'ai_tutor_questions'
|
| 32 |
+
ordering = ['created_at']
|
| 33 |
+
|
| 34 |
+
class CodeSnippet(TimestampMixin, SoftDeleteMixin):
|
| 35 |
+
"""Sauvegardes du sandbox de code"""
|
| 36 |
+
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='code_snippets')
|
| 37 |
+
title = models.CharField(max_length=255)
|
| 38 |
+
language = models.CharField(max_length=50)
|
| 39 |
+
code = models.TextField()
|
| 40 |
+
description = models.TextField(null=True, blank=True)
|
| 41 |
+
is_public = models.BooleanField(default=False)
|
| 42 |
+
|
| 43 |
+
class Meta:
|
| 44 |
+
db_table = 'code_snippets'
|
| 45 |
+
verbose_name = 'Snippet de Code'
|
| 46 |
+
verbose_name_plural = 'Snippets de Code'
|
| 47 |
+
ordering = ['-created_at']
|
apps/ai_tools/serializers.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================
|
| 2 |
+
# apps/ai_tools/serializers.py
|
| 3 |
+
# ============================================
|
| 4 |
+
from rest_framework import serializers
|
| 5 |
+
|
| 6 |
+
class AITutorRequestSerializer(serializers.Serializer):
|
| 7 |
+
question = serializers.CharField()
|
| 8 |
+
subject = serializers.CharField(max_length=100, required=False)
|
| 9 |
+
level = serializers.CharField(max_length=50, required=False)
|
| 10 |
+
style = serializers.CharField(max_length=100, required=False)
|
| 11 |
+
conversation_history = serializers.ListField(
|
| 12 |
+
child=serializers.DictField(),
|
| 13 |
+
required=False,
|
| 14 |
+
default=list
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
class AITutorResponseSerializer(serializers.Serializer):
|
| 18 |
+
answer = serializers.CharField()
|
| 19 |
+
session_id = serializers.IntegerField()
|
| 20 |
+
tokens_used = serializers.IntegerField(required=False)
|
| 21 |
+
|
apps/ai_tools/tests.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from django.test import TestCase
|
| 2 |
+
|
| 3 |
+
# Create your tests here.
|
apps/ai_tools/urls.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================
|
| 2 |
+
# apps/ai_tools/urls.py
|
| 3 |
+
# ============================================
|
| 4 |
+
from django.urls import path, include
|
| 5 |
+
from rest_framework.routers import DefaultRouter
|
| 6 |
+
from apps.ai_tools.views import AIToolsViewSet
|
| 7 |
+
|
| 8 |
+
router = DefaultRouter()
|
| 9 |
+
router.register(r'', AIToolsViewSet, basename='ai_tools')
|
| 10 |
+
|
| 11 |
+
urlpatterns = [
|
| 12 |
+
path('', include(router.urls)),
|
| 13 |
+
]
|
| 14 |
+
|
| 15 |
+
"""
|
| 16 |
+
Endpoints disponibles:
|
| 17 |
+
- POST /api/ai/tutor/
|
| 18 |
+
"""
|
apps/ai_tools/views.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================
|
| 2 |
+
# apps/ai_tools/views.py
|
| 3 |
+
# ============================================
|
| 4 |
+
from rest_framework import viewsets, status
|
| 5 |
+
from rest_framework.decorators import action
|
| 6 |
+
from rest_framework.response import Response
|
| 7 |
+
from rest_framework.permissions import IsAuthenticated
|
| 8 |
+
from django.conf import settings
|
| 9 |
+
|
| 10 |
+
from apps.ai_tools.serializers import (
|
| 11 |
+
AITutorRequestSerializer, AITutorResponseSerializer
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
class AIToolsViewSet(viewsets.GenericViewSet):
|
| 15 |
+
"""Outils IA"""
|
| 16 |
+
permission_classes = [IsAuthenticated]
|
| 17 |
+
|
| 18 |
+
@action(detail=False, methods=['post'])
|
| 19 |
+
def tutor(self, request):
|
| 20 |
+
"""POST /api/ai/tutor/ - Proxy vers l'API Gemini/OpenAI"""
|
| 21 |
+
serializer = AITutorRequestSerializer(data=request.data)
|
| 22 |
+
serializer.is_valid(raise_exception=True)
|
| 23 |
+
|
| 24 |
+
question = serializer.validated_data['question']
|
| 25 |
+
subject = serializer.validated_data.get('subject', '')
|
| 26 |
+
level = serializer.validated_data.get('level', '')
|
| 27 |
+
style = serializer.validated_data.get('style', '')
|
| 28 |
+
|
| 29 |
+
try:
|
| 30 |
+
# Utiliser Gemini si disponible, sinon OpenAI
|
| 31 |
+
if settings.GEMINI_API_KEY:
|
| 32 |
+
answer, tokens = self._call_gemini(question, subject, level, style)
|
| 33 |
+
elif settings.OPENAI_API_KEY:
|
| 34 |
+
answer, tokens = self._call_openai(question, subject, level, style)
|
| 35 |
+
else:
|
| 36 |
+
return Response(
|
| 37 |
+
{'error': 'Aucune API IA configurée'},
|
| 38 |
+
status=status.HTTP_503_SERVICE_UNAVAILABLE
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
# Sauvegarder dans l'historique
|
| 42 |
+
from apps.ai_tools.models import AITutorSession, AITutorQuestion
|
| 43 |
+
from django.utils import timezone
|
| 44 |
+
import time
|
| 45 |
+
|
| 46 |
+
start_time = time.time()
|
| 47 |
+
|
| 48 |
+
session = AITutorSession.objects.create(
|
| 49 |
+
user=request.user,
|
| 50 |
+
subject=subject,
|
| 51 |
+
level=level
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
AITutorQuestion.objects.create(
|
| 55 |
+
session=session,
|
| 56 |
+
question=question,
|
| 57 |
+
answer=answer,
|
| 58 |
+
model_used='gemini' if settings.GEMINI_API_KEY else 'openai',
|
| 59 |
+
tokens_used=tokens,
|
| 60 |
+
response_time=time.time() - start_time
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
session.total_questions += 1
|
| 64 |
+
session.save()
|
| 65 |
+
|
| 66 |
+
response_serializer = AITutorResponseSerializer({
|
| 67 |
+
'answer': answer,
|
| 68 |
+
'session_id': session.id,
|
| 69 |
+
'tokens_used': tokens
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
return Response(response_serializer.data)
|
| 73 |
+
|
| 74 |
+
except Exception as e:
|
| 75 |
+
import traceback
|
| 76 |
+
error_details = traceback.format_exc()
|
| 77 |
+
print(f"GEMINI ERROR: {error_details}") # Pour les logs
|
| 78 |
+
|
| 79 |
+
return Response(
|
| 80 |
+
{
|
| 81 |
+
'error': str(e),
|
| 82 |
+
'details': 'Vérifiez que votre clé API Gemini est valide et configurée dans GEMINI_API_KEY',
|
| 83 |
+
'type': type(e).__name__
|
| 84 |
+
},
|
| 85 |
+
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
@action(detail=False, methods=['get'])
|
| 89 |
+
def history(self, request):
|
| 90 |
+
"""GET /api/ai/history/ - Récupérer l'historique des sessions"""
|
| 91 |
+
from apps.ai_tools.models import AITutorSession
|
| 92 |
+
|
| 93 |
+
sessions = AITutorSession.objects.filter(
|
| 94 |
+
user=request.user,
|
| 95 |
+
is_active=True
|
| 96 |
+
).prefetch_related('questions')[:20] # Dernières 20 sessions
|
| 97 |
+
|
| 98 |
+
sessions_data = []
|
| 99 |
+
for session in sessions:
|
| 100 |
+
questions_list = list(session.questions.all())
|
| 101 |
+
sessions_data.append({
|
| 102 |
+
'id': session.id,
|
| 103 |
+
'subject': session.subject,
|
| 104 |
+
'level': session.level,
|
| 105 |
+
'total_questions': session.total_questions,
|
| 106 |
+
'created_at': session.created_at.isoformat(),
|
| 107 |
+
'first_question': questions_list[0].question if questions_list else '',
|
| 108 |
+
'questions': [
|
| 109 |
+
{
|
| 110 |
+
'question': q.question,
|
| 111 |
+
'answer': q.answer,
|
| 112 |
+
'created_at': q.created_at.isoformat()
|
| 113 |
+
} for q in questions_list
|
| 114 |
+
]
|
| 115 |
+
})
|
| 116 |
+
|
| 117 |
+
return Response(sessions_data)
|
| 118 |
+
|
| 119 |
+
def _call_gemini(self, question, subject, level, style=''):
|
| 120 |
+
"""Appel à l'API Gemini"""
|
| 121 |
+
from google import genai
|
| 122 |
+
from django.conf import settings
|
| 123 |
+
|
| 124 |
+
# Le client récupère la clé API depuis settings ou variable d'environnement
|
| 125 |
+
# Ici on passe explicitement la clé si elle est dans settings
|
| 126 |
+
client = genai.Client(api_key=settings.GEMINI_API_KEY)
|
| 127 |
+
|
| 128 |
+
# Construction du prompt avec tous les paramètres
|
| 129 |
+
style_instruction = ""
|
| 130 |
+
if style:
|
| 131 |
+
if "Simple" in style or "Concis" in style:
|
| 132 |
+
style_instruction = "Sois concis et va droit au but. Utilise un langage simple."
|
| 133 |
+
elif "Détaillé" in style or "Académique" in style:
|
| 134 |
+
style_instruction = "Fournis une explication détaillée et académique avec des références théoriques."
|
| 135 |
+
elif "5 ans" in style or "ELI5" in style:
|
| 136 |
+
style_instruction = "Explique comme si tu parlais à un enfant de 5 ans. Utilise des analogies simples et amusantes."
|
| 137 |
+
elif "Pratique" in style or "Exemples" in style:
|
| 138 |
+
style_instruction = "Concentre-toi sur des exemples pratiques et concrets. Montre comment appliquer les concepts."
|
| 139 |
+
elif "Socratique" in style:
|
| 140 |
+
style_instruction = "Utilise la méthode socratique : pose des questions pour guider l'étudiant vers la réponse."
|
| 141 |
+
|
| 142 |
+
prompt = f"""Tu es un tuteur pédagogique bienveillant et patient pour EduLab Africa.
|
| 143 |
+
L'étudiant est basé en Afrique.
|
| 144 |
+
|
| 145 |
+
Contexte:
|
| 146 |
+
Matière: {subject if subject else 'Général'}
|
| 147 |
+
Niveau: {level if level else 'Adapté'}
|
| 148 |
+
{f'Style: {style_instruction}' if style_instruction else ''}
|
| 149 |
+
|
| 150 |
+
Question de l'étudiant: {question}
|
| 151 |
+
|
| 152 |
+
Réponds de manière claire, structurée et pédagogique. Utilise des exemples culturellement pertinents pour un étudiant africain si possible.
|
| 153 |
+
|
| 154 |
+
IMPORTANT - Formatage des formules :
|
| 155 |
+
- Pour les formules mathématiques inline (dans le texte), utilise la syntaxe : $formule$
|
| 156 |
+
Exemple: La formule $E = mc^2$ est célèbre
|
| 157 |
+
- Pour les formules mathématiques en bloc (centrées), utilise : $$formule$$
|
| 158 |
+
Exemple: $$\\int_{{a}}^{{b}} x^2 dx = \\frac{{b^3 - a^3}}{{3}}$$
|
| 159 |
+
- Pour les formules chimiques, utilise aussi $ : $H_2O$, $CO_2$, $C_6H_{{12}}O_6$
|
| 160 |
+
- Pour les équations chimiques : $$CH_4 + 2O_2 \\rightarrow CO_2 + 2H_2O$$
|
| 161 |
+
- Utilise TOUJOURS cette syntaxe LaTeX pour TOUTES les formules mathématiques et chimiques
|
| 162 |
+
"""
|
| 163 |
+
|
| 164 |
+
response = client.models.generate_content(
|
| 165 |
+
model="gemini-flash-latest", contents=prompt
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
# Estimation des tokens (approximatif)
|
| 169 |
+
tokens = len(prompt.split()) + len(response.text.split())
|
| 170 |
+
|
| 171 |
+
return response.text, tokens
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _call_openai(self, question, subject, level, style=''):
|
| 175 |
+
"""Appel à l'API OpenAI"""
|
| 176 |
+
import openai
|
| 177 |
+
from django.conf import settings
|
| 178 |
+
|
| 179 |
+
openai.api_key = settings.OPENAI_API_KEY
|
| 180 |
+
|
| 181 |
+
response = openai.ChatCompletion.create(
|
| 182 |
+
model="gpt-3.5-turbo",
|
| 183 |
+
messages=[
|
| 184 |
+
{
|
| 185 |
+
"role": "system",
|
| 186 |
+
"content": f"Tu es un tuteur pédagogique pour la matière {subject} au niveau {level}."
|
| 187 |
+
},
|
| 188 |
+
{
|
| 189 |
+
"role": "user",
|
| 190 |
+
"content": question
|
| 191 |
+
}
|
| 192 |
+
]
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
answer = response.choices[0].message.content
|
| 196 |
+
tokens = response.usage.total_tokens
|
| 197 |
+
|
| 198 |
+
return answer, tokens
|
| 199 |
+
|
| 200 |
+
|
apps/analytics/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# apps/analytics/__init__.py
|
| 2 |
+
default_app_config = 'apps.analytics.apps.AnalyticsConfig'
|
apps/analytics/admin.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from django.contrib import admin
|
| 2 |
+
from apps.analytics.models import SearchLog, PopularSearch
|
| 3 |
+
|
| 4 |
+
@admin.register(SearchLog)
|
| 5 |
+
class SearchLogAdmin(admin.ModelAdmin):
|
| 6 |
+
list_display = ['id', 'user', 'category', 'search_query', 'results_count', 'created_at']
|
| 7 |
+
list_filter = ['category', 'created_at']
|
| 8 |
+
search_fields = ['search_query', 'user__email']
|
| 9 |
+
readonly_fields = ['created_at', 'updated_at']
|
| 10 |
+
date_hierarchy = 'created_at'
|
| 11 |
+
|
| 12 |
+
fieldsets = (
|
| 13 |
+
('Recherche', {
|
| 14 |
+
'fields': ('user', 'category', 'search_query', 'filters_applied', 'results_count')
|
| 15 |
+
}),
|
| 16 |
+
('Interaction', {
|
| 17 |
+
'fields': ('clicked_result_id', 'clicked_result_position')
|
| 18 |
+
}),
|
| 19 |
+
('Métadonnées', {
|
| 20 |
+
'fields': ('session_id', 'ip_address', 'user_agent', 'page_url'),
|
| 21 |
+
'classes': ('collapse',)
|
| 22 |
+
}),
|
| 23 |
+
('Timestamps', {
|
| 24 |
+
'fields': ('created_at', 'updated_at'),
|
| 25 |
+
'classes': ('collapse',)
|
| 26 |
+
}),
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@admin.register(PopularSearch)
|
| 31 |
+
class PopularSearchAdmin(admin.ModelAdmin):
|
| 32 |
+
list_display = ['search_query', 'category', 'search_count', 'last_searched']
|
| 33 |
+
list_filter = ['category']
|
| 34 |
+
search_fields = ['search_query']
|
| 35 |
+
ordering = ['-search_count', '-last_searched']
|
apps/analytics/apps.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from django.apps import AppConfig
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class AnalyticsConfig(AppConfig):
|
| 5 |
+
default_auto_field = 'django.db.models.BigAutoField'
|
| 6 |
+
name = 'apps.analytics'
|
| 7 |
+
verbose_name = 'Analytics & Tracking'
|
apps/analytics/migrations/0001_initial.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Generated by Django 4.2.7 on 2025-12-04 23:49
|
| 2 |
+
|
| 3 |
+
from django.conf import settings
|
| 4 |
+
from django.db import migrations, models
|
| 5 |
+
import django.db.models.deletion
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class Migration(migrations.Migration):
|
| 9 |
+
initial = True
|
| 10 |
+
|
| 11 |
+
dependencies = [
|
| 12 |
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
| 13 |
+
]
|
| 14 |
+
|
| 15 |
+
operations = [
|
| 16 |
+
migrations.CreateModel(
|
| 17 |
+
name="PopularSearch",
|
| 18 |
+
fields=[
|
| 19 |
+
(
|
| 20 |
+
"id",
|
| 21 |
+
models.BigAutoField(
|
| 22 |
+
auto_created=True,
|
| 23 |
+
primary_key=True,
|
| 24 |
+
serialize=False,
|
| 25 |
+
verbose_name="ID",
|
| 26 |
+
),
|
| 27 |
+
),
|
| 28 |
+
(
|
| 29 |
+
"category",
|
| 30 |
+
models.CharField(
|
| 31 |
+
choices=[
|
| 32 |
+
("QUESTIONS", "Questions du Forum"),
|
| 33 |
+
("MENTORS", "Recherche de Mentors"),
|
| 34 |
+
("OPPORTUNITIES", "Opportunités"),
|
| 35 |
+
("TOOLS", "Outils Pédagogiques"),
|
| 36 |
+
("USERS", "Utilisateurs"),
|
| 37 |
+
("GENERAL", "Recherche Générale"),
|
| 38 |
+
],
|
| 39 |
+
max_length=20,
|
| 40 |
+
),
|
| 41 |
+
),
|
| 42 |
+
("search_query", models.CharField(max_length=500)),
|
| 43 |
+
("search_count", models.IntegerField(default=0)),
|
| 44 |
+
("last_searched", models.DateTimeField(auto_now=True)),
|
| 45 |
+
],
|
| 46 |
+
options={
|
| 47 |
+
"verbose_name": "Recherche Populaire",
|
| 48 |
+
"verbose_name_plural": "Recherches Populaires",
|
| 49 |
+
"db_table": "popular_searches",
|
| 50 |
+
"ordering": ["-search_count", "-last_searched"],
|
| 51 |
+
"unique_together": {("category", "search_query")},
|
| 52 |
+
},
|
| 53 |
+
),
|
| 54 |
+
migrations.CreateModel(
|
| 55 |
+
name="SearchLog",
|
| 56 |
+
fields=[
|
| 57 |
+
(
|
| 58 |
+
"id",
|
| 59 |
+
models.BigAutoField(
|
| 60 |
+
auto_created=True,
|
| 61 |
+
primary_key=True,
|
| 62 |
+
serialize=False,
|
| 63 |
+
verbose_name="ID",
|
| 64 |
+
),
|
| 65 |
+
),
|
| 66 |
+
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
|
| 67 |
+
("updated_at", models.DateTimeField(auto_now=True)),
|
| 68 |
+
(
|
| 69 |
+
"category",
|
| 70 |
+
models.CharField(
|
| 71 |
+
choices=[
|
| 72 |
+
("QUESTIONS", "Questions du Forum"),
|
| 73 |
+
("MENTORS", "Recherche de Mentors"),
|
| 74 |
+
("OPPORTUNITIES", "Opportunités"),
|
| 75 |
+
("TOOLS", "Outils Pédagogiques"),
|
| 76 |
+
("USERS", "Utilisateurs"),
|
| 77 |
+
("GENERAL", "Recherche Générale"),
|
| 78 |
+
],
|
| 79 |
+
db_index=True,
|
| 80 |
+
help_text="Type de contenu recherché",
|
| 81 |
+
max_length=20,
|
| 82 |
+
),
|
| 83 |
+
),
|
| 84 |
+
(
|
| 85 |
+
"search_query",
|
| 86 |
+
models.CharField(
|
| 87 |
+
db_index=True,
|
| 88 |
+
help_text="Texte saisi par l'utilisateur",
|
| 89 |
+
max_length=500,
|
| 90 |
+
),
|
| 91 |
+
),
|
| 92 |
+
(
|
| 93 |
+
"filters_applied",
|
| 94 |
+
models.JSONField(
|
| 95 |
+
blank=True,
|
| 96 |
+
default=dict,
|
| 97 |
+
help_text='Filtres appliqués lors de la recherche (ex: {"status": "solved", "tags": ["math"]})',
|
| 98 |
+
),
|
| 99 |
+
),
|
| 100 |
+
(
|
| 101 |
+
"results_count",
|
| 102 |
+
models.IntegerField(
|
| 103 |
+
default=0, help_text="Nombre de résultats retournés"
|
| 104 |
+
),
|
| 105 |
+
),
|
| 106 |
+
(
|
| 107 |
+
"session_id",
|
| 108 |
+
models.CharField(
|
| 109 |
+
blank=True,
|
| 110 |
+
db_index=True,
|
| 111 |
+
help_text="ID de session pour regrouper les recherches d'une même visite",
|
| 112 |
+
max_length=100,
|
| 113 |
+
),
|
| 114 |
+
),
|
| 115 |
+
(
|
| 116 |
+
"ip_address",
|
| 117 |
+
models.GenericIPAddressField(
|
| 118 |
+
blank=True, help_text="Adresse IP de l'utilisateur", null=True
|
| 119 |
+
),
|
| 120 |
+
),
|
| 121 |
+
(
|
| 122 |
+
"user_agent",
|
| 123 |
+
models.TextField(blank=True, help_text="User agent du navigateur"),
|
| 124 |
+
),
|
| 125 |
+
(
|
| 126 |
+
"page_url",
|
| 127 |
+
models.CharField(
|
| 128 |
+
blank=True,
|
| 129 |
+
help_text="URL de la page où la recherche a été effectuée",
|
| 130 |
+
max_length=500,
|
| 131 |
+
),
|
| 132 |
+
),
|
| 133 |
+
(
|
| 134 |
+
"clicked_result_id",
|
| 135 |
+
models.CharField(
|
| 136 |
+
blank=True,
|
| 137 |
+
help_text="ID du résultat cliqué (si applicable)",
|
| 138 |
+
max_length=100,
|
| 139 |
+
null=True,
|
| 140 |
+
),
|
| 141 |
+
),
|
| 142 |
+
(
|
| 143 |
+
"clicked_result_position",
|
| 144 |
+
models.IntegerField(
|
| 145 |
+
blank=True,
|
| 146 |
+
help_text="Position du résultat cliqué dans la liste",
|
| 147 |
+
null=True,
|
| 148 |
+
),
|
| 149 |
+
),
|
| 150 |
+
(
|
| 151 |
+
"user",
|
| 152 |
+
models.ForeignKey(
|
| 153 |
+
blank=True,
|
| 154 |
+
help_text="Utilisateur qui a effectué la recherche (null si anonyme)",
|
| 155 |
+
null=True,
|
| 156 |
+
on_delete=django.db.models.deletion.SET_NULL,
|
| 157 |
+
related_name="search_logs",
|
| 158 |
+
to=settings.AUTH_USER_MODEL,
|
| 159 |
+
),
|
| 160 |
+
),
|
| 161 |
+
],
|
| 162 |
+
options={
|
| 163 |
+
"verbose_name": "Recherche",
|
| 164 |
+
"verbose_name_plural": "Recherches",
|
| 165 |
+
"db_table": "search_logs",
|
| 166 |
+
"ordering": ["-created_at"],
|
| 167 |
+
"indexes": [
|
| 168 |
+
models.Index(
|
| 169 |
+
fields=["category", "-created_at"],
|
| 170 |
+
name="search_logs_categor_24bab5_idx",
|
| 171 |
+
),
|
| 172 |
+
models.Index(
|
| 173 |
+
fields=["user", "-created_at"],
|
| 174 |
+
name="search_logs_user_id_30eff5_idx",
|
| 175 |
+
),
|
| 176 |
+
models.Index(
|
| 177 |
+
fields=["search_query"], name="search_logs_search__18b7a2_idx"
|
| 178 |
+
),
|
| 179 |
+
models.Index(
|
| 180 |
+
fields=["-created_at"], name="search_logs_created_9a6547_idx"
|
| 181 |
+
),
|
| 182 |
+
],
|
| 183 |
+
},
|
| 184 |
+
),
|
| 185 |
+
]
|
apps/analytics/migrations/__init__.py
ADDED
|
File without changes
|
apps/analytics/models.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================
|
| 2 |
+
# apps/analytics/models.py - Analytics & Search Tracking
|
| 3 |
+
# ============================================
|
| 4 |
+
from django.db import models
|
| 5 |
+
from apps.core.models import TimestampMixin
|
| 6 |
+
from apps.users.models import User
|
| 7 |
+
|
| 8 |
+
class SearchLog(TimestampMixin):
|
| 9 |
+
"""
|
| 10 |
+
Enregistre chaque recherche effectuée sur la plateforme
|
| 11 |
+
pour analyser les intérêts et comportements des utilisateurs
|
| 12 |
+
"""
|
| 13 |
+
CATEGORY_CHOICES = [
|
| 14 |
+
('QUESTIONS', 'Questions du Forum'),
|
| 15 |
+
('MENTORS', 'Recherche de Mentors'),
|
| 16 |
+
('OPPORTUNITIES', 'Opportunités'),
|
| 17 |
+
('TOOLS', 'Outils Pédagogiques'),
|
| 18 |
+
('USERS', 'Utilisateurs'),
|
| 19 |
+
('GENERAL', 'Recherche Générale'),
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
# Qui a effectué la recherche
|
| 23 |
+
user = models.ForeignKey(
|
| 24 |
+
User,
|
| 25 |
+
on_delete=models.SET_NULL,
|
| 26 |
+
null=True,
|
| 27 |
+
blank=True,
|
| 28 |
+
related_name='search_logs',
|
| 29 |
+
help_text="Utilisateur qui a effectué la recherche (null si anonyme)"
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
# Catégorie de la recherche
|
| 33 |
+
category = models.CharField(
|
| 34 |
+
max_length=20,
|
| 35 |
+
choices=CATEGORY_CHOICES,
|
| 36 |
+
db_index=True,
|
| 37 |
+
help_text="Type de contenu recherché"
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# Terme de recherche
|
| 41 |
+
search_query = models.CharField(
|
| 42 |
+
max_length=500,
|
| 43 |
+
db_index=True,
|
| 44 |
+
help_text="Texte saisi par l'utilisateur"
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
# Filtres appliqués (JSON pour flexibilité)
|
| 48 |
+
filters_applied = models.JSONField(
|
| 49 |
+
default=dict,
|
| 50 |
+
blank=True,
|
| 51 |
+
help_text="Filtres appliqués lors de la recherche (ex: {\"status\": \"solved\", \"tags\": [\"math\"]})"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
# Résultats
|
| 55 |
+
results_count = models.IntegerField(
|
| 56 |
+
default=0,
|
| 57 |
+
help_text="Nombre de résultats retournés"
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
# Métadonnées de session
|
| 61 |
+
session_id = models.CharField(
|
| 62 |
+
max_length=100,
|
| 63 |
+
blank=True,
|
| 64 |
+
db_index=True,
|
| 65 |
+
help_text="ID de session pour regrouper les recherches d'une même visite"
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
ip_address = models.GenericIPAddressField(
|
| 69 |
+
null=True,
|
| 70 |
+
blank=True,
|
| 71 |
+
help_text="Adresse IP de l'utilisateur"
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
user_agent = models.TextField(
|
| 75 |
+
blank=True,
|
| 76 |
+
help_text="User agent du navigateur"
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
# Page d'origine
|
| 80 |
+
page_url = models.CharField(
|
| 81 |
+
max_length=500,
|
| 82 |
+
blank=True,
|
| 83 |
+
help_text="URL de la page où la recherche a été effectuée"
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
# Interaction
|
| 87 |
+
clicked_result_id = models.CharField(
|
| 88 |
+
max_length=100,
|
| 89 |
+
null=True,
|
| 90 |
+
blank=True,
|
| 91 |
+
help_text="ID du résultat cliqué (si applicable)"
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
clicked_result_position = models.IntegerField(
|
| 95 |
+
null=True,
|
| 96 |
+
blank=True,
|
| 97 |
+
help_text="Position du résultat cliqué dans la liste"
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
class Meta:
|
| 101 |
+
db_table = 'search_logs'
|
| 102 |
+
verbose_name = 'Recherche'
|
| 103 |
+
verbose_name_plural = 'Recherches'
|
| 104 |
+
indexes = [
|
| 105 |
+
models.Index(fields=['category', '-created_at']),
|
| 106 |
+
models.Index(fields=['user', '-created_at']),
|
| 107 |
+
models.Index(fields=['search_query']),
|
| 108 |
+
models.Index(fields=['-created_at']),
|
| 109 |
+
]
|
| 110 |
+
ordering = ['-created_at']
|
| 111 |
+
|
| 112 |
+
def __str__(self):
|
| 113 |
+
user_info = self.user.email if self.user else "Anonyme"
|
| 114 |
+
return f"{user_info} - {self.category}: '{self.search_query}'"
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class PopularSearch(models.Model):
|
| 118 |
+
"""
|
| 119 |
+
Vue matérialisée des recherches les plus populaires
|
| 120 |
+
Mise à jour périodiquement pour optimiser les performances
|
| 121 |
+
"""
|
| 122 |
+
category = models.CharField(max_length=20, choices=SearchLog.CATEGORY_CHOICES)
|
| 123 |
+
search_query = models.CharField(max_length=500)
|
| 124 |
+
search_count = models.IntegerField(default=0)
|
| 125 |
+
last_searched = models.DateTimeField(auto_now=True)
|
| 126 |
+
|
| 127 |
+
class Meta:
|
| 128 |
+
db_table = 'popular_searches'
|
| 129 |
+
verbose_name = 'Recherche Populaire'
|
| 130 |
+
verbose_name_plural = 'Recherches Populaires'
|
| 131 |
+
unique_together = ['category', 'search_query']
|
| 132 |
+
ordering = ['-search_count', '-last_searched']
|
| 133 |
+
|
| 134 |
+
def __str__(self):
|
| 135 |
+
return f"{self.category}: '{self.search_query}' ({self.search_count}x)"
|
apps/analytics/serializers.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================
|
| 2 |
+
# apps/analytics/serializers.py
|
| 3 |
+
# ============================================
|
| 4 |
+
from rest_framework import serializers
|
| 5 |
+
from apps.analytics.models import SearchLog, PopularSearch
|
| 6 |
+
|
| 7 |
+
class SearchLogSerializer(serializers.ModelSerializer):
|
| 8 |
+
"""Serializer pour enregistrer une recherche"""
|
| 9 |
+
|
| 10 |
+
class Meta:
|
| 11 |
+
model = SearchLog
|
| 12 |
+
fields = [
|
| 13 |
+
'id', 'category', 'search_query', 'filters_applied',
|
| 14 |
+
'results_count', 'created_at'
|
| 15 |
+
]
|
| 16 |
+
read_only_fields = ['id', 'created_at']
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class PopularSearchSerializer(serializers.ModelSerializer):
|
| 20 |
+
"""Serializer pour les recherches populaires"""
|
| 21 |
+
|
| 22 |
+
class Meta:
|
| 23 |
+
model = PopularSearch
|
| 24 |
+
fields = ['category', 'search_query', 'search_count', 'last_searched']
|
apps/analytics/services.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================
|
| 2 |
+
# apps/analytics/services.py - Search Tracking Service
|
| 3 |
+
# ============================================
|
| 4 |
+
from apps.analytics.models import SearchLog, PopularSearch
|
| 5 |
+
from django.db.models import F
|
| 6 |
+
|
| 7 |
+
class SearchTrackingService:
|
| 8 |
+
"""Service centralisé pour tracker les recherches"""
|
| 9 |
+
|
| 10 |
+
@staticmethod
|
| 11 |
+
def log_search(
|
| 12 |
+
category: str,
|
| 13 |
+
search_query: str,
|
| 14 |
+
user=None,
|
| 15 |
+
filters_applied: dict = None,
|
| 16 |
+
results_count: int = 0,
|
| 17 |
+
request=None
|
| 18 |
+
):
|
| 19 |
+
"""
|
| 20 |
+
Enregistre une recherche dans les logs
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
category: Catégorie de recherche (QUESTIONS, MENTORS, etc.)
|
| 24 |
+
search_query: Terme recherché
|
| 25 |
+
user: Utilisateur qui effectue la recherche (None si anonyme)
|
| 26 |
+
filters_applied: Dict des filtres appliqués
|
| 27 |
+
results_count: Nombre de résultats retournés
|
| 28 |
+
request: Objet request Django pour extraire métadonnées
|
| 29 |
+
|
| 30 |
+
Returns:
|
| 31 |
+
SearchLog instance
|
| 32 |
+
"""
|
| 33 |
+
# Extraire métadonnées de la requête si disponible
|
| 34 |
+
session_id = ''
|
| 35 |
+
ip_address = None
|
| 36 |
+
user_agent = ''
|
| 37 |
+
page_url = ''
|
| 38 |
+
|
| 39 |
+
if request:
|
| 40 |
+
session_id = request.session.session_key or ''
|
| 41 |
+
ip_address = request.META.get('REMOTE_ADDR')
|
| 42 |
+
user_agent = request.META.get('HTTP_USER_AGENT', '')[:500]
|
| 43 |
+
page_url = request.META.get('HTTP_REFERER', '')[:500]
|
| 44 |
+
|
| 45 |
+
# Créer le log
|
| 46 |
+
search_log = SearchLog.objects.create(
|
| 47 |
+
user=user,
|
| 48 |
+
category=category,
|
| 49 |
+
search_query=search_query.strip()[:500],
|
| 50 |
+
filters_applied=filters_applied or {},
|
| 51 |
+
results_count=results_count,
|
| 52 |
+
session_id=session_id,
|
| 53 |
+
ip_address=ip_address,
|
| 54 |
+
user_agent=user_agent,
|
| 55 |
+
page_url=page_url
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
# Mettre à jour les recherches populaires
|
| 59 |
+
SearchTrackingService._update_popular_search(category, search_query.strip())
|
| 60 |
+
|
| 61 |
+
return search_log
|
| 62 |
+
|
| 63 |
+
@staticmethod
|
| 64 |
+
def _update_popular_search(category: str, search_query: str):
|
| 65 |
+
"""Mise à jour incrémentale des recherches populaires"""
|
| 66 |
+
if not search_query or len(search_query) < 2:
|
| 67 |
+
return
|
| 68 |
+
|
| 69 |
+
popular, created = PopularSearch.objects.get_or_create(
|
| 70 |
+
category=category,
|
| 71 |
+
search_query=search_query[:500],
|
| 72 |
+
defaults={'search_count': 1}
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
if not created:
|
| 76 |
+
popular.search_count = F('search_count') + 1
|
| 77 |
+
popular.save(update_fields=['search_count', 'last_searched'])
|
| 78 |
+
|
| 79 |
+
@staticmethod
|
| 80 |
+
def log_result_click(search_log_id: int, result_id: str, position: int):
|
| 81 |
+
"""
|
| 82 |
+
Enregistre le clic sur un résultat de recherche
|
| 83 |
+
|
| 84 |
+
Args:
|
| 85 |
+
search_log_id: ID du SearchLog
|
| 86 |
+
result_id: ID du résultat cliqué
|
| 87 |
+
position: Position dans la liste (0-indexed)
|
| 88 |
+
"""
|
| 89 |
+
try:
|
| 90 |
+
search_log = SearchLog.objects.get(id=search_log_id)
|
| 91 |
+
search_log.clicked_result_id = str(result_id)
|
| 92 |
+
search_log.clicked_result_position = position
|
| 93 |
+
search_log.save(update_fields=['clicked_result_id', 'clicked_result_position'])
|
| 94 |
+
except SearchLog.DoesNotExist:
|
| 95 |
+
pass
|
| 96 |
+
|
| 97 |
+
@staticmethod
|
| 98 |
+
def get_popular_searches(category: str = None, limit: int = 10):
|
| 99 |
+
"""
|
| 100 |
+
Récupère les recherches les plus populaires
|
| 101 |
+
|
| 102 |
+
Args:
|
| 103 |
+
category: Filtrer par catégorie (None = toutes)
|
| 104 |
+
limit: Nombre max de résultats
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
QuerySet de PopularSearch
|
| 108 |
+
"""
|
| 109 |
+
qs = PopularSearch.objects.all()
|
| 110 |
+
|
| 111 |
+
if category:
|
| 112 |
+
qs = qs.filter(category=category)
|
| 113 |
+
|
| 114 |
+
return qs[:limit]
|
| 115 |
+
|
| 116 |
+
@staticmethod
|
| 117 |
+
def get_trending_searches(category: str = None, days: int = 7, limit: int = 10):
|
| 118 |
+
"""
|
| 119 |
+
Récupère les recherches tendances (populaires récemment)
|
| 120 |
+
|
| 121 |
+
Args:
|
| 122 |
+
category: Filtrer par catégorie
|
| 123 |
+
days: Nombre de jours à considérer
|
| 124 |
+
limit: Nombre max de résultats
|
| 125 |
+
|
| 126 |
+
Returns:
|
| 127 |
+
Liste de dicts avec query et count
|
| 128 |
+
"""
|
| 129 |
+
from django.utils import timezone
|
| 130 |
+
from datetime import timedelta
|
| 131 |
+
from django.db.models import Count
|
| 132 |
+
|
| 133 |
+
since = timezone.now() - timedelta(days=days)
|
| 134 |
+
|
| 135 |
+
qs = SearchLog.objects.filter(created_at__gte=since)
|
| 136 |
+
|
| 137 |
+
if category:
|
| 138 |
+
qs = qs.filter(category=category)
|
| 139 |
+
|
| 140 |
+
trending = qs.values('search_query').annotate(
|
| 141 |
+
count=Count('id')
|
| 142 |
+
).order_by('-count')[:limit]
|
| 143 |
+
|
| 144 |
+
return list(trending)
|
apps/analytics/tests.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from django.test import TestCase
|
| 2 |
+
|
| 3 |
+
# Create your tests here.
|
apps/analytics/urls.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from django.urls import path
|
| 2 |
+
from apps.analytics import views
|
| 3 |
+
|
| 4 |
+
urlpatterns = [
|
| 5 |
+
path('search-log/', views.log_search, name='log-search'),
|
| 6 |
+
path('result-click/', views.log_result_click, name='log-result-click'),
|
| 7 |
+
path('popular-searches/', views.popular_searches, name='popular-searches'),
|
| 8 |
+
path('trending-searches/', views.trending_searches, name='trending-searches'),
|
| 9 |
+
]
|
apps/analytics/views.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================
|
| 2 |
+
# apps/analytics/views.py
|
| 3 |
+
# ============================================
|
| 4 |
+
from rest_framework import status
|
| 5 |
+
from rest_framework.decorators import api_view, permission_classes
|
| 6 |
+
from rest_framework.permissions import AllowAny
|
| 7 |
+
from rest_framework.response import Response
|
| 8 |
+
from apps.analytics.services import SearchTrackingService
|
| 9 |
+
from apps.analytics.serializers import SearchLogSerializer, PopularSearchSerializer
|
| 10 |
+
|
| 11 |
+
@api_view(['POST'])
|
| 12 |
+
@permission_classes([AllowAny])
|
| 13 |
+
def log_search(request):
|
| 14 |
+
"""
|
| 15 |
+
Enregistrer une recherche
|
| 16 |
+
POST /api/analytics/search-log/
|
| 17 |
+
|
| 18 |
+
Body:
|
| 19 |
+
{
|
| 20 |
+
"category": "QUESTIONS",
|
| 21 |
+
"search_query": "python",
|
| 22 |
+
"filters_applied": {"status": "unsolved"},
|
| 23 |
+
"results_count": 15
|
| 24 |
+
}
|
| 25 |
+
"""
|
| 26 |
+
serializer = SearchLogSerializer(data=request.data)
|
| 27 |
+
|
| 28 |
+
if serializer.is_valid():
|
| 29 |
+
user = request.user if request.user.is_authenticated else None
|
| 30 |
+
|
| 31 |
+
search_log = SearchTrackingService.log_search(
|
| 32 |
+
category=serializer.validated_data['category'],
|
| 33 |
+
search_query=serializer.validated_data['search_query'],
|
| 34 |
+
user=user,
|
| 35 |
+
filters_applied=serializer.validated_data.get('filters_applied', {}),
|
| 36 |
+
results_count=serializer.validated_data.get('results_count', 0),
|
| 37 |
+
request=request
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
return Response(
|
| 41 |
+
SearchLogSerializer(search_log).data,
|
| 42 |
+
status=status.HTTP_201_CREATED
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@api_view(['POST'])
|
| 49 |
+
@permission_classes([AllowAny])
|
| 50 |
+
def log_result_click(request):
|
| 51 |
+
"""
|
| 52 |
+
Enregistrer le clic sur un résultat
|
| 53 |
+
POST /api/analytics/result-click/
|
| 54 |
+
|
| 55 |
+
Body:
|
| 56 |
+
{
|
| 57 |
+
"search_log_id": 123,
|
| 58 |
+
"result_id": "456",
|
| 59 |
+
"position": 2
|
| 60 |
+
}
|
| 61 |
+
"""
|
| 62 |
+
search_log_id = request.data.get('search_log_id')
|
| 63 |
+
result_id = request.data.get('result_id')
|
| 64 |
+
position = request.data.get('position')
|
| 65 |
+
|
| 66 |
+
if not all([search_log_id, result_id, position is not None]):
|
| 67 |
+
return Response(
|
| 68 |
+
{'error': 'Missing required fields'},
|
| 69 |
+
status=status.HTTP_400_BAD_REQUEST
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
SearchTrackingService.log_result_click(
|
| 73 |
+
search_log_id=search_log_id,
|
| 74 |
+
result_id=result_id,
|
| 75 |
+
position=position
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
return Response({'status': 'success'}, status=status.HTTP_200_OK)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@api_view(['GET'])
|
| 82 |
+
@permission_classes([AllowAny])
|
| 83 |
+
def popular_searches(request):
|
| 84 |
+
"""
|
| 85 |
+
Récupérer les recherches populaires
|
| 86 |
+
GET /api/analytics/popular-searches/?category=QUESTIONS&limit=10
|
| 87 |
+
"""
|
| 88 |
+
category = request.query_params.get('category')
|
| 89 |
+
limit = int(request.query_params.get('limit', 10))
|
| 90 |
+
|
| 91 |
+
searches = SearchTrackingService.get_popular_searches(
|
| 92 |
+
category=category,
|
| 93 |
+
limit=min(limit, 50) # Max 50
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
serializer = PopularSearchSerializer(searches, many=True)
|
| 97 |
+
return Response(serializer.data)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@api_view(['GET'])
|
| 101 |
+
@permission_classes([AllowAny])
|
| 102 |
+
def trending_searches(request):
|
| 103 |
+
"""
|
| 104 |
+
Récupérer les recherches tendances
|
| 105 |
+
GET /api/analytics/trending-searches/?category=QUESTIONS&days=7&limit=10
|
| 106 |
+
"""
|
| 107 |
+
category = request.query_params.get('category')
|
| 108 |
+
days = int(request.query_params.get('days', 7))
|
| 109 |
+
limit = int(request.query_params.get('limit', 10))
|
| 110 |
+
|
| 111 |
+
trending = SearchTrackingService.get_trending_searches(
|
| 112 |
+
category=category,
|
| 113 |
+
days=min(days, 30), # Max 30 jours
|
| 114 |
+
limit=min(limit, 50)
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
return Response(trending)
|
apps/bookings/__init__.py
ADDED
|
File without changes
|
apps/bookings/admin.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from django.contrib import admin
|
| 2 |
+
from .models import (
|
| 3 |
+
Booking, BookingDomain, BookingExpectation,
|
| 4 |
+
BookingMainQuestion, BookingStatusHistory
|
| 5 |
+
)
|
| 6 |
+
|
| 7 |
+
class BookingDomainInline(admin.TabularInline):
|
| 8 |
+
model = BookingDomain
|
| 9 |
+
extra = 0
|
| 10 |
+
|
| 11 |
+
class BookingExpectationInline(admin.StackedInline):
|
| 12 |
+
model = BookingExpectation
|
| 13 |
+
extra = 0
|
| 14 |
+
|
| 15 |
+
class BookingMainQuestionInline(admin.StackedInline):
|
| 16 |
+
model = BookingMainQuestion
|
| 17 |
+
extra = 0
|
| 18 |
+
|
| 19 |
+
@admin.register(Booking)
|
| 20 |
+
class BookingAdmin(admin.ModelAdmin):
|
| 21 |
+
list_display = ('id', 'student', 'mentor', 'date', 'time', 'status', 'created_at')
|
| 22 |
+
list_filter = ('status', 'date', 'created_at')
|
| 23 |
+
search_fields = ('student__email', 'mentor__email')
|
| 24 |
+
inlines = [BookingDomainInline, BookingExpectationInline, BookingMainQuestionInline]
|
| 25 |
+
|
| 26 |
+
@admin.register(BookingStatusHistory)
|
| 27 |
+
class BookingStatusHistoryAdmin(admin.ModelAdmin):
|
| 28 |
+
list_display = ('booking', 'previous_status', 'new_status', 'changed_by', 'created_at')
|
| 29 |
+
list_filter = ('new_status', 'created_at')
|
apps/bookings/apps.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from django.apps import AppConfig
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class BookingsConfig(AppConfig):
|
| 5 |
+
default_auto_field = 'django.db.models.BigAutoField'
|
| 6 |
+
name = 'apps.bookings'
|
| 7 |
+
|
| 8 |
+
def ready(self):
|
| 9 |
+
import apps.bookings.signals # noqa
|
apps/bookings/migrations/0001_initial.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Generated by Django 4.2.7 on 2025-11-29 13:38
|
| 2 |
+
|
| 3 |
+
from django.db import migrations, models
|
| 4 |
+
import django.db.models.deletion
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Migration(migrations.Migration):
|
| 8 |
+
initial = True
|
| 9 |
+
|
| 10 |
+
dependencies = []
|
| 11 |
+
|
| 12 |
+
operations = [
|
| 13 |
+
migrations.CreateModel(
|
| 14 |
+
name="Booking",
|
| 15 |
+
fields=[
|
| 16 |
+
(
|
| 17 |
+
"id",
|
| 18 |
+
models.BigAutoField(
|
| 19 |
+
auto_created=True,
|
| 20 |
+
primary_key=True,
|
| 21 |
+
serialize=False,
|
| 22 |
+
verbose_name="ID",
|
| 23 |
+
),
|
| 24 |
+
),
|
| 25 |
+
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
|
| 26 |
+
("updated_at", models.DateTimeField(auto_now=True)),
|
| 27 |
+
(
|
| 28 |
+
"deleted_at",
|
| 29 |
+
models.DateTimeField(blank=True, db_index=True, null=True),
|
| 30 |
+
),
|
| 31 |
+
("is_active", models.BooleanField(db_index=True, default=True)),
|
| 32 |
+
("date", models.DateField(db_index=True)),
|
| 33 |
+
("time", models.TimeField()),
|
| 34 |
+
(
|
| 35 |
+
"status",
|
| 36 |
+
models.CharField(
|
| 37 |
+
choices=[
|
| 38 |
+
("PENDING", "En attente"),
|
| 39 |
+
("CONFIRMED", "Confirmé"),
|
| 40 |
+
("REJECTED", "Refusé"),
|
| 41 |
+
("COMPLETED", "Terminé"),
|
| 42 |
+
("CANCELLED", "Annulé"),
|
| 43 |
+
],
|
| 44 |
+
db_index=True,
|
| 45 |
+
default="PENDING",
|
| 46 |
+
max_length=20,
|
| 47 |
+
),
|
| 48 |
+
),
|
| 49 |
+
],
|
| 50 |
+
options={
|
| 51 |
+
"verbose_name": "Réservation",
|
| 52 |
+
"verbose_name_plural": "Réservations",
|
| 53 |
+
"db_table": "bookings",
|
| 54 |
+
"ordering": ["-created_at"],
|
| 55 |
+
},
|
| 56 |
+
),
|
| 57 |
+
migrations.CreateModel(
|
| 58 |
+
name="BookingDomain",
|
| 59 |
+
fields=[
|
| 60 |
+
(
|
| 61 |
+
"id",
|
| 62 |
+
models.BigAutoField(
|
| 63 |
+
auto_created=True,
|
| 64 |
+
primary_key=True,
|
| 65 |
+
serialize=False,
|
| 66 |
+
verbose_name="ID",
|
| 67 |
+
),
|
| 68 |
+
),
|
| 69 |
+
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
|
| 70 |
+
("updated_at", models.DateTimeField(auto_now=True)),
|
| 71 |
+
("domain", models.CharField(max_length=100)),
|
| 72 |
+
],
|
| 73 |
+
options={
|
| 74 |
+
"db_table": "booking_domains",
|
| 75 |
+
},
|
| 76 |
+
),
|
| 77 |
+
migrations.CreateModel(
|
| 78 |
+
name="BookingExpectation",
|
| 79 |
+
fields=[
|
| 80 |
+
(
|
| 81 |
+
"id",
|
| 82 |
+
models.BigAutoField(
|
| 83 |
+
auto_created=True,
|
| 84 |
+
primary_key=True,
|
| 85 |
+
serialize=False,
|
| 86 |
+
verbose_name="ID",
|
| 87 |
+
),
|
| 88 |
+
),
|
| 89 |
+
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
|
| 90 |
+
("updated_at", models.DateTimeField(auto_now=True)),
|
| 91 |
+
("is_current", models.BooleanField(db_index=True, default=True)),
|
| 92 |
+
("expectation", models.TextField()),
|
| 93 |
+
],
|
| 94 |
+
options={
|
| 95 |
+
"db_table": "booking_expectations",
|
| 96 |
+
},
|
| 97 |
+
),
|
| 98 |
+
migrations.CreateModel(
|
| 99 |
+
name="BookingMainQuestion",
|
| 100 |
+
fields=[
|
| 101 |
+
(
|
| 102 |
+
"id",
|
| 103 |
+
models.BigAutoField(
|
| 104 |
+
auto_created=True,
|
| 105 |
+
primary_key=True,
|
| 106 |
+
serialize=False,
|
| 107 |
+
verbose_name="ID",
|
| 108 |
+
),
|
| 109 |
+
),
|
| 110 |
+
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
|
| 111 |
+
("updated_at", models.DateTimeField(auto_now=True)),
|
| 112 |
+
("is_current", models.BooleanField(db_index=True, default=True)),
|
| 113 |
+
("question", models.TextField()),
|
| 114 |
+
],
|
| 115 |
+
options={
|
| 116 |
+
"db_table": "booking_main_questions",
|
| 117 |
+
},
|
| 118 |
+
),
|
| 119 |
+
migrations.CreateModel(
|
| 120 |
+
name="BookingStatusHistory",
|
| 121 |
+
fields=[
|
| 122 |
+
(
|
| 123 |
+
"id",
|
| 124 |
+
models.BigAutoField(
|
| 125 |
+
auto_created=True,
|
| 126 |
+
primary_key=True,
|
| 127 |
+
serialize=False,
|
| 128 |
+
verbose_name="ID",
|
| 129 |
+
),
|
| 130 |
+
),
|
| 131 |
+
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
|
| 132 |
+
("updated_at", models.DateTimeField(auto_now=True)),
|
| 133 |
+
("previous_status", models.CharField(max_length=20)),
|
| 134 |
+
("new_status", models.CharField(max_length=20)),
|
| 135 |
+
("reason", models.TextField(blank=True, null=True)),
|
| 136 |
+
(
|
| 137 |
+
"booking",
|
| 138 |
+
models.ForeignKey(
|
| 139 |
+
on_delete=django.db.models.deletion.CASCADE,
|
| 140 |
+
related_name="status_history",
|
| 141 |
+
to="bookings.booking",
|
| 142 |
+
),
|
| 143 |
+
),
|
| 144 |
+
],
|
| 145 |
+
options={
|
| 146 |
+
"db_table": "booking_status_history",
|
| 147 |
+
"ordering": ["-created_at"],
|
| 148 |
+
},
|
| 149 |
+
),
|
| 150 |
+
]
|
apps/bookings/migrations/0002_initial.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Generated by Django 4.2.7 on 2025-11-29 13:38
|
| 2 |
+
|
| 3 |
+
from django.conf import settings
|
| 4 |
+
from django.db import migrations, models
|
| 5 |
+
import django.db.models.deletion
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class Migration(migrations.Migration):
|
| 9 |
+
initial = True
|
| 10 |
+
|
| 11 |
+
dependencies = [
|
| 12 |
+
("bookings", "0001_initial"),
|
| 13 |
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
operations = [
|
| 17 |
+
migrations.AddField(
|
| 18 |
+
model_name="bookingstatushistory",
|
| 19 |
+
name="changed_by",
|
| 20 |
+
field=models.ForeignKey(
|
| 21 |
+
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
|
| 22 |
+
),
|
| 23 |
+
),
|
| 24 |
+
migrations.AddField(
|
| 25 |
+
model_name="bookingmainquestion",
|
| 26 |
+
name="booking",
|
| 27 |
+
field=models.ForeignKey(
|
| 28 |
+
on_delete=django.db.models.deletion.CASCADE,
|
| 29 |
+
related_name="main_questions",
|
| 30 |
+
to="bookings.booking",
|
| 31 |
+
),
|
| 32 |
+
),
|
| 33 |
+
migrations.AddField(
|
| 34 |
+
model_name="bookingexpectation",
|
| 35 |
+
name="booking",
|
| 36 |
+
field=models.ForeignKey(
|
| 37 |
+
on_delete=django.db.models.deletion.CASCADE,
|
| 38 |
+
related_name="expectations",
|
| 39 |
+
to="bookings.booking",
|
| 40 |
+
),
|
| 41 |
+
),
|
| 42 |
+
migrations.AddField(
|
| 43 |
+
model_name="bookingdomain",
|
| 44 |
+
name="booking",
|
| 45 |
+
field=models.ForeignKey(
|
| 46 |
+
on_delete=django.db.models.deletion.CASCADE,
|
| 47 |
+
related_name="domains",
|
| 48 |
+
to="bookings.booking",
|
| 49 |
+
),
|
| 50 |
+
),
|
| 51 |
+
migrations.AddField(
|
| 52 |
+
model_name="booking",
|
| 53 |
+
name="mentor",
|
| 54 |
+
field=models.ForeignKey(
|
| 55 |
+
on_delete=django.db.models.deletion.CASCADE,
|
| 56 |
+
related_name="bookings_as_mentor",
|
| 57 |
+
to=settings.AUTH_USER_MODEL,
|
| 58 |
+
),
|
| 59 |
+
),
|
| 60 |
+
migrations.AddField(
|
| 61 |
+
model_name="booking",
|
| 62 |
+
name="student",
|
| 63 |
+
field=models.ForeignKey(
|
| 64 |
+
on_delete=django.db.models.deletion.CASCADE,
|
| 65 |
+
related_name="bookings_as_student",
|
| 66 |
+
to=settings.AUTH_USER_MODEL,
|
| 67 |
+
),
|
| 68 |
+
),
|
| 69 |
+
migrations.AddIndex(
|
| 70 |
+
model_name="booking",
|
| 71 |
+
index=models.Index(
|
| 72 |
+
fields=["student", "status", "is_active"],
|
| 73 |
+
name="bookings_student_84c0c2_idx",
|
| 74 |
+
),
|
| 75 |
+
),
|
| 76 |
+
migrations.AddIndex(
|
| 77 |
+
model_name="booking",
|
| 78 |
+
index=models.Index(
|
| 79 |
+
fields=["mentor", "status", "is_active"],
|
| 80 |
+
name="bookings_mentor__d43236_idx",
|
| 81 |
+
),
|
| 82 |
+
),
|
| 83 |
+
migrations.AddIndex(
|
| 84 |
+
model_name="booking",
|
| 85 |
+
index=models.Index(
|
| 86 |
+
fields=["date", "time"], name="bookings_date_44cf2c_idx"
|
| 87 |
+
),
|
| 88 |
+
),
|
| 89 |
+
]
|
apps/bookings/migrations/__init__.py
ADDED
|
File without changes
|
apps/bookings/models.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================
|
| 2 |
+
# apps/bookings/models.py - Système Réservation
|
| 3 |
+
# ============================================
|
| 4 |
+
from django.db import models
|
| 5 |
+
from apps.core.models import TimestampMixin, SoftDeleteMixin, VersionedFieldMixin
|
| 6 |
+
from apps.users.models import User
|
| 7 |
+
|
| 8 |
+
class Booking(TimestampMixin, SoftDeleteMixin):
|
| 9 |
+
"""Réservation de session avec mentor"""
|
| 10 |
+
STATUS_CHOICES = [
|
| 11 |
+
('PENDING', 'En attente'),
|
| 12 |
+
('CONFIRMED', 'Confirmé'),
|
| 13 |
+
('REJECTED', 'Refusé'),
|
| 14 |
+
('COMPLETED', 'Terminé'),
|
| 15 |
+
('CANCELLED', 'Annulé'),
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
student = models.ForeignKey(User, on_delete=models.CASCADE, related_name='bookings_as_student')
|
| 19 |
+
mentor = models.ForeignKey(User, on_delete=models.CASCADE, related_name='bookings_as_mentor')
|
| 20 |
+
date = models.DateField(db_index=True)
|
| 21 |
+
time = models.TimeField()
|
| 22 |
+
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='PENDING', db_index=True)
|
| 23 |
+
|
| 24 |
+
class Meta:
|
| 25 |
+
db_table = 'bookings'
|
| 26 |
+
verbose_name = 'Réservation'
|
| 27 |
+
verbose_name_plural = 'Réservations'
|
| 28 |
+
indexes = [
|
| 29 |
+
models.Index(fields=['student', 'status', 'is_active']),
|
| 30 |
+
models.Index(fields=['mentor', 'status', 'is_active']),
|
| 31 |
+
models.Index(fields=['date', 'time']),
|
| 32 |
+
]
|
| 33 |
+
ordering = ['-created_at']
|
| 34 |
+
|
| 35 |
+
def __str__(self):
|
| 36 |
+
return f"Booking #{self.id}: {self.student.email} -> {self.mentor.email}"
|
| 37 |
+
|
| 38 |
+
class BookingDomain(TimestampMixin):
|
| 39 |
+
booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name='domains')
|
| 40 |
+
domain = models.CharField(max_length=100)
|
| 41 |
+
|
| 42 |
+
class Meta:
|
| 43 |
+
db_table = 'booking_domains'
|
| 44 |
+
|
| 45 |
+
class BookingExpectation(TimestampMixin, VersionedFieldMixin):
|
| 46 |
+
booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name='expectations')
|
| 47 |
+
expectation = models.TextField()
|
| 48 |
+
|
| 49 |
+
class Meta:
|
| 50 |
+
db_table = 'booking_expectations'
|
| 51 |
+
|
| 52 |
+
class BookingMainQuestion(TimestampMixin, VersionedFieldMixin):
|
| 53 |
+
booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name='main_questions')
|
| 54 |
+
question = models.TextField()
|
| 55 |
+
|
| 56 |
+
class Meta:
|
| 57 |
+
db_table = 'booking_main_questions'
|
| 58 |
+
|
| 59 |
+
class BookingStatusHistory(TimestampMixin):
|
| 60 |
+
"""Traçabilité des changements de statut"""
|
| 61 |
+
booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name='status_history')
|
| 62 |
+
previous_status = models.CharField(max_length=20)
|
| 63 |
+
new_status = models.CharField(max_length=20)
|
| 64 |
+
changed_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
| 65 |
+
reason = models.TextField(null=True, blank=True)
|
| 66 |
+
|
| 67 |
+
class Meta:
|
| 68 |
+
db_table = 'booking_status_history'
|
| 69 |
+
ordering = ['-created_at']
|
apps/bookings/serializers.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================
|
| 2 |
+
# apps/bookings/serializers.py
|
| 3 |
+
# ============================================
|
| 4 |
+
from rest_framework import serializers
|
| 5 |
+
from apps.bookings.models import (
|
| 6 |
+
Booking, BookingDomain, BookingExpectation, BookingMainQuestion
|
| 7 |
+
)
|
| 8 |
+
from apps.users.serializers import UserSerializer
|
| 9 |
+
from apps.core.serializers import HashIdField
|
| 10 |
+
|
| 11 |
+
class BookingSerializer(serializers.ModelSerializer):
|
| 12 |
+
student = UserSerializer(read_only=True)
|
| 13 |
+
mentor = UserSerializer(read_only=True)
|
| 14 |
+
domains = serializers.SerializerMethodField()
|
| 15 |
+
expectation = serializers.SerializerMethodField()
|
| 16 |
+
main_question = serializers.SerializerMethodField()
|
| 17 |
+
status_label = serializers.CharField(source='get_status_display', read_only=True)
|
| 18 |
+
id = HashIdField(read_only=True)
|
| 19 |
+
|
| 20 |
+
class Meta:
|
| 21 |
+
model = Booking
|
| 22 |
+
fields = [
|
| 23 |
+
'id', 'student', 'mentor', 'date', 'time', 'status', 'status_label',
|
| 24 |
+
'domains', 'expectation', 'main_question', 'created_at', 'updated_at'
|
| 25 |
+
]
|
| 26 |
+
read_only_fields = ['id', 'student', 'created_at', 'updated_at']
|
| 27 |
+
|
| 28 |
+
def get_domains(self, obj):
|
| 29 |
+
return [d.domain for d in obj.domains.all()]
|
| 30 |
+
|
| 31 |
+
def get_expectation(self, obj):
|
| 32 |
+
exp = obj.expectations.filter(is_current=True).first()
|
| 33 |
+
return exp.expectation if exp else None
|
| 34 |
+
|
| 35 |
+
def get_main_question(self, obj):
|
| 36 |
+
q = obj.main_questions.filter(is_current=True).first()
|
| 37 |
+
return q.question if q else None
|
| 38 |
+
|
| 39 |
+
class BookingCreateSerializer(serializers.Serializer):
|
| 40 |
+
"""Création d'une réservation"""
|
| 41 |
+
mentor_id = HashIdField()
|
| 42 |
+
date = serializers.DateField()
|
| 43 |
+
time = serializers.TimeField()
|
| 44 |
+
domains = serializers.ListField(
|
| 45 |
+
child=serializers.CharField(max_length=100),
|
| 46 |
+
min_length=1
|
| 47 |
+
)
|
| 48 |
+
expectations = serializers.CharField()
|
| 49 |
+
main_questions = serializers.CharField()
|
| 50 |
+
|
| 51 |
+
def validate_mentor_id(self, value):
|
| 52 |
+
from apps.users.models import User
|
| 53 |
+
try:
|
| 54 |
+
mentor = User.objects.get(id=value, role='MENTOR', is_active=True)
|
| 55 |
+
if not hasattr(mentor, 'mentor_profile'):
|
| 56 |
+
raise serializers.ValidationError("Ce mentor n'a pas de profil actif")
|
| 57 |
+
except User.DoesNotExist:
|
| 58 |
+
raise serializers.ValidationError("Mentor introuvable")
|
| 59 |
+
return value
|
| 60 |
+
|
| 61 |
+
def validate_date(self, value):
|
| 62 |
+
from datetime import date
|
| 63 |
+
if value < date.today():
|
| 64 |
+
raise serializers.ValidationError("La date doit être dans le futur")
|
| 65 |
+
return value
|
| 66 |
+
|
| 67 |
+
def create(self, validated_data):
|
| 68 |
+
from django.db import transaction
|
| 69 |
+
from apps.users.models import User
|
| 70 |
+
|
| 71 |
+
user = self.context['request'].user
|
| 72 |
+
mentor = User.objects.get(id=validated_data['mentor_id'])
|
| 73 |
+
domains = validated_data.pop('domains')
|
| 74 |
+
expectations = validated_data.pop('expectations')
|
| 75 |
+
main_question = validated_data.pop('main_questions')
|
| 76 |
+
validated_data.pop('mentor_id')
|
| 77 |
+
|
| 78 |
+
with transaction.atomic():
|
| 79 |
+
booking = Booking.objects.create(
|
| 80 |
+
student=user,
|
| 81 |
+
mentor=mentor,
|
| 82 |
+
**validated_data
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
# Ajouter domaines
|
| 86 |
+
for domain in domains:
|
| 87 |
+
BookingDomain.objects.create(booking=booking, domain=domain)
|
| 88 |
+
|
| 89 |
+
# Ajouter attentes
|
| 90 |
+
BookingExpectation.objects.create(
|
| 91 |
+
booking=booking,
|
| 92 |
+
expectation=expectations
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
# Ajouter question principale
|
| 96 |
+
BookingMainQuestion.objects.create(
|
| 97 |
+
booking=booking,
|
| 98 |
+
question=main_question
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
# Créer notification pour le mentor
|
| 102 |
+
from apps.notifications.services import NotificationService
|
| 103 |
+
NotificationService.create_booking_notification(booking)
|
| 104 |
+
|
| 105 |
+
return booking
|
| 106 |
+
|
| 107 |
+
class BookingStatusUpdateSerializer(serializers.Serializer):
|
| 108 |
+
"""Mise à jour du statut"""
|
| 109 |
+
status = serializers.ChoiceField(choices=['CONFIRMED', 'REJECTED', 'COMPLETED', 'CANCELLED'])
|
| 110 |
+
reason = serializers.CharField(required=False, allow_blank=True)
|
| 111 |
+
|
| 112 |
+
def update(self, instance, validated_data):
|
| 113 |
+
from apps.bookings.models import BookingStatusHistory
|
| 114 |
+
from django.db import transaction
|
| 115 |
+
|
| 116 |
+
with transaction.atomic():
|
| 117 |
+
previous_status = instance.status
|
| 118 |
+
instance.status = validated_data['status']
|
| 119 |
+
instance.save()
|
| 120 |
+
|
| 121 |
+
# Historiser
|
| 122 |
+
BookingStatusHistory.objects.create(
|
| 123 |
+
booking=instance,
|
| 124 |
+
previous_status=previous_status,
|
| 125 |
+
new_status=validated_data['status'],
|
| 126 |
+
changed_by=self.context['request'].user,
|
| 127 |
+
reason=validated_data.get('reason', '')
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
# Créer notification pour l'étudiant
|
| 131 |
+
from apps.notifications.services import NotificationService
|
| 132 |
+
NotificationService.create_booking_status_notification(instance)
|
| 133 |
+
|
| 134 |
+
# Attribution de points si complété
|
| 135 |
+
if validated_data['status'] == 'COMPLETED':
|
| 136 |
+
from django.conf import settings
|
| 137 |
+
instance.student.add_points(
|
| 138 |
+
settings.GAMIFICATION_POINTS['BOOKING_COMPLETED'],
|
| 139 |
+
'booking_completed'
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
return instance
|
apps/bookings/signals.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================
|
| 2 |
+
# apps/bookings/signals.py - Signaux pour les réservations
|
| 3 |
+
# ============================================
|
| 4 |
+
from django.db.models.signals import post_save
|
| 5 |
+
from django.dispatch import receiver
|
| 6 |
+
import datetime
|
| 7 |
+
import logging
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@receiver(post_save, sender='bookings.Booking')
|
| 13 |
+
def schedule_tasks_on_confirm(sender, instance, created, **kwargs):
|
| 14 |
+
"""
|
| 15 |
+
Quand un booking passe en CONFIRMED:
|
| 16 |
+
1. Planifie le déblocage des messages
|
| 17 |
+
2. Planifie les rappels de rendez-vous
|
| 18 |
+
"""
|
| 19 |
+
from django.utils import timezone
|
| 20 |
+
|
| 21 |
+
# Ne traiter que les bookings confirmés
|
| 22 |
+
if instance.status != 'CONFIRMED':
|
| 23 |
+
return
|
| 24 |
+
|
| 25 |
+
# Calculer l'heure de début
|
| 26 |
+
booking_start = datetime.datetime.combine(instance.date, instance.time)
|
| 27 |
+
booking_start = timezone.make_aware(booking_start)
|
| 28 |
+
now = timezone.now()
|
| 29 |
+
|
| 30 |
+
# === 1. Déblocage des messages ===
|
| 31 |
+
if booking_start <= now:
|
| 32 |
+
try:
|
| 33 |
+
from apps.messaging.tasks import unlock_messages_for_booking
|
| 34 |
+
# Exécuter immédiatement
|
| 35 |
+
unlock_messages_for_booking.delay(instance.id)
|
| 36 |
+
logger.info(f"Déblocage immédiat des messages pour booking {instance.id}")
|
| 37 |
+
except Exception as e:
|
| 38 |
+
logger.error(f"Erreur lors du déblocage immédiat: {e}")
|
| 39 |
+
else:
|
| 40 |
+
try:
|
| 41 |
+
from apps.messaging.tasks import unlock_messages_for_booking
|
| 42 |
+
# Planifier l'exécution à l'heure du booking
|
| 43 |
+
unlock_messages_for_booking.apply_async(
|
| 44 |
+
args=[instance.id],
|
| 45 |
+
eta=booking_start
|
| 46 |
+
)
|
| 47 |
+
logger.info(f"Déblocage planifié pour booking {instance.id} à {booking_start}")
|
| 48 |
+
except Exception as e:
|
| 49 |
+
logger.error(f"Erreur lors de la planification du déblocage: {e}")
|
| 50 |
+
|
| 51 |
+
# === 2. Planifier les rappels de rendez-vous ===
|
| 52 |
+
try:
|
| 53 |
+
from apps.bookings.tasks import schedule_all_reminders
|
| 54 |
+
# Planifier tous les rappels de manière asynchrone
|
| 55 |
+
schedule_all_reminders.delay(instance.id)
|
| 56 |
+
logger.info(f"Planification des rappels pour booking {instance.id}")
|
| 57 |
+
except Exception as e:
|
| 58 |
+
logger.error(f"Erreur lors de la planification des rappels: {e}")
|
apps/bookings/tasks.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================
|
| 2 |
+
# apps/bookings/tasks.py - Tâches Celery pour les réservations
|
| 3 |
+
# ============================================
|
| 4 |
+
import datetime
|
| 5 |
+
from celery import shared_task
|
| 6 |
+
from django.utils import timezone
|
| 7 |
+
import logging
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
# Intervalles de rappel avant le rendez-vous (en minutes)
|
| 12 |
+
REMINDER_INTERVALS = [
|
| 13 |
+
(1440, "24 heures"), # 24h avant
|
| 14 |
+
(60, "1 heure"), # 1h avant
|
| 15 |
+
(30, "30 minutes"), # 30min avant
|
| 16 |
+
(15, "15 minutes"), # 15min avant
|
| 17 |
+
(5, "5 minutes"), # 5min avant
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@shared_task(name='bookings.send_booking_reminder')
|
| 22 |
+
def send_booking_reminder(booking_id, time_label):
|
| 23 |
+
"""
|
| 24 |
+
Envoie une notification de rappel aux participants d'un booking.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
booking_id: ID du booking
|
| 28 |
+
time_label: Texte décrivant le temps restant (ex: "30 minutes")
|
| 29 |
+
"""
|
| 30 |
+
from apps.bookings.models import Booking
|
| 31 |
+
from apps.notifications.models import Notification, NotificationTitle, NotificationMessage
|
| 32 |
+
from apps.notifications.services import NotificationService
|
| 33 |
+
|
| 34 |
+
try:
|
| 35 |
+
booking = Booking.objects.get(id=booking_id)
|
| 36 |
+
|
| 37 |
+
# Vérifier que le booking est toujours confirmé
|
| 38 |
+
if booking.status != 'CONFIRMED':
|
| 39 |
+
logger.info(f"Booking {booking_id} n'est plus confirmé, skip reminder.")
|
| 40 |
+
return
|
| 41 |
+
|
| 42 |
+
# Récupérer les noms des participants
|
| 43 |
+
student_profile = booking.student.profiles.filter(is_current=True).first()
|
| 44 |
+
student_name = student_profile.name if student_profile else booking.student.email
|
| 45 |
+
|
| 46 |
+
mentor_profile = booking.mentor.profiles.filter(is_current=True).first()
|
| 47 |
+
mentor_name = mentor_profile.name if mentor_profile else booking.mentor.email
|
| 48 |
+
|
| 49 |
+
# Notification pour l'étudiant
|
| 50 |
+
notif_student = Notification.objects.create(
|
| 51 |
+
user=booking.student,
|
| 52 |
+
type='BOOKING',
|
| 53 |
+
link=f'/chat?partner={booking.mentor.id}'
|
| 54 |
+
)
|
| 55 |
+
NotificationTitle.objects.create(
|
| 56 |
+
notification=notif_student,
|
| 57 |
+
title=f'⏰ Rappel : Rendez-vous dans {time_label}'
|
| 58 |
+
)
|
| 59 |
+
NotificationMessage.objects.create(
|
| 60 |
+
notification=notif_student,
|
| 61 |
+
message=f'Votre session avec {mentor_name} commence dans {time_label}. Préparez-vous !'
|
| 62 |
+
)
|
| 63 |
+
NotificationService._send_ws_notification(notif_student)
|
| 64 |
+
|
| 65 |
+
# Notification pour le mentor
|
| 66 |
+
notif_mentor = Notification.objects.create(
|
| 67 |
+
user=booking.mentor,
|
| 68 |
+
type='BOOKING',
|
| 69 |
+
link=f'/chat?partner={booking.student.id}'
|
| 70 |
+
)
|
| 71 |
+
NotificationTitle.objects.create(
|
| 72 |
+
notification=notif_mentor,
|
| 73 |
+
title=f'⏰ Rappel : Rendez-vous dans {time_label}'
|
| 74 |
+
)
|
| 75 |
+
NotificationMessage.objects.create(
|
| 76 |
+
notification=notif_mentor,
|
| 77 |
+
message=f'Votre session avec {student_name} commence dans {time_label}. L\'étudiant vous attend !'
|
| 78 |
+
)
|
| 79 |
+
NotificationService._send_ws_notification(notif_mentor)
|
| 80 |
+
|
| 81 |
+
logger.info(f"Rappel envoyé pour booking {booking_id} ({time_label})")
|
| 82 |
+
return True
|
| 83 |
+
|
| 84 |
+
except Booking.DoesNotExist:
|
| 85 |
+
logger.error(f"Booking {booking_id} introuvable")
|
| 86 |
+
return False
|
| 87 |
+
except Exception as e:
|
| 88 |
+
logger.error(f"Erreur lors de l'envoi du rappel: {e}")
|
| 89 |
+
raise
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@shared_task(name='bookings.send_booking_starting_now')
|
| 93 |
+
def send_booking_starting_now(booking_id):
|
| 94 |
+
"""
|
| 95 |
+
Notification spéciale quand le rendez-vous commence.
|
| 96 |
+
"""
|
| 97 |
+
from apps.bookings.models import Booking
|
| 98 |
+
from apps.notifications.models import Notification, NotificationTitle, NotificationMessage
|
| 99 |
+
from apps.notifications.services import NotificationService
|
| 100 |
+
|
| 101 |
+
try:
|
| 102 |
+
booking = Booking.objects.get(id=booking_id)
|
| 103 |
+
|
| 104 |
+
if booking.status != 'CONFIRMED':
|
| 105 |
+
return
|
| 106 |
+
|
| 107 |
+
# Récupérer les noms
|
| 108 |
+
student_profile = booking.student.profiles.filter(is_current=True).first()
|
| 109 |
+
student_name = student_profile.name if student_profile else booking.student.email
|
| 110 |
+
|
| 111 |
+
mentor_profile = booking.mentor.profiles.filter(is_current=True).first()
|
| 112 |
+
mentor_name = mentor_profile.name if mentor_profile else booking.mentor.email
|
| 113 |
+
|
| 114 |
+
# Notification pour l'étudiant
|
| 115 |
+
notif_student = Notification.objects.create(
|
| 116 |
+
user=booking.student,
|
| 117 |
+
type='BOOKING',
|
| 118 |
+
link=f'/chat?partner={booking.mentor.id}'
|
| 119 |
+
)
|
| 120 |
+
NotificationTitle.objects.create(
|
| 121 |
+
notification=notif_student,
|
| 122 |
+
title='🚀 C\'est l\'heure de votre session !'
|
| 123 |
+
)
|
| 124 |
+
NotificationMessage.objects.create(
|
| 125 |
+
notification=notif_student,
|
| 126 |
+
message=f'Votre session de mentorat avec {mentor_name} commence maintenant. Cliquez pour rejoindre le chat.'
|
| 127 |
+
)
|
| 128 |
+
NotificationService._send_ws_notification(notif_student)
|
| 129 |
+
|
| 130 |
+
# Notification pour le mentor
|
| 131 |
+
notif_mentor = Notification.objects.create(
|
| 132 |
+
user=booking.mentor,
|
| 133 |
+
type='BOOKING',
|
| 134 |
+
link=f'/chat?partner={booking.student.id}'
|
| 135 |
+
)
|
| 136 |
+
NotificationTitle.objects.create(
|
| 137 |
+
notification=notif_mentor,
|
| 138 |
+
title='🚀 C\'est l\'heure de votre session !'
|
| 139 |
+
)
|
| 140 |
+
NotificationMessage.objects.create(
|
| 141 |
+
notification=notif_mentor,
|
| 142 |
+
message=f'Votre session avec {student_name} commence maintenant. L\'étudiant vous attend !'
|
| 143 |
+
)
|
| 144 |
+
NotificationService._send_ws_notification(notif_mentor)
|
| 145 |
+
|
| 146 |
+
logger.info(f"Notification de démarrage envoyée pour booking {booking_id}")
|
| 147 |
+
return True
|
| 148 |
+
|
| 149 |
+
except Booking.DoesNotExist:
|
| 150 |
+
logger.error(f"Booking {booking_id} introuvable")
|
| 151 |
+
return False
|
| 152 |
+
except Exception as e:
|
| 153 |
+
logger.error(f"Erreur: {e}")
|
| 154 |
+
raise
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@shared_task(name='bookings.schedule_all_reminders')
|
| 158 |
+
def schedule_all_reminders(booking_id):
|
| 159 |
+
"""
|
| 160 |
+
Planifie tous les rappels pour un booking.
|
| 161 |
+
À appeler quand un booking est confirmé.
|
| 162 |
+
"""
|
| 163 |
+
from apps.bookings.models import Booking
|
| 164 |
+
|
| 165 |
+
try:
|
| 166 |
+
booking = Booking.objects.get(id=booking_id)
|
| 167 |
+
|
| 168 |
+
if booking.status != 'CONFIRMED':
|
| 169 |
+
return
|
| 170 |
+
|
| 171 |
+
# Calculer l'heure de début
|
| 172 |
+
booking_start = datetime.datetime.combine(booking.date, booking.time)
|
| 173 |
+
booking_start = timezone.make_aware(booking_start)
|
| 174 |
+
now = timezone.now()
|
| 175 |
+
|
| 176 |
+
scheduled_count = 0
|
| 177 |
+
|
| 178 |
+
# Planifier chaque rappel
|
| 179 |
+
for minutes_before, label in REMINDER_INTERVALS:
|
| 180 |
+
reminder_time = booking_start - datetime.timedelta(minutes=minutes_before)
|
| 181 |
+
|
| 182 |
+
# Ne planifier que les rappels dans le futur
|
| 183 |
+
if reminder_time > now:
|
| 184 |
+
send_booking_reminder.apply_async(
|
| 185 |
+
args=[booking_id, label],
|
| 186 |
+
eta=reminder_time
|
| 187 |
+
)
|
| 188 |
+
scheduled_count += 1
|
| 189 |
+
logger.info(f"Rappel planifié pour booking {booking_id}: {label} (à {reminder_time})")
|
| 190 |
+
|
| 191 |
+
# Planifier la notification de démarrage
|
| 192 |
+
if booking_start > now:
|
| 193 |
+
send_booking_starting_now.apply_async(
|
| 194 |
+
args=[booking_id],
|
| 195 |
+
eta=booking_start
|
| 196 |
+
)
|
| 197 |
+
logger.info(f"Notification de démarrage planifiée pour booking {booking_id}")
|
| 198 |
+
|
| 199 |
+
return f"Planifié {scheduled_count} rappels pour booking {booking_id}"
|
| 200 |
+
|
| 201 |
+
except Booking.DoesNotExist:
|
| 202 |
+
logger.error(f"Booking {booking_id} introuvable")
|
| 203 |
+
return None
|
| 204 |
+
except Exception as e:
|
| 205 |
+
logger.error(f"Erreur lors de la planification: {e}")
|
| 206 |
+
raise
|
apps/bookings/tests.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from django.test import TestCase
|
| 2 |
+
|
| 3 |
+
# Create your tests here.
|
apps/bookings/urls.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================
|
| 2 |
+
# apps/bookings/urls.py
|
| 3 |
+
# ============================================
|
| 4 |
+
from django.urls import path, include
|
| 5 |
+
from rest_framework.routers import DefaultRouter
|
| 6 |
+
from apps.bookings.views import BookingViewSet
|
| 7 |
+
|
| 8 |
+
router = DefaultRouter()
|
| 9 |
+
router.register(r'', BookingViewSet, basename='booking')
|
| 10 |
+
|
| 11 |
+
urlpatterns = [
|
| 12 |
+
path('', include(router.urls)),
|
| 13 |
+
]
|
| 14 |
+
|
| 15 |
+
"""
|
| 16 |
+
Endpoints disponibles:
|
| 17 |
+
- GET /api/bookings/ (mes réservations)
|
| 18 |
+
- POST /api/bookings/ (créer)
|
| 19 |
+
- GET /api/bookings/{id}/
|
| 20 |
+
- PATCH /api/bookings/{id}/update_status/
|
| 21 |
+
- GET /api/bookings/mentor_requests/
|
| 22 |
+
"""
|
| 23 |
+
|
apps/bookings/views.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================
|
| 2 |
+
# apps/bookings/views.py
|
| 3 |
+
# ============================================
|
| 4 |
+
from rest_framework import viewsets, status
|
| 5 |
+
from rest_framework.decorators import action
|
| 6 |
+
from rest_framework.permissions import IsAuthenticated
|
| 7 |
+
from rest_framework.response import Response
|
| 8 |
+
|
| 9 |
+
from apps.bookings.models import Booking
|
| 10 |
+
from apps.bookings.serializers import (
|
| 11 |
+
BookingSerializer, BookingCreateSerializer, BookingStatusUpdateSerializer
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
from apps.core.mixins import HashIdMixin
|
| 15 |
+
|
| 16 |
+
class BookingViewSet(HashIdMixin, viewsets.ModelViewSet):
|
| 17 |
+
"""Gestion des réservations"""
|
| 18 |
+
permission_classes = [IsAuthenticated]
|
| 19 |
+
serializer_class = BookingSerializer
|
| 20 |
+
|
| 21 |
+
def get_queryset(self):
|
| 22 |
+
user = self.request.user
|
| 23 |
+
queryset = Booking.objects.filter(is_active=True)
|
| 24 |
+
|
| 25 |
+
# Étudiants voient leurs réservations
|
| 26 |
+
if user.role == 'STUDENT':
|
| 27 |
+
queryset = queryset.filter(student=user)
|
| 28 |
+
# Mentors voient les demandes reçues
|
| 29 |
+
elif user.role == 'MENTOR':
|
| 30 |
+
queryset = queryset.filter(mentor=user)
|
| 31 |
+
|
| 32 |
+
# Filtre par statut
|
| 33 |
+
status_filter = self.request.query_params.get('status')
|
| 34 |
+
if status_filter:
|
| 35 |
+
queryset = queryset.filter(status=status_filter)
|
| 36 |
+
|
| 37 |
+
return queryset.order_by('-created_at')
|
| 38 |
+
|
| 39 |
+
def get_serializer_class(self):
|
| 40 |
+
if self.action == 'create':
|
| 41 |
+
return BookingCreateSerializer
|
| 42 |
+
elif self.action == 'update_status':
|
| 43 |
+
return BookingStatusUpdateSerializer
|
| 44 |
+
return BookingSerializer
|
| 45 |
+
|
| 46 |
+
def create(self, request, *args, **kwargs):
|
| 47 |
+
serializer = self.get_serializer(data=request.data)
|
| 48 |
+
serializer.is_valid(raise_exception=True)
|
| 49 |
+
booking = serializer.save()
|
| 50 |
+
|
| 51 |
+
# Utiliser le serializer standard pour la réponse
|
| 52 |
+
response_serializer = BookingSerializer(booking, context=self.get_serializer_context())
|
| 53 |
+
headers = self.get_success_headers(response_serializer.data)
|
| 54 |
+
return Response(response_serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
| 55 |
+
|
| 56 |
+
def perform_create(self, serializer):
|
| 57 |
+
serializer.save()
|
| 58 |
+
|
| 59 |
+
@action(detail=True, methods=['patch'], permission_classes=[IsAuthenticated])
|
| 60 |
+
def update_status(self, request, pk=None):
|
| 61 |
+
"""PATCH /api/bookings/{id}/update_status/"""
|
| 62 |
+
booking = self.get_object()
|
| 63 |
+
|
| 64 |
+
# Vérifier que c'est le mentor
|
| 65 |
+
if booking.mentor != request.user:
|
| 66 |
+
return Response(
|
| 67 |
+
{'error': 'Seul le mentor peut modifier le statut'},
|
| 68 |
+
status=status.HTTP_403_FORBIDDEN
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
serializer = BookingStatusUpdateSerializer(
|
| 72 |
+
booking,
|
| 73 |
+
data=request.data,
|
| 74 |
+
context={'request': request}
|
| 75 |
+
)
|
| 76 |
+
serializer.is_valid(raise_exception=True)
|
| 77 |
+
serializer.save()
|
| 78 |
+
|
| 79 |
+
return Response(BookingSerializer(booking).data)
|
| 80 |
+
|
| 81 |
+
@action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
|
| 82 |
+
def mentor_requests(self, request):
|
| 83 |
+
"""GET /api/bookings/mentor_requests/ - Demandes reçues par le mentor"""
|
| 84 |
+
if request.user.role != 'MENTOR':
|
| 85 |
+
return Response(
|
| 86 |
+
{'error': 'Accessible uniquement aux mentors'},
|
| 87 |
+
status=status.HTTP_403_FORBIDDEN
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
bookings = Booking.objects.filter(
|
| 91 |
+
mentor=request.user,
|
| 92 |
+
is_active=True
|
| 93 |
+
).order_by('date', 'time')
|
| 94 |
+
|
| 95 |
+
page = self.paginate_queryset(bookings)
|
| 96 |
+
if page is not None:
|
| 97 |
+
serializer = self.get_serializer(page, many=True)
|
| 98 |
+
return self.get_paginated_response(serializer.data)
|
| 99 |
+
|
| 100 |
+
serializer = self.get_serializer(bookings, many=True)
|
| 101 |
+
return Response(serializer.data)
|
| 102 |
+
|
apps/core/__init__.py
ADDED
|
File without changes
|
apps/core/admin.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from django.contrib import admin
|
| 2 |
+
from .models import ImpactStat, LearningTool, Testimonial, SocialLink
|
| 3 |
+
|
| 4 |
+
@admin.register(ImpactStat)
|
| 5 |
+
class ImpactStatAdmin(admin.ModelAdmin):
|
| 6 |
+
list_display = ('title', 'value', 'icon', 'order', 'is_visible')
|
| 7 |
+
list_editable = ('order', 'is_visible')
|
| 8 |
+
search_fields = ('title', 'description')
|
| 9 |
+
|
| 10 |
+
@admin.register(LearningTool)
|
| 11 |
+
class LearningToolAdmin(admin.ModelAdmin):
|
| 12 |
+
list_display = ('title', 'category', 'level', 'status', 'order', 'is_visible')
|
| 13 |
+
list_editable = ('status', 'order', 'is_visible')
|
| 14 |
+
list_filter = ('category', 'level', 'status')
|
| 15 |
+
search_fields = ('title', 'description')
|
| 16 |
+
|
| 17 |
+
@admin.register(Testimonial)
|
| 18 |
+
class TestimonialAdmin(admin.ModelAdmin):
|
| 19 |
+
list_display = ('name', 'role', 'country', 'order', 'is_visible')
|
| 20 |
+
list_editable = ('order', 'is_visible')
|
| 21 |
+
search_fields = ('name', 'text', 'country')
|
| 22 |
+
|
| 23 |
+
@admin.register(SocialLink)
|
| 24 |
+
class SocialLinkAdmin(admin.ModelAdmin):
|
| 25 |
+
list_display = ('name', 'platform', 'url', 'order', 'is_visible')
|
| 26 |
+
list_editable = ('order', 'is_visible')
|
| 27 |
+
list_filter = ('platform',)
|
apps/core/apps.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from django.apps import AppConfig
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class CoreConfig(AppConfig):
|
| 5 |
+
default_auto_field = 'django.db.models.BigAutoField'
|
| 6 |
+
name = 'apps.core'
|
| 7 |
+
|
| 8 |
+
def ready(self):
|
| 9 |
+
import apps.core.signals
|
apps/core/exceptions.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Custom exception handler for Django REST Framework
|
| 3 |
+
Développé par Marino ATOHOUN pour Hypee
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from rest_framework.views import exception_handler
|
| 7 |
+
from rest_framework.response import Response
|
| 8 |
+
from rest_framework import status
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def custom_exception_handler(exc, context):
|
| 12 |
+
"""
|
| 13 |
+
Gestionnaire d'exceptions personnalisé pour DRF
|
| 14 |
+
|
| 15 |
+
Ajoute des informations supplémentaires aux réponses d'erreur
|
| 16 |
+
et standardise le format de retour.
|
| 17 |
+
"""
|
| 18 |
+
# Appeler le gestionnaire par défaut de DRF
|
| 19 |
+
response = exception_handler(exc, context)
|
| 20 |
+
|
| 21 |
+
if response is not None:
|
| 22 |
+
# Standardiser le format de réponse d'erreur
|
| 23 |
+
custom_response_data = {
|
| 24 |
+
'error': True,
|
| 25 |
+
'status_code': response.status_code,
|
| 26 |
+
'message': None,
|
| 27 |
+
'details': response.data
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
# Essayer d'extraire un message clair
|
| 31 |
+
if isinstance(response.data, dict):
|
| 32 |
+
if 'detail' in response.data:
|
| 33 |
+
custom_response_data['message'] = response.data['detail']
|
| 34 |
+
elif 'non_field_errors' in response.data:
|
| 35 |
+
custom_response_data['message'] = response.data['non_field_errors'][0]
|
| 36 |
+
else:
|
| 37 |
+
# Prendre le premier message d'erreur trouvé
|
| 38 |
+
for field, errors in response.data.items():
|
| 39 |
+
if isinstance(errors, list) and errors:
|
| 40 |
+
custom_response_data['message'] = f"{field}: {errors[0]}"
|
| 41 |
+
break
|
| 42 |
+
|
| 43 |
+
# Message par défaut selon le code de statut
|
| 44 |
+
if not custom_response_data['message']:
|
| 45 |
+
if response.status_code == 400:
|
| 46 |
+
custom_response_data['message'] = "Données invalides"
|
| 47 |
+
elif response.status_code == 401:
|
| 48 |
+
custom_response_data['message'] = "Authentification requise"
|
| 49 |
+
elif response.status_code == 403:
|
| 50 |
+
custom_response_data['message'] = "Accès refusé"
|
| 51 |
+
elif response.status_code == 404:
|
| 52 |
+
custom_response_data['message'] = "Ressource non trouvée"
|
| 53 |
+
elif response.status_code == 500:
|
| 54 |
+
custom_response_data['message'] = "Erreur serveur interne"
|
| 55 |
+
else:
|
| 56 |
+
custom_response_data['message'] = "Une erreur est survenue"
|
| 57 |
+
|
| 58 |
+
response.data = custom_response_data
|
| 59 |
+
|
| 60 |
+
return response
|
apps/core/management/commands/create_test_data.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from django.core.management.base import BaseCommand
|
| 2 |
+
from django.contrib.auth import get_user_model
|
| 3 |
+
from apps.users.models import UserProfile, UserCountry, UserUniversity
|
| 4 |
+
from apps.mentors.models import (
|
| 5 |
+
MentorProfile, MentorBio, MentorSpecialty, MentorAvailability
|
| 6 |
+
)
|
| 7 |
+
from apps.forum.models import Question, QuestionTitle, QuestionContent, QuestionTag
|
| 8 |
+
from faker import Faker
|
| 9 |
+
import random
|
| 10 |
+
|
| 11 |
+
User = get_user_model()
|
| 12 |
+
fake = Faker(['fr_FR'])
|
| 13 |
+
|
| 14 |
+
class Command(BaseCommand):
|
| 15 |
+
help = 'Créer des données de test'
|
| 16 |
+
|
| 17 |
+
def add_arguments(self, parser):
|
| 18 |
+
parser.add_argument(
|
| 19 |
+
'--users',
|
| 20 |
+
type=int,
|
| 21 |
+
default=20,
|
| 22 |
+
help='Nombre d\'utilisateurs à créer'
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
def handle(self, *args, **options):
|
| 26 |
+
num_users = options['users']
|
| 27 |
+
|
| 28 |
+
self.stdout.write(f'Création de {num_users} utilisateurs de test...')
|
| 29 |
+
|
| 30 |
+
countries = ['Bénin', 'Sénégal', 'Côte d\'Ivoire', 'Togo', 'Burkina Faso', 'Mali']
|
| 31 |
+
universities = [
|
| 32 |
+
'Université d\'Abomey-Calavi',
|
| 33 |
+
'Université Cheikh Anta Diop',
|
| 34 |
+
'Université Félix Houphouët-Boigny',
|
| 35 |
+
'Université de Lomé'
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
# Créer des étudiants
|
| 39 |
+
students = []
|
| 40 |
+
for i in range(num_users):
|
| 41 |
+
email = fake.email()
|
| 42 |
+
user = User.objects.create_user(
|
| 43 |
+
email=email,
|
| 44 |
+
password='password123',
|
| 45 |
+
role='STUDENT'
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
profile = UserProfile.objects.create(
|
| 49 |
+
user=user,
|
| 50 |
+
name=fake.name()
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
UserCountry.objects.create(
|
| 54 |
+
profile=profile,
|
| 55 |
+
country=random.choice(countries)
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
UserUniversity.objects.create(
|
| 59 |
+
profile=profile,
|
| 60 |
+
university=random.choice(universities)
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
students.append(user)
|
| 64 |
+
|
| 65 |
+
self.stdout.write(self.style.SUCCESS(f'{num_users} étudiants créés'))
|
| 66 |
+
|
| 67 |
+
# Créer des mentors
|
| 68 |
+
specialties_list = [
|
| 69 |
+
'Mathématiques', 'Physique', 'Chimie', 'Informatique',
|
| 70 |
+
'Économie', 'Gestion', 'Droit', 'Marketing', 'Finance'
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
num_mentors = num_users // 4
|
| 74 |
+
mentors = []
|
| 75 |
+
|
| 76 |
+
for i in range(num_mentors):
|
| 77 |
+
email = f'mentor{i}@edulab.test'
|
| 78 |
+
user = User.objects.create_user(
|
| 79 |
+
email=email,
|
| 80 |
+
password='password123',
|
| 81 |
+
role='MENTOR'
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
profile = UserProfile.objects.create(
|
| 85 |
+
user=user,
|
| 86 |
+
name=fake.name()
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
UserCountry.objects.create(
|
| 90 |
+
profile=profile,
|
| 91 |
+
country=random.choice(countries)
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
mentor_profile = MentorProfile.objects.create(
|
| 95 |
+
user=user,
|
| 96 |
+
rating=round(random.uniform(3.5, 5.0), 1),
|
| 97 |
+
reviews_count=random.randint(5, 50),
|
| 98 |
+
is_verified=True
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
MentorBio.objects.create(
|
| 102 |
+
mentor_profile=mentor_profile,
|
| 103 |
+
bio=fake.text(max_nb_chars=200)
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
# Spécialités
|
| 107 |
+
for specialty in random.sample(specialties_list, k=random.randint(2, 4)):
|
| 108 |
+
MentorSpecialty.objects.create(
|
| 109 |
+
mentor_profile=mentor_profile,
|
| 110 |
+
specialty=specialty
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
# Disponibilités
|
| 114 |
+
days = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY']
|
| 115 |
+
for day in random.sample(days, k=3):
|
| 116 |
+
MentorAvailability.objects.create(
|
| 117 |
+
mentor_profile=mentor_profile,
|
| 118 |
+
day_of_week=day,
|
| 119 |
+
start_time='14:00:00',
|
| 120 |
+
end_time='17:00:00'
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
mentors.append(user)
|
| 124 |
+
|
| 125 |
+
self.stdout.write(self.style.SUCCESS(f'{num_mentors} mentors créés'))
|
| 126 |
+
|
| 127 |
+
# Créer des questions
|
| 128 |
+
num_questions = num_users * 2
|
| 129 |
+
tags_list = ['aide', 'urgent', 'exercice', 'cours', 'examen', 'projet']
|
| 130 |
+
|
| 131 |
+
for i in range(num_questions):
|
| 132 |
+
author = random.choice(students)
|
| 133 |
+
profile = author.profiles.filter(is_current=True).first()
|
| 134 |
+
|
| 135 |
+
question = Question.objects.create(
|
| 136 |
+
author=author,
|
| 137 |
+
profile=profile,
|
| 138 |
+
votes=random.randint(0, 50)
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
QuestionTitle.objects.create(
|
| 142 |
+
question=question,
|
| 143 |
+
title=fake.sentence()
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
QuestionContent.objects.create(
|
| 147 |
+
question=question,
|
| 148 |
+
content=fake.text(max_nb_chars=500)
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
for tag in random.sample(tags_list, k=random.randint(1, 3)):
|
| 152 |
+
QuestionTag.objects.create(
|
| 153 |
+
question=question,
|
| 154 |
+
tag=tag
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
self.stdout.write(self.style.SUCCESS(f'{num_questions} questions créées'))
|
| 158 |
+
|
| 159 |
+
self.stdout.write(self.style.SUCCESS('Données de test créées avec succès!'))
|
| 160 |
+
self.stdout.write('\\nComptes de test:')
|
| 161 |
+
self.stdout.write(f' Étudiant: student@test.com / password123')
|
| 162 |
+
self.stdout.write(f' Mentor: mentor0@edulab.test / password123')
|
apps/core/management/commands/init_data.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from django.core.management.base import BaseCommand
|
| 2 |
+
from apps.core.models import ImpactStat, LearningTool, Testimonial, SocialLink
|
| 3 |
+
from apps.opportunities.models import Opportunity
|
| 4 |
+
from apps.gamification.models import Badge
|
| 5 |
+
|
| 6 |
+
class Command(BaseCommand):
|
| 7 |
+
help = 'Initialize essential database data for the application startup'
|
| 8 |
+
|
| 9 |
+
def handle(self, *args, **options):
|
| 10 |
+
self.stdout.write(self.style.SUCCESS('Starting data initialization...'))
|
| 11 |
+
|
| 12 |
+
self.init_social_links()
|
| 13 |
+
self.init_impact_stats()
|
| 14 |
+
self.init_learning_tools()
|
| 15 |
+
self.init_testimonials()
|
| 16 |
+
self.init_opportunities()
|
| 17 |
+
self.init_badges()
|
| 18 |
+
|
| 19 |
+
self.stdout.write(self.style.SUCCESS('Data initialization completed successfully.'))
|
| 20 |
+
|
| 21 |
+
def init_social_links(self):
|
| 22 |
+
links = [
|
| 23 |
+
{
|
| 24 |
+
'platform': 'facebook',
|
| 25 |
+
'defaults': {
|
| 26 |
+
'name': 'Facebook',
|
| 27 |
+
'url': 'https://facebook.com/edulabafrica',
|
| 28 |
+
'icon': 'Facebook',
|
| 29 |
+
'order': 1
|
| 30 |
+
}
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
'platform': 'twitter',
|
| 34 |
+
'defaults': {
|
| 35 |
+
'name': 'Twitter',
|
| 36 |
+
'url': 'https://twitter.com/edulabafrica',
|
| 37 |
+
'icon': 'Twitter',
|
| 38 |
+
'order': 2
|
| 39 |
+
}
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
'platform': 'linkedin',
|
| 43 |
+
'defaults': {
|
| 44 |
+
'name': 'LinkedIn',
|
| 45 |
+
'url': 'https://linkedin.com/company/edulabafrica',
|
| 46 |
+
'icon': 'Linkedin',
|
| 47 |
+
'order': 3
|
| 48 |
+
}
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
'platform': 'instagram',
|
| 52 |
+
'defaults': {
|
| 53 |
+
'name': 'Instagram',
|
| 54 |
+
'url': 'https://instagram.com/edulabafrica',
|
| 55 |
+
'icon': 'Instagram',
|
| 56 |
+
'order': 4
|
| 57 |
+
}
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
'platform': 'youtube',
|
| 61 |
+
'defaults': {
|
| 62 |
+
'name': 'YouTube',
|
| 63 |
+
'url': 'https://youtube.com/c/edulabafrica',
|
| 64 |
+
'icon': 'Youtube',
|
| 65 |
+
'order': 5
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
]
|
| 69 |
+
|
| 70 |
+
count = 0
|
| 71 |
+
for item in links:
|
| 72 |
+
obj, created = SocialLink.objects.get_or_create(
|
| 73 |
+
platform=item['platform'],
|
| 74 |
+
defaults=item['defaults']
|
| 75 |
+
)
|
| 76 |
+
if created:
|
| 77 |
+
count += 1
|
| 78 |
+
|
| 79 |
+
if count > 0:
|
| 80 |
+
self.stdout.write(self.style.SUCCESS(f'Created {count} new social links.'))
|
| 81 |
+
else:
|
| 82 |
+
self.stdout.write('Social links up to date.')
|
| 83 |
+
|
| 84 |
+
def init_impact_stats(self):
|
| 85 |
+
stats = [
|
| 86 |
+
{
|
| 87 |
+
'title': 'Étudiants formés',
|
| 88 |
+
'defaults': {
|
| 89 |
+
'value': '5000+',
|
| 90 |
+
'icon': 'Users',
|
| 91 |
+
'description': 'Étudiants accompagnés à travers l\'Afrique',
|
| 92 |
+
'order': 1
|
| 93 |
+
}
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
'title': 'Taux de réussite',
|
| 97 |
+
'defaults': {
|
| 98 |
+
'value': '98%',
|
| 99 |
+
'icon': 'CheckCircle',
|
| 100 |
+
'description': 'Réussite aux examens nationaux',
|
| 101 |
+
'order': 2
|
| 102 |
+
}
|
| 103 |
+
},
|
| 104 |
+
{
|
| 105 |
+
'title': 'Mentors Experts',
|
| 106 |
+
'defaults': {
|
| 107 |
+
'value': '200+',
|
| 108 |
+
'icon': 'Award',
|
| 109 |
+
'description': 'Professionnels engagés',
|
| 110 |
+
'order': 3
|
| 111 |
+
}
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
'title': 'Outils Pratiques',
|
| 115 |
+
'defaults': {
|
| 116 |
+
'value': '50+',
|
| 117 |
+
'icon': 'Wrench',
|
| 118 |
+
'description': 'Simulateurs et laboratoires virtuels',
|
| 119 |
+
'order': 4
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
]
|
| 123 |
+
|
| 124 |
+
count = 0
|
| 125 |
+
for item in stats:
|
| 126 |
+
obj, created = ImpactStat.objects.get_or_create(
|
| 127 |
+
title=item['title'],
|
| 128 |
+
defaults=item['defaults']
|
| 129 |
+
)
|
| 130 |
+
if created:
|
| 131 |
+
count += 1
|
| 132 |
+
|
| 133 |
+
if count > 0:
|
| 134 |
+
self.stdout.write(self.style.SUCCESS(f'Created {count} new impact stats.'))
|
| 135 |
+
else:
|
| 136 |
+
self.stdout.write('Impact stats up to date.')
|
| 137 |
+
|
| 138 |
+
def init_learning_tools(self):
|
| 139 |
+
tools = [
|
| 140 |
+
{
|
| 141 |
+
'tool_id': 'code',
|
| 142 |
+
'defaults': {
|
| 143 |
+
'title': 'Code Sandbox',
|
| 144 |
+
'description': 'Environnement de développement intégré pour apprendre HTML, CSS et JS en temps réel.',
|
| 145 |
+
'icon': 'Code',
|
| 146 |
+
'category': 'Informatique',
|
| 147 |
+
'level': 'Tous niveaux',
|
| 148 |
+
'link': '/tools/code-sandbox',
|
| 149 |
+
'color': 'bg-blue-100',
|
| 150 |
+
'text_color': 'text-blue-600',
|
| 151 |
+
'bg_gradient': 'from-blue-500 to-cyan-400',
|
| 152 |
+
'order': 1
|
| 153 |
+
}
|
| 154 |
+
},
|
| 155 |
+
{
|
| 156 |
+
'tool_id': 'geo',
|
| 157 |
+
'defaults': {
|
| 158 |
+
'title': 'Atlas Interactif',
|
| 159 |
+
'description': 'Exploration géographique 3D avec données économiques et climatiques en temps réel.',
|
| 160 |
+
'icon': 'Globe',
|
| 161 |
+
'category': 'Sciences',
|
| 162 |
+
'level': 'Collège',
|
| 163 |
+
'link': '/tools/atlas',
|
| 164 |
+
'color': 'bg-green-100',
|
| 165 |
+
'text_color': 'text-green-600',
|
| 166 |
+
'bg_gradient': 'from-green-500 to-emerald-400',
|
| 167 |
+
'order': 2
|
| 168 |
+
}
|
| 169 |
+
},
|
| 170 |
+
{
|
| 171 |
+
'tool_id': 'calc',
|
| 172 |
+
'defaults': {
|
| 173 |
+
'title': 'Calculatrice Scientifique',
|
| 174 |
+
'description': 'Outil avancé pour les mathématiques, la physique et l\'ingénierie avec graphiques.',
|
| 175 |
+
'icon': 'Calculator',
|
| 176 |
+
'category': 'Sciences',
|
| 177 |
+
'level': 'Lycée',
|
| 178 |
+
'link': '/tools/calculator',
|
| 179 |
+
'color': 'bg-purple-100',
|
| 180 |
+
'text_color': 'text-purple-600',
|
| 181 |
+
'bg_gradient': 'from-purple-500 to-indigo-400',
|
| 182 |
+
'order': 3
|
| 183 |
+
}
|
| 184 |
+
},
|
| 185 |
+
{
|
| 186 |
+
'tool_id': 'write',
|
| 187 |
+
'defaults': {
|
| 188 |
+
'title': 'Atelier d\'Écriture',
|
| 189 |
+
'description': 'Assistant intelligent pour améliorer vos rédactions et votre style littéraire.',
|
| 190 |
+
'icon': 'PenTool',
|
| 191 |
+
'category': 'Langues',
|
| 192 |
+
'level': 'Primaire',
|
| 193 |
+
'link': '/tools/writing',
|
| 194 |
+
'color': 'bg-orange-100',
|
| 195 |
+
'text_color': 'text-orange-600',
|
| 196 |
+
'bg_gradient': 'from-orange-500 to-amber-400',
|
| 197 |
+
'order': 4
|
| 198 |
+
}
|
| 199 |
+
},
|
| 200 |
+
{
|
| 201 |
+
'tool_id': 'art',
|
| 202 |
+
'defaults': {
|
| 203 |
+
'title': 'Atelier Créatif',
|
| 204 |
+
'description': 'Espace de dessin et de coloriage numérique pour développer la créativité.',
|
| 205 |
+
'icon': 'Palette',
|
| 206 |
+
'category': 'Créativité',
|
| 207 |
+
'level': 'Primaire',
|
| 208 |
+
'link': '/tools/coloring',
|
| 209 |
+
'color': 'bg-pink-100',
|
| 210 |
+
'text_color': 'text-pink-600',
|
| 211 |
+
'bg_gradient': 'from-pink-500 to-rose-400',
|
| 212 |
+
'order': 5
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
]
|
| 216 |
+
|
| 217 |
+
count = 0
|
| 218 |
+
for item in tools:
|
| 219 |
+
obj, created = LearningTool.objects.get_or_create(
|
| 220 |
+
tool_id=item['tool_id'],
|
| 221 |
+
defaults=item['defaults']
|
| 222 |
+
)
|
| 223 |
+
if created:
|
| 224 |
+
count += 1
|
| 225 |
+
|
| 226 |
+
if count > 0:
|
| 227 |
+
self.stdout.write(self.style.SUCCESS(f'Created {count} new learning tools.'))
|
| 228 |
+
else:
|
| 229 |
+
self.stdout.write('Learning tools up to date.')
|
| 230 |
+
|
| 231 |
+
def init_testimonials(self):
|
| 232 |
+
testimonials = [
|
| 233 |
+
{
|
| 234 |
+
'name': 'Awa Ndiaye',
|
| 235 |
+
'defaults': {
|
| 236 |
+
'role': 'Étudiante en Médecine',
|
| 237 |
+
'country': 'Sénégal',
|
| 238 |
+
'text': 'EduLab m\'a permis de visualiser l\'anatomie en 3D, ce qui était impossible avec mes livres seulement. Une révolution !',
|
| 239 |
+
'order': 1
|
| 240 |
+
}
|
| 241 |
+
},
|
| 242 |
+
{
|
| 243 |
+
'name': 'Kofi Mensah',
|
| 244 |
+
'defaults': {
|
| 245 |
+
'role': 'Lycéen',
|
| 246 |
+
'country': 'Ghana',
|
| 247 |
+
'text': 'Grâce au tuteur IA, j\'ai enfin compris les intégrales. C\'est comme avoir un prof particulier disponible 24h/24.',
|
| 248 |
+
'order': 2
|
| 249 |
+
}
|
| 250 |
+
},
|
| 251 |
+
{
|
| 252 |
+
'name': 'Sarah Benali',
|
| 253 |
+
'defaults': {
|
| 254 |
+
'role': 'Développeuse Web',
|
| 255 |
+
'country': 'Maroc',
|
| 256 |
+
'text': 'Le Code Sandbox est incroyable pour tester des idées rapidement sans rien installer. J\'ai appris React grâce à ça.',
|
| 257 |
+
'order': 3
|
| 258 |
+
}
|
| 259 |
+
}
|
| 260 |
+
]
|
| 261 |
+
|
| 262 |
+
count = 0
|
| 263 |
+
for item in testimonials:
|
| 264 |
+
obj, created = Testimonial.objects.get_or_create(
|
| 265 |
+
name=item['name'],
|
| 266 |
+
defaults=item['defaults']
|
| 267 |
+
)
|
| 268 |
+
if created:
|
| 269 |
+
count += 1
|
| 270 |
+
|
| 271 |
+
if count > 0:
|
| 272 |
+
self.stdout.write(self.style.SUCCESS(f'Created {count} new testimonials.'))
|
| 273 |
+
else:
|
| 274 |
+
self.stdout.write('Testimonials up to date.')
|
| 275 |
+
|
| 276 |
+
def init_opportunities(self):
|
| 277 |
+
# For opportunities, we'll just check if the table is empty to avoid duplicates of the sample
|
| 278 |
+
# as they don't have unique keys like tool_id
|
| 279 |
+
if Opportunity.objects.exists():
|
| 280 |
+
self.stdout.write('Opportunities already exist. Skipping.')
|
| 281 |
+
return
|
| 282 |
+
|
| 283 |
+
from django.utils import timezone
|
| 284 |
+
from datetime import timedelta
|
| 285 |
+
|
| 286 |
+
# Create one sample opportunity
|
| 287 |
+
opp = Opportunity.objects.create(
|
| 288 |
+
type='SCHOLARSHIP',
|
| 289 |
+
deadline=timezone.now().date() + timedelta(days=30),
|
| 290 |
+
external_link='https://au.int/en/scholarship',
|
| 291 |
+
is_featured=True
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
# Add related data
|
| 295 |
+
opp.titles.create(title="Bourse d'Excellence Africaine 2025")
|
| 296 |
+
opp.providers.create(provider="Union Africaine")
|
| 297 |
+
opp.descriptions.create(description="Une opportunité unique pour les étudiants africains brillants de poursuivre leurs études supérieures avec une couverture financière complète.")
|
| 298 |
+
opp.locations.create(location="Addis Ababa, Éthiopie")
|
| 299 |
+
|
| 300 |
+
self.stdout.write(self.style.SUCCESS('Created 1 sample opportunity.'))
|
| 301 |
+
|
| 302 |
+
def init_badges(self):
|
| 303 |
+
badges = [
|
| 304 |
+
{'code': 'b1', 'name': 'Premier Pas', 'description': 'Première question posée', 'icon': '👣', 'color': 'bg-blue-100 text-blue-600'},
|
| 305 |
+
{'code': 'b2', 'name': 'Savant', 'description': '10 meilleures réponses', 'icon': '🦉', 'color': 'bg-yellow-100 text-yellow-600'},
|
| 306 |
+
{'code': 'b3', 'name': 'Mentor Star', 'description': 'Note moyenne de 5.0', 'icon': '⭐', 'color': 'bg-purple-100 text-purple-600'},
|
| 307 |
+
{'code': 'b4', 'name': 'Globe Trotter', 'description': 'Aidé des étudiants de 5 pays', 'icon': '🌍', 'color': 'bg-green-100 text-green-600'},
|
| 308 |
+
{'code': 'b5', 'name': 'Curieux', 'description': 'Avoir visité 50 questions', 'icon': '🔍', 'color': 'bg-indigo-100 text-indigo-600'},
|
| 309 |
+
{'code': 'b6', 'name': 'Tech Guru', 'description': 'Répondre à 20 questions Tech', 'icon': '💻', 'color': 'bg-pink-100 text-pink-600'},
|
| 310 |
+
{'code': 'b7', 'name': 'Philanthrope', 'description': 'Donner 5 sessions de mentorat', 'icon': '🤝', 'color': 'bg-orange-100 text-orange-600'},
|
| 311 |
+
{'code': 'b8', 'name': 'Légende', 'description': 'Atteindre 5000 points', 'icon': '👑', 'color': 'bg-red-100 text-red-600'},
|
| 312 |
+
]
|
| 313 |
+
|
| 314 |
+
count = 0
|
| 315 |
+
for b in badges:
|
| 316 |
+
badge, created = Badge.objects.get_or_create(code=b['code'])
|
| 317 |
+
if created:
|
| 318 |
+
badge.names.create(name=b['name'])
|
| 319 |
+
badge.descriptions.create(description=b['description'])
|
| 320 |
+
badge.icons.create(icon=b['icon'])
|
| 321 |
+
badge.colors.create(color=b['color'])
|
| 322 |
+
count += 1
|
| 323 |
+
|
| 324 |
+
if count > 0:
|
| 325 |
+
self.stdout.write(self.style.SUCCESS(f'Created {count} new badges.'))
|
| 326 |
+
else:
|
| 327 |
+
self.stdout.write('Badges up to date.')
|
apps/core/management/commands/init_db.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from django.core.management.base import BaseCommand
|
| 2 |
+
from apps.gamification.services import BadgeService
|
| 3 |
+
|
| 4 |
+
class Command(BaseCommand):
|
| 5 |
+
help = 'Initialiser la base de données avec les données par défaut'
|
| 6 |
+
|
| 7 |
+
def handle(self, *args, **options):
|
| 8 |
+
self.stdout.write('Initialisation de la base de données...')
|
| 9 |
+
|
| 10 |
+
# Créer les badges par défaut
|
| 11 |
+
self.stdout.write('Création des badges par défaut...')
|
| 12 |
+
BadgeService.initialize_default_badges()
|
| 13 |
+
|
| 14 |
+
self.stdout.write(self.style.SUCCESS('Base de données initialisée avec succès!'))
|
apps/core/middleware.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================
|
| 2 |
+
# apps/core/middleware.py (Optionnel)
|
| 3 |
+
# ============================================
|
| 4 |
+
import logging
|
| 5 |
+
from django.utils import timezone
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
class ActivityTrackingMiddleware:
|
| 10 |
+
"""Middleware pour tracker l'activité utilisateur"""
|
| 11 |
+
|
| 12 |
+
def __init__(self, get_response):
|
| 13 |
+
self.get_response = get_response
|
| 14 |
+
|
| 15 |
+
def __call__(self, request):
|
| 16 |
+
response = self.get_response(request)
|
| 17 |
+
|
| 18 |
+
# Logger l'activité si utilisateur authentifié
|
| 19 |
+
if request.user.is_authenticated and request.method in ['POST', 'PUT', 'PATCH', 'DELETE']:
|
| 20 |
+
from apps.analytics.models import UserActivity
|
| 21 |
+
|
| 22 |
+
try:
|
| 23 |
+
UserActivity.objects.create(
|
| 24 |
+
user=request.user,
|
| 25 |
+
activity_type=f"{request.method}_{request.path}",
|
| 26 |
+
activity_data={
|
| 27 |
+
'path': request.path,
|
| 28 |
+
'method': request.method,
|
| 29 |
+
'status_code': response.status_code
|
| 30 |
+
},
|
| 31 |
+
ip_address=request.META.get('REMOTE_ADDR'),
|
| 32 |
+
user_agent=request.META.get('HTTP_USER_AGENT', '')[:500]
|
| 33 |
+
)
|
| 34 |
+
except Exception as e:
|
| 35 |
+
logger.error(f"Error tracking activity: {e}")
|
| 36 |
+
|
| 37 |
+
return response
|
| 38 |
+
|