commit b7a84a53aa14f9441e32a2772beaf7210032c475 Author: EvanChal Date: Thu Aug 21 00:28:21 2025 +0200 initial commit - LeDiscord plateforme des copains diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..41ae8f7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,73 @@ +# Git +.git +.gitignore +README.md + +# Documentation +docs/ +*.md + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Temporary files +*.tmp +*.temp +.cache/ + +# Node modules (pour le frontend) +frontend/node_modules/ +frontend/dist/ + +# Python cache +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.so + +# Virtual environments +venv/ +env/ +ENV/ + +# Database +*.db +*.sqlite +*.sqlite3 +postgres_data/ + +# Uploads +uploads/ +!uploads/.gitkeep + +# Environment files +.env +.env.local +.env.production + +# Docker +docker-compose.override.yml +Dockerfile* + +# Testing +.coverage +htmlcov/ +.pytest_cache/ +.tox/ + +# Celery +celerybeat-schedule +celerybeat.pid diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..27421cf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,107 @@ +name: CI/CD Pipeline + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: lediscord_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install Python dependencies + run: | + cd backend + pip install -r requirements.txt + + - name: Run backend tests + run: | + cd backend + python -m pytest tests/ -v + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/lediscord_test + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + run: | + cd frontend + npm ci + + - name: Run frontend tests + run: | + cd frontend + npm run test:unit + + - name: Build frontend + run: | + cd frontend + npm run build + + security: + runs-on: ubuntu-latest + needs: test + + steps: + - uses: actions/checkout@v3 + + - name: Run security scan + uses: snyk/actions/node@master + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + args: --severity-threshold=high + path: frontend/ + + - name: Run Python security scan + run: | + cd backend + pip install safety + safety check + + docker: + runs-on: ubuntu-latest + needs: [test, security] + + steps: + - uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Build and test Docker images + run: | + docker-compose build + docker-compose up -d + sleep 30 + curl -f http://localhost:8000/health || exit 1 + curl -f http://localhost:5173 || exit 1 + docker-compose down diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4005159 --- /dev/null +++ b/.gitignore @@ -0,0 +1,107 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# FastAPI +.env +.env.local +.env.production +*.db +*.sqlite +*.sqlite3 + +# Uploads +uploads/ +!uploads/.gitkeep + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Vue +.DS_Store +dist/ +dist-ssr/ +*.local + +# Editor directories and files +.idea +.vscode +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Logs +logs/ +*.log + +# Docker +postgres_data/ +docker-compose.override.yml + +# Testing +.coverage +htmlcov/ +.pytest_cache/ +.tox/ + +# Celery +celerybeat-schedule +celerybeat.pid + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini + +# Production +.env.production +.env.staging +*.pem +*.key +*.crt + +# Temporary files +*.tmp +*.temp +.cache/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0e9c69c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,158 @@ +# Guide de contribution - LeDiscord + +## 🎯 Comment contribuer + +Merci de votre intĂ©rĂȘt pour contribuer Ă  LeDiscord ! Ce guide vous aidera Ă  comprendre comment participer au projet. + +## 🚀 DĂ©marrage rapide + +### 1. Fork et clone +```bash +# Fork le projet sur GitHub +# Puis clonez votre fork +git clone https://github.com/votre-username/LeDiscord.git +cd LeDiscord +``` + +### 2. Configuration de l'environnement +```bash +# Copier le fichier d'environnement +cp env.example .env + +# Éditer .env avec vos valeurs +nano .env + +# DĂ©marrer l'application +make start +``` + +### 3. CrĂ©er une branche +```bash +git checkout -b feature/nom-de-votre-fonctionnalite +``` + +## 📝 Standards de code + +### Backend (Python) +- **Formatage** : Utilisez `black` pour le formatage automatique +- **Linting** : Utilisez `flake8` pour la vĂ©rification du code +- **Types** : Utilisez les annotations de type Python +- **Tests** : Écrivez des tests pour toutes les nouvelles fonctionnalitĂ©s + +### Frontend (Vue.js) +- **Formatage** : Utilisez `prettier` pour le formatage automatique +- **Linting** : Utilisez `eslint` pour la vĂ©rification du code +- **Composition API** : Utilisez la Composition API de Vue 3 +- **Tests** : Écrivez des tests unitaires pour les composants + +## 🔧 Outils de dĂ©veloppement + +### Installation des outils +```bash +# Backend +cd backend +pip install black flake8 pytest + +# Frontend +cd frontend +npm install -g prettier eslint +``` + +### Commandes utiles +```bash +# Formatage automatique +make format + +# VĂ©rification du code +make lint + +# Tests +make test +``` + +## đŸ§Ș Tests + +### Backend +```bash +cd backend +pytest tests/ -v +``` + +### Frontend +```bash +cd frontend +npm run test:unit +``` + +## 📋 Checklist avant de soumettre + +- [ ] Code formatĂ© avec les outils appropriĂ©s +- [ ] Tests passent +- [ ] Documentation mise Ă  jour +- [ ] Pas de secrets ou de donnĂ©es sensibles dans le code +- [ ] Messages de commit clairs et descriptifs + +## 🚀 Processus de contribution + +### 1. DĂ©veloppement +- DĂ©veloppez votre fonctionnalitĂ© +- Écrivez des tests +- Mettez Ă  jour la documentation + +### 2. Tests +```bash +# Tests backend +make test-backend + +# Tests frontend +make test-frontend + +# Tests complets +make test +``` + +### 3. Commit et push +```bash +git add . +git commit -m "feat: ajouter une nouvelle fonctionnalitĂ©" +git push origin feature/nom-de-votre-fonctionnalite +``` + +### 4. Pull Request +- CrĂ©ez une PR sur GitHub +- DĂ©crivez clairement les changements +- Attendez la review + +## 📚 Ressources utiles + +- [Documentation FastAPI](https://fastapi.tiangolo.com/) +- [Documentation Vue.js 3](https://vuejs.org/) +- [Documentation Tailwind CSS](https://tailwindcss.com/) +- [Documentation Docker](https://docs.docker.com/) + +## 🐛 Signaler un bug + +1. VĂ©rifiez que le bug n'a pas dĂ©jĂ  Ă©tĂ© signalĂ© +2. CrĂ©ez une issue avec : + - Description claire du bug + - Étapes pour le reproduire + - Comportement attendu vs. observĂ© + - Version de l'application + - Logs d'erreur si applicable + +## 💡 Proposer une fonctionnalitĂ© + +1. CrĂ©ez une issue avec le label "enhancement" +2. DĂ©crivez la fonctionnalitĂ© souhaitĂ©e +3. Expliquez pourquoi elle serait utile +4. Proposez une implĂ©mentation si possible + +## 📞 Besoin d'aide ? + +- CrĂ©ez une issue avec le label "question" +- Consultez la documentation +- Rejoignez la communautĂ© + +## 🎉 Merci ! + +Votre contribution aide Ă  amĂ©liorer LeDiscord pour tous les utilisateurs. Merci de participer au projet ! diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fb669dc --- /dev/null +++ b/Makefile @@ -0,0 +1,56 @@ +.PHONY: help start stop restart logs clean build install + +help: ## Afficher cette aide + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' + +start: ## DĂ©marrer l'application + ./start.sh + +stop: ## ArrĂȘter l'application + ./stop.sh + +restart: ## RedĂ©marrer l'application + ./stop.sh + ./start.sh + +logs: ## Afficher les logs + docker compose logs -f + +logs-backend: ## Afficher les logs du backend + docker compose logs -f backend + +logs-frontend: ## Afficher les logs du frontend + docker compose logs -f frontend + +logs-db: ## Afficher les logs de la base de donnĂ©es + docker compose logs -f postgres + +build: ## Reconstruire les images Docker + docker compose build + +clean: ## Nettoyer les conteneurs et volumes + docker compose down -v + rm -rf backend/__pycache__ + rm -rf backend/**/__pycache__ + +install: ## Installer les dĂ©pendances localement (dev) + cd backend && pip install -r requirements.txt + cd frontend && npm install + +dev-backend: ## Lancer le backend en mode dĂ©veloppement + cd backend && uvicorn app:app --reload --host 0.0.0.0 --port 8000 + +dev-frontend: ## Lancer le frontend en mode dĂ©veloppement + cd frontend && npm run dev + +shell-backend: ## Ouvrir un shell dans le conteneur backend + docker compose exec backend /bin/bash + +shell-db: ## Ouvrir psql dans le conteneur PostgreSQL + docker compose exec postgres psql -U lediscord_user -d lediscord + +backup-db: ## Sauvegarder la base de donnĂ©es + docker compose exec postgres pg_dump -U lediscord_user lediscord > backup_$$(date +%Y%m%d_%H%M%S).sql + +status: ## Afficher le statut des conteneurs + docker compose ps diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f4bc88 --- /dev/null +++ b/README.md @@ -0,0 +1,265 @@ +# LeDiscord - Plateforme communautaire pour groupe d'amis + +## 🎯 Description + +LeDiscord est une plateforme web privĂ©e pour gĂ©rer la vie sociale d'un groupe d'amis. Elle permet de : + +- đŸ“č **Vlogs** : Partager des vlogs vidĂ©o avec le groupe +- 📾 **Albums** : CrĂ©er des albums photos/vidĂ©os pour les soirĂ©es et vacances +- 📅 **ÉvĂ©nements** : Organiser des Ă©vĂ©nements avec systĂšme de prĂ©sence +- 💬 **Publications** : Poster des actualitĂ©s et mentionner des amis +- 📊 **Statistiques** : Visualiser des stats fun sur le groupe +- đŸ‘„ **Taux de prĂ©sence** : Suivre la participation aux Ă©vĂ©nements +- 🔔 **Notifications** : Recevoir des alertes par email et dans l'app +- đŸ›Ąïž **Admin** : Dashboard pour gĂ©rer l'espace de stockage et les utilisateurs +- đŸŽ« **Tickets** : SystĂšme de support et de gestion des demandes +- â„č **Informations** : Gestion d'informations publiques et privĂ©es + +## 🚀 Technologies + +### Backend +- **Python 3.11** avec FastAPI +- **PostgreSQL** pour la base de donnĂ©es +- **JWT** pour l'authentification +- **SQLAlchemy** comme ORM +- **Alembic** pour les migrations +- **Celery** pour les tĂąches asynchrones (notifications) +- **PIL (Pillow)** pour le traitement d'images +- **OpenCV** pour la gĂ©nĂ©ration de miniatures vidĂ©o + +### Frontend +- **Vue.js 3** avec Composition API +- **Vite** pour le build +- **Tailwind CSS** pour le style +- **Pinia** pour la gestion d'Ă©tat +- **Vue Router** pour la navigation +- **Axios** pour les appels API + +### Infrastructure +- **Docker** et Docker Compose +- **Volumes mappĂ©s** pour le stockage des mĂ©dias + +## 📁 Structure du projet + +``` +LeDiscord/ +├── backend/ +│ ├── api/routers/ # Routes API (auth, events, albums, etc.) +│ ├── models/ # ModĂšles SQLAlchemy +│ ├── schemas/ # SchĂ©mas Pydantic +│ ├── config/ # Configuration +│ ├── utils/ # Utilitaires (sĂ©curitĂ©, email, etc.) +│ └── app.py # Application principale +├── frontend/ +│ ├── src/ +│ │ ├── views/ # Pages Vue +│ │ ├── layouts/ # Layouts de l'app +│ │ ├── stores/ # Stores Pinia +│ │ ├── router/ # Configuration du router +│ │ └── utils/ # Utilitaires +│ └── package.json +├── uploads/ # Dossier des mĂ©dias uploadĂ©s (mappĂ© sur NAS) +├── docker-compose.yml # Configuration Docker +├── Makefile # Commandes utiles +├── env.example # Variables d'environnement d'exemple +└── README.md +``` + +## đŸ› ïž Installation et dĂ©marrage + +### PrĂ©requis +- Docker et Docker Compose installĂ©s +- Port 8000 (backend), 5173 (frontend) et 5432 (PostgreSQL) disponibles +- Git pour cloner le projet + +### 1. **Cloner le projet** +```bash +git clone +cd LeDiscord +``` + +### 2. **Configuration de l'environnement** +```bash +# Copier le fichier d'exemple +cp env.example .env + +# Éditer le fichier .env avec vos valeurs +nano .env +``` + +**Variables importantes Ă  modifier :** +- `DB_PASSWORD` : Mot de passe fort pour la base de donnĂ©es +- `JWT_SECRET_KEY` : ClĂ© secrĂšte trĂšs longue et alĂ©atoire +- `ADMIN_PASSWORD` : Mot de passe admin (Ă  changer absolument !) +- `SMTP_USER` et `SMTP_PASSWORD` : Configuration email (optionnel) + +### 3. **DĂ©marrer l'application** +```bash +# DĂ©marrer tous les services +make start + +# Ou manuellement +docker-compose up --build -d +``` + +### 4. **AccĂ©der Ă  l'application** +- **Frontend** : http://localhost:5173 +- **API Docs** : http://localhost:8000/docs +- **Admin** : Se connecter avec les identifiants admin dĂ©finis dans `.env` + +## đŸ‘€ Compte administrateur par dĂ©faut +- Email : `admin@lediscord.com` +- Mot de passe : Celui dĂ©fini dans votre fichier `.env` + +## 🔧 DĂ©veloppement + +### Commandes utiles (Makefile) +```bash +make help # Afficher l'aide +make start # DĂ©marrer l'application +make stop # ArrĂȘter l'application +make restart # RedĂ©marrer l'application +make logs # Voir les logs +make build # Reconstruire les images Docker +make clean # Nettoyer les conteneurs et volumes +make install # Installer les dĂ©pendances localement +``` + +### DĂ©veloppement local +```bash +# Backend +make dev-backend + +# Frontend +make dev-frontend +``` + +### AccĂšs aux conteneurs +```bash +make shell-backend # Shell dans le conteneur backend +make shell-db # psql dans PostgreSQL +``` + +## 📝 FonctionnalitĂ©s principales + +### Authentification +- Inscription/Connexion avec JWT +- Tokens d'accĂšs sĂ©curisĂ©s +- Protection des routes +- Gestion des permissions admin/user + +### ÉvĂ©nements +- CrĂ©ation d'Ă©vĂ©nements avec date, lieu, description +- SystĂšme de prĂ©sence (prĂ©sent/absent/peut-ĂȘtre) +- Notifications automatiques aux membres +- Taux de prĂ©sence calculĂ© automatiquement + +### Albums & MĂ©dias +- Upload de photos/vidĂ©os +- Albums liĂ©s aux Ă©vĂ©nements +- GĂ©nĂ©ration automatique de miniatures +- Gestion de l'espace de stockage +- Support multi-fichiers avec drag & drop + +### Vlogs +- Upload de vidĂ©os +- Compteur de vues +- Miniatures personnalisĂ©es +- GĂ©nĂ©ration automatique de miniatures + +### Publications +- Posts avec mentions d'utilisateurs +- Notifications lors des mentions +- Timeline chronologique +- Support des images + +### Statistiques +- Taux de prĂ©sence par utilisateur +- Membre le plus actif +- Statistiques fun personnalisables + +### Administration +- Dashboard de gestion +- Monitoring de l'espace disque +- Gestion des utilisateurs +- Nettoyage des fichiers orphelins +- Gestion des tickets de support + +### Tickets +- SystĂšme de support complet +- Gestion des prioritĂ©s et statuts +- Upload de captures d'Ă©cran +- Notes administrateur +- Assignation aux admins + +### Informations +- Gestion d'informations publiques/privĂ©es +- SystĂšme de catĂ©gories +- PrioritĂ©s d'affichage +- Interface d'administration + +## 🔐 SĂ©curitĂ© + +- Authentification JWT +- Mots de passe hashĂ©s avec bcrypt +- Protection CORS configurĂ©e +- Validation des types de fichiers +- Limitation de la taille des uploads +- Permissions par rĂŽle (admin/user) +- Variables d'environnement sĂ©curisĂ©es + +## 🚧 Roadmap / AmĂ©liorations futures + +- [ ] Application mobile (React Native ou PWA) +- [ ] Notifications push sur mobile +- [ ] Chat en temps rĂ©el +- [ ] Calendrier partagĂ© +- [ ] Sondages et votes +- [ ] Cagnotte pour les Ă©vĂ©nements +- [ ] Export des photos d'un Ă©vĂ©nement +- [ ] Mode sombre +- [ ] SystĂšme de likes/rĂ©actions +- [ ] Historique d'activitĂ© dĂ©taillĂ© + +## 📋 Checklist de dĂ©ploiement + +### Avant de commiter sur GitHub +- [ ] VĂ©rifier que `.env` est dans `.gitignore` +- [ ] VĂ©rifier que `uploads/` est dans `.gitignore` +- [ ] VĂ©rifier que `postgres_data/` est dans `.gitignore` +- [ ] Tester que l'application dĂ©marre correctement + +### Sur la nouvelle machine +- [ ] Cloner le repository +- [ ] Copier `env.example` vers `.env` +- [ ] Modifier les variables dans `.env` +- [ ] Lancer `make start` +- [ ] VĂ©rifier l'accĂšs Ă  l'application + +## 🐛 DĂ©pannage + +### ProblĂšmes courants +1. **Ports dĂ©jĂ  utilisĂ©s** : VĂ©rifiez que 8000, 5173 et 5432 sont libres +2. **Permissions Docker** : Assurez-vous d'ĂȘtre dans le groupe docker +3. **Variables d'environnement** : VĂ©rifiez que `.env` est correctement configurĂ© +4. **Base de donnĂ©es** : VĂ©rifiez que PostgreSQL peut dĂ©marrer + +### Logs et debugging +```bash +make logs # Voir tous les logs +make logs-backend # Logs du backend uniquement +make logs-frontend # Logs du frontend uniquement +make logs-db # Logs de la base de donnĂ©es +``` + +## 📄 Licence + +Projet privĂ© - Tous droits rĂ©servĂ©s + +## đŸ€ Contact + +Pour toute question sur le projet, contactez l'administrateur. + +--- + +**Note** : Ce projet est conçu pour un usage privĂ© entre amis. Assurez-vous de configurer correctement la sĂ©curitĂ© avant tout dĂ©ploiement en production. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..d754e4b --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + libpq-dev \ + libmagic1 \ + libgl1 \ + libglib2.0-0 \ + libsm6 \ + libxext6 \ + libxrender1 \ + libgomp1 \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for better caching +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create uploads directory +RUN mkdir -p /app/uploads + +# Run the application +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..ff0677a --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +# Backend package initialization diff --git a/backend/api/routers/__init__.py b/backend/api/routers/__init__.py new file mode 100644 index 0000000..0bcdda5 --- /dev/null +++ b/backend/api/routers/__init__.py @@ -0,0 +1,3 @@ +from . import auth, users, events, albums, posts, vlogs, stats, admin, notifications, settings, information, tickets + +__all__ = ["auth", "users", "events", "albums", "posts", "vlogs", "stats", "admin", "notifications", "settings", "information", "tickets"] diff --git a/backend/api/routers/admin.py b/backend/api/routers/admin.py new file mode 100644 index 0000000..24784b7 --- /dev/null +++ b/backend/api/routers/admin.py @@ -0,0 +1,444 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from sqlalchemy import func +from typing import List +import os +from pathlib import Path +from config.database import get_db +from config.settings import settings +from models.user import User +from models.album import Album, Media +from models.vlog import Vlog +from models.event import Event +from models.post import Post +from utils.security import get_admin_user + +router = APIRouter() + +def get_directory_size(path): + """Calculate total size of a directory.""" + total = 0 + try: + for entry in os.scandir(path): + if entry.is_file(): + total += entry.stat().st_size + elif entry.is_dir(): + total += get_directory_size(entry.path) + except (OSError, PermissionError): + pass + return total + +def format_bytes(bytes): + """Format bytes to human readable string.""" + for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + if bytes < 1024.0: + return f"{bytes:.2f} {unit}" + bytes /= 1024.0 + return f"{bytes:.2f} PB" + +@router.get("/dashboard") +async def get_admin_dashboard( + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """Get admin dashboard information.""" + # User statistics + total_users = db.query(User).count() + active_users = db.query(User).filter(User.is_active == True).count() + admin_users = db.query(User).filter(User.is_admin == True).count() + + # Content statistics + total_events = db.query(Event).count() + total_posts = db.query(Post).count() + total_vlogs = db.query(Vlog).count() + total_media = db.query(Media).count() + + # Storage statistics + upload_path = Path(settings.UPLOAD_PATH) + total_storage = 0 + storage_breakdown = {} + + if upload_path.exists(): + # Calculate storage by category + categories = ['avatars', 'albums', 'vlogs', 'posts'] + for category in categories: + category_path = upload_path / category + if category_path.exists(): + size = get_directory_size(category_path) + storage_breakdown[category] = { + "bytes": size, + "formatted": format_bytes(size) + } + total_storage += size + + # Database storage + db_size = db.query(func.sum(Media.file_size)).scalar() or 0 + + return { + "users": { + "total": total_users, + "active": active_users, + "admins": admin_users + }, + "content": { + "events": total_events, + "posts": total_posts, + "vlogs": total_vlogs, + "media_files": total_media + }, + "storage": { + "total_bytes": total_storage, + "total_formatted": format_bytes(total_storage), + "breakdown": storage_breakdown, + "database_tracked": format_bytes(db_size) + } + } + +@router.get("/users") +async def get_all_users_admin( + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """Get all users with admin details.""" + users = db.query(User).all() + + user_list = [] + for user in users: + # Calculate user storage + user_media = db.query(func.sum(Media.file_size)).join(Album).filter( + Album.creator_id == user.id + ).scalar() or 0 + + user_vlogs = db.query(func.sum(Vlog.id)).filter( + Vlog.author_id == user.id + ).scalar() or 0 + + user_list.append({ + "id": user.id, + "email": user.email, + "username": user.username, + "full_name": user.full_name, + "is_active": user.is_active, + "is_admin": user.is_admin, + "created_at": user.created_at, + "attendance_rate": user.attendance_rate, + "storage_used": format_bytes(user_media), + "content_count": { + "posts": db.query(Post).filter(Post.author_id == user.id).count(), + "vlogs": user_vlogs, + "albums": db.query(Album).filter(Album.creator_id == user.id).count() + } + }) + + return user_list + +@router.put("/users/{user_id}/toggle-active") +async def toggle_user_active( + user_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """Toggle user active status.""" + if user_id == current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot deactivate your own account" + ) + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + user.is_active = not user.is_active + db.commit() + + return { + "message": f"User {'activated' if user.is_active else 'deactivated'} successfully", + "is_active": user.is_active + } + +@router.put("/users/{user_id}/toggle-admin") +async def toggle_user_admin( + user_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """Toggle user admin status.""" + if user_id == current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot change your own admin status" + ) + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + user.is_admin = not user.is_admin + db.commit() + + return { + "message": f"User admin status {'granted' if user.is_admin else 'revoked'} successfully", + "is_admin": user.is_admin + } + +@router.delete("/users/{user_id}") +async def delete_user( + user_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """Delete a user and all their content.""" + if user_id == current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot delete your own account" + ) + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + # Delete user's files + user_dirs = [ + Path(settings.UPLOAD_PATH) / "avatars" / str(user_id), + Path(settings.UPLOAD_PATH) / "vlogs" / str(user_id) + ] + + for dir_path in user_dirs: + if dir_path.exists(): + import shutil + shutil.rmtree(dir_path) + + # Delete user (cascade will handle related records) + db.delete(user) + db.commit() + + return {"message": "User deleted successfully"} + +@router.get("/storage/cleanup") +async def cleanup_storage( + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """Find and optionally clean up orphaned files.""" + upload_path = Path(settings.UPLOAD_PATH) + orphaned_files = [] + total_orphaned_size = 0 + + try: + # Check for orphaned media files + if (upload_path / "albums").exists(): + for album_dir in (upload_path / "albums").iterdir(): + if album_dir.is_dir(): + try: + album_id = int(album_dir.name) + album = db.query(Album).filter(Album.id == album_id).first() + + if not album: + # Entire album directory is orphaned + size = get_directory_size(album_dir) + orphaned_files.append({ + "path": str(album_dir), + "type": "album_directory", + "size": format_bytes(size) + }) + total_orphaned_size += size + else: + # Check individual files + for file_path in album_dir.glob("*"): + if file_path.is_file(): + relative_path = f"/albums/{album_id}/{file_path.name}" + media = db.query(Media).filter( + (Media.file_path == relative_path) | + (Media.thumbnail_path == relative_path) + ).first() + + if not media: + size = file_path.stat().st_size + orphaned_files.append({ + "path": str(file_path), + "type": "media_file", + "size": format_bytes(size) + }) + total_orphaned_size += size + except (ValueError, OSError) as e: + print(f"Error processing album directory {album_dir}: {e}") + continue + + # Check for orphaned avatar files + if (upload_path / "avatars").exists(): + for user_dir in (upload_path / "avatars").iterdir(): + if user_dir.is_dir(): + try: + user_id = int(user_dir.name) + user = db.query(User).filter(User.id == user_id).first() + + if not user: + size = get_directory_size(user_dir) + orphaned_files.append({ + "path": str(user_dir), + "type": "user_avatar_directory", + "size": format_bytes(size) + }) + total_orphaned_size += size + except (ValueError, OSError) as e: + print(f"Error processing avatar directory {user_dir}: {e}") + continue + + # Check for orphaned vlog files + if (upload_path / "vlogs").exists(): + for user_dir in (upload_path / "vlogs").iterdir(): + if user_dir.is_dir(): + try: + user_id = int(user_dir.name) + user = db.query(User).filter(User.id == user_id).first() + + if not user: + size = get_directory_size(user_dir) + orphaned_files.append({ + "path": str(user_dir), + "type": "user_vlog_directory", + "size": format_bytes(size) + }) + total_orphaned_size += size + except (ValueError, OSError) as e: + print(f"Error processing vlog directory {user_dir}: {e}") + continue + + return { + "orphaned_files": orphaned_files, + "total_orphaned": len(orphaned_files), + "total_orphaned_size": format_bytes(total_orphaned_size), + "message": f"TrouvĂ© {len(orphaned_files)} fichier(s) orphelin(s) pour un total de {format_bytes(total_orphaned_size)}. Utilisez DELETE pour les supprimer." + } + + except Exception as e: + print(f"Error during cleanup scan: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error during cleanup scan: {str(e)}" + ) + +@router.delete("/storage/cleanup") +async def delete_orphaned_files( + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """Delete orphaned files.""" + upload_path = Path(settings.UPLOAD_PATH) + deleted_files = [] + total_freed_space = 0 + + try: + # Check for orphaned media files + if (upload_path / "albums").exists(): + for album_dir in (upload_path / "albums").iterdir(): + if album_dir.is_dir(): + try: + album_id = int(album_dir.name) + album = db.query(Album).filter(Album.id == album_id).first() + + if not album: + # Entire album directory is orphaned + size = get_directory_size(album_dir) + import shutil + shutil.rmtree(album_dir) + deleted_files.append({ + "path": str(album_dir), + "type": "album_directory", + "size": format_bytes(size) + }) + total_freed_space += size + else: + # Check individual files + for file_path in album_dir.glob("*"): + if file_path.is_file(): + relative_path = f"/albums/{album_id}/{file_path.name}" + media = db.query(Media).filter( + (Media.file_path == relative_path) | + (Media.thumbnail_path == relative_path) + ).first() + + if not media: + size = file_path.stat().st_size + file_path.unlink() # Delete the file + deleted_files.append({ + "path": str(file_path), + "type": "media_file", + "size": format_bytes(size) + }) + total_freed_space += size + except (ValueError, OSError) as e: + print(f"Error processing album directory {album_dir}: {e}") + continue + + # Check for orphaned avatar files + if (upload_path / "avatars").exists(): + for user_dir in (upload_path / "avatars").iterdir(): + if user_dir.is_dir(): + try: + user_id = int(user_dir.name) + user = db.query(User).filter(User.id == user_id).first() + + if not user: + # User directory is orphaned + size = get_directory_size(user_dir) + import shutil + shutil.rmtree(user_dir) + deleted_files.append({ + "path": str(user_dir), + "type": "user_avatar_directory", + "size": format_bytes(size) + }) + total_freed_space += size + except (ValueError, OSError) as e: + print(f"Error processing avatar directory {user_dir}: {e}") + continue + + # Check for orphaned vlog files + if (upload_path / "vlogs").exists(): + for user_dir in (upload_path / "vlogs").iterdir(): + if user_dir.is_dir(): + try: + user_id = int(user_dir.name) + user = db.query(User).filter(User.id == user_id).first() + + if not user: + # User directory is orphaned + size = get_directory_size(user_dir) + import shutil + shutil.rmtree(user_dir) + deleted_files.append({ + "path": str(user_dir), + "type": "user_vlog_directory", + "size": format_bytes(size) + }) + total_freed_space += size + except (ValueError, OSError) as e: + print(f"Error processing vlog directory {user_dir}: {e}") + continue + + return { + "message": "Cleanup completed successfully", + "deleted_files": deleted_files, + "total_deleted": len(deleted_files), + "total_freed_space": format_bytes(total_freed_space), + "details": f"SupprimĂ© {len(deleted_files)} fichier(s) pour libĂ©rer {format_bytes(total_freed_space)}" + } + + except Exception as e: + print(f"Error during cleanup: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error during cleanup: {str(e)}" + ) diff --git a/backend/api/routers/albums.py b/backend/api/routers/albums.py new file mode 100644 index 0000000..48d69ee --- /dev/null +++ b/backend/api/routers/albums.py @@ -0,0 +1,464 @@ +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form +from sqlalchemy.orm import Session +from typing import List, Optional +import os +import uuid +from pathlib import Path +from PIL import Image +import magic +from config.database import get_db +from config.settings import settings +from models.album import Album, Media, MediaType, MediaLike +from models.user import User +from schemas.album import AlbumCreate, AlbumUpdate, AlbumResponse +from utils.security import get_current_active_user +from utils.settings_service import SettingsService + +router = APIRouter() + +@router.post("/", response_model=AlbumResponse) +async def create_album( + album_data: AlbumCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Create a new album.""" + album = Album( + **album_data.dict(), + creator_id=current_user.id + ) + db.add(album) + db.commit() + db.refresh(album) + return format_album_response(album, db, current_user.id) + +@router.get("/", response_model=List[AlbumResponse]) +async def get_albums( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), + event_id: Optional[int] = None +): + """Get all albums, optionally filtered by event.""" + query = db.query(Album) + if event_id: + query = query.filter(Album.event_id == event_id) + albums = query.order_by(Album.created_at.desc()).all() + return [format_album_response(album, db, current_user.id) for album in albums] + +@router.get("/{album_id}", response_model=AlbumResponse) +async def get_album( + album_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Get a specific album.""" + album = db.query(Album).filter(Album.id == album_id).first() + if not album: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Album not found" + ) + return format_album_response(album, db, current_user.id) + +@router.put("/{album_id}", response_model=AlbumResponse) +async def update_album( + album_id: int, + album_update: AlbumUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Update an album.""" + album = db.query(Album).filter(Album.id == album_id).first() + if not album: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Album not found" + ) + + if album.creator_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to update this album" + ) + + for field, value in album_update.dict(exclude_unset=True).items(): + setattr(album, field, value) + + db.commit() + db.refresh(album) + return format_album_response(album, db, current_user.id) + +@router.delete("/{album_id}") +async def delete_album( + album_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Delete an album.""" + album = db.query(Album).filter(Album.id == album_id).first() + if not album: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Album not found" + ) + + if album.creator_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to delete this album" + ) + + # Delete media files + for media in album.media: + try: + if media.file_path: + os.remove(settings.UPLOAD_PATH + media.file_path) + if media.thumbnail_path: + os.remove(settings.UPLOAD_PATH + media.thumbnail_path) + except: + pass + + db.delete(album) + db.commit() + return {"message": "Album deleted successfully"} + +@router.post("/{album_id}/media") +async def upload_media( + album_id: int, + files: List[UploadFile] = File(...), + captions: Optional[List[str]] = Form(None), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Upload media files to an album.""" + album = db.query(Album).filter(Album.id == album_id).first() + if not album: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Album not found" + ) + + if album.creator_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to add media to this album" + ) + + # Check max media per album limit + from utils.settings_service import SettingsService + max_media_per_album = SettingsService.get_setting(db, "max_media_per_album", 50) + current_media_count = len(album.media) + + if current_media_count + len(files) > max_media_per_album: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Album would exceed maximum of {max_media_per_album} media files. Current: {current_media_count}, trying to add: {len(files)}" + ) + + # Create album directory + album_dir = Path(settings.UPLOAD_PATH) / "albums" / str(album_id) + album_dir.mkdir(parents=True, exist_ok=True) + thumbnails_dir = album_dir / "thumbnails" + thumbnails_dir.mkdir(exist_ok=True) + + uploaded_media = [] + + for i, file in enumerate(files): + # Check file size + contents = await file.read() + file_size = len(contents) + + # Get dynamic upload limits + max_size = SettingsService.get_max_upload_size(db, file.content_type or "unknown") + if file_size > max_size: + max_size_mb = max_size // (1024 * 1024) + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f"File {file.filename} exceeds maximum size of {max_size_mb}MB" + ) + + # Check file type + mime = magic.from_buffer(contents, mime=True) + if not SettingsService.is_file_type_allowed(db, mime): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"File {file.filename} has unsupported type: {mime}" + ) + + # Determine media type + if mime.startswith('image/'): + media_type = MediaType.IMAGE + elif mime.startswith('video/'): + media_type = MediaType.VIDEO + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"File {file.filename} has unsupported type: {mime}" + ) + + # Generate unique filename + file_extension = file.filename.split(".")[-1] + filename = f"{uuid.uuid4()}.{file_extension}" + file_path = album_dir / filename + + # Save file + with open(file_path, "wb") as f: + f.write(contents) + + # Process media + thumbnail_path = None + width = height = duration = None + + if media_type == MediaType.IMAGE: + # Get image dimensions and create thumbnail + try: + img = Image.open(file_path) + width, height = img.size + + # Create thumbnail with better quality + thumbnail = img.copy() + thumbnail.thumbnail((800, 800)) # Larger thumbnail for better quality + thumbnail_filename = f"thumb_{filename}" + thumbnail_path_full = thumbnails_dir / thumbnail_filename + thumbnail.save(thumbnail_path_full, quality=95, optimize=True) # High quality + thumbnail_path = f"/albums/{album_id}/thumbnails/{thumbnail_filename}" + except Exception as e: + print(f"Error processing image: {e}") + + # Create media record + media = Media( + album_id=album_id, + file_path=f"/albums/{album_id}/{filename}", + thumbnail_path=thumbnail_path, + media_type=media_type, + caption=captions[i] if captions and i < len(captions) else None, + file_size=file_size, + width=width, + height=height, + duration=duration + ) + db.add(media) + uploaded_media.append(media) + + # Set first image as album cover if not set + if not album.cover_image and uploaded_media: + first_image = next((m for m in uploaded_media if m.media_type == MediaType.IMAGE), None) + if first_image: + album.cover_image = first_image.thumbnail_path or first_image.file_path + + db.commit() + + # Update event cover image if this album is linked to an event + if album.event_id: + update_event_cover_from_album(album.event_id, db) + + return { + "message": f"Successfully uploaded {len(uploaded_media)} files", + "media_count": len(uploaded_media) + } + +@router.delete("/{album_id}/media/{media_id}") +async def delete_media( + album_id: int, + media_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Delete a media file from an album.""" + media = db.query(Media).filter( + Media.id == media_id, + Media.album_id == album_id + ).first() + + if not media: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Media not found" + ) + + album = media.album + if album.creator_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to delete this media" + ) + + # Delete files + try: + if media.file_path: + os.remove(settings.UPLOAD_PATH + media.file_path) + if media.thumbnail_path: + os.remove(settings.UPLOAD_PATH + media.thumbnail_path) + except: + pass + + db.delete(media) + db.commit() + + # Update event cover image if this album is linked to an event + if album.event_id: + update_event_cover_from_album(album.event_id, db) + + return {"message": "Media deleted successfully"} + +@router.post("/{album_id}/media/{media_id}/like") +async def toggle_media_like( + album_id: int, + media_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Toggle like on a media file.""" + media = db.query(Media).filter( + Media.id == media_id, + Media.album_id == album_id + ).first() + + if not media: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Media not found" + ) + + existing_like = db.query(MediaLike).filter( + MediaLike.media_id == media_id, + MediaLike.user_id == current_user.id + ).first() + + if existing_like: + # Unlike + db.delete(existing_like) + media.likes_count = max(0, media.likes_count - 1) + message = "Like removed" + else: + # Like + like = MediaLike(media_id=media_id, user_id=current_user.id) + db.add(like) + media.likes_count += 1 + message = "Media liked" + + db.commit() + + # Update event cover image if this album is linked to an event + album = media.album + if album.event_id: + update_event_cover_from_album(album.event_id, db) + + return {"message": message, "likes_count": media.likes_count} + +def format_album_response(album: Album, db: Session, current_user_id: int) -> dict: + """Format album response with additional information.""" + # Get top media (most liked) + top_media = db.query(Media).filter( + Media.album_id == album.id + ).order_by(Media.likes_count.desc()).limit(3).all() + + # Format media with likes information + formatted_media = [] + for media in album.media: + # Check if current user liked this media + is_liked = db.query(MediaLike).filter( + MediaLike.media_id == media.id, + MediaLike.user_id == current_user_id + ).first() is not None + + # Format likes + likes = [] + for like in media.likes: + user = db.query(User).filter(User.id == like.user_id).first() + if user: + likes.append({ + "id": like.id, + "user_id": user.id, + "username": user.username, + "full_name": user.full_name, + "avatar_url": user.avatar_url, + "created_at": like.created_at + }) + + formatted_media.append({ + "id": media.id, + "file_path": media.file_path, + "thumbnail_path": media.thumbnail_path, + "media_type": media.media_type, + "caption": media.caption, + "file_size": media.file_size, + "width": media.width, + "height": media.height, + "duration": media.duration, + "likes_count": media.likes_count, + "is_liked": is_liked, + "likes": likes, + "created_at": media.created_at + }) + + # Format top media + formatted_top_media = [] + for media in top_media: + is_liked = db.query(MediaLike).filter( + MediaLike.media_id == media.id, + MediaLike.user_id == current_user_id + ).first() is not None + + likes = [] + for like in media.likes: + user = db.query(User).filter(User.id == like.user_id).first() + if user: + likes.append({ + "id": like.id, + "user_id": user.id, + "username": user.username, + "full_name": user.full_name, + "avatar_url": user.avatar_url, + "created_at": like.created_at + }) + + formatted_top_media.append({ + "id": media.id, + "file_path": media.file_path, + "thumbnail_path": media.thumbnail_path, + "media_type": media.media_type, + "caption": media.caption, + "file_size": media.file_size, + "width": media.width, + "height": media.height, + "duration": media.duration, + "likes_count": media.likes_count, + "is_liked": is_liked, + "likes": likes, + "created_at": media.created_at + }) + + return { + "id": album.id, + "title": album.title, + "description": album.description, + "creator_id": album.creator_id, + "event_id": album.event_id, + "cover_image": album.cover_image, + "created_at": album.created_at, + "updated_at": album.updated_at, + "creator_name": album.creator.full_name, + "media_count": len(album.media), + "media": formatted_media, + "top_media": formatted_top_media, + "event_title": album.event.title if album.event else None + } + +def update_event_cover_from_album(event_id: int, db: Session): + """Update event cover image from the most liked media in linked albums.""" + from models.event import Event + + event = db.query(Event).filter(Event.id == event_id).first() + if not event: + return + + # Get the most liked media from all albums linked to this event + most_liked_media = db.query(Media).join(Album).filter( + Album.event_id == event_id, + Media.media_type == MediaType.IMAGE + ).order_by(Media.likes_count.desc()).first() + + if most_liked_media: + event.cover_image = most_liked_media.thumbnail_path or most_liked_media.file_path + db.commit() diff --git a/backend/api/routers/auth.py b/backend/api/routers/auth.py new file mode 100644 index 0000000..2729378 --- /dev/null +++ b/backend/api/routers/auth.py @@ -0,0 +1,97 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session +from datetime import timedelta +from config.database import get_db +from config.settings import settings +from models.user import User +from schemas.user import UserCreate, UserResponse, Token +from utils.security import verify_password, get_password_hash, create_access_token + +router = APIRouter() + +@router.post("/register", response_model=Token) +async def register(user_data: UserCreate, db: Session = Depends(get_db)): + """Register a new user.""" + # Check if registration is enabled + from utils.settings_service import SettingsService + if not SettingsService.get_setting(db, "enable_registration", True): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Registration is currently disabled" + ) + + # Check if max users limit is reached + max_users = SettingsService.get_setting(db, "max_users", 50) + current_users_count = db.query(User).count() + if current_users_count >= max_users: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Maximum number of users reached" + ) + + # Check if email already exists + if db.query(User).filter(User.email == user_data.email).first(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + # Check if username already exists + if db.query(User).filter(User.username == user_data.username).first(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Username already taken" + ) + + # Create new user + user = User( + email=user_data.email, + username=user_data.username, + full_name=user_data.full_name, + hashed_password=get_password_hash(user_data.password) + ) + db.add(user) + db.commit() + db.refresh(user) + + # Create access token + access_token = create_access_token( + data={"sub": str(user.id), "email": user.email} + ) + + return { + "access_token": access_token, + "token_type": "bearer", + "user": user + } + +@router.post("/login", response_model=Token) +async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): + """Login with email and password.""" + # Find user by email (username field is used for email in OAuth2PasswordRequestForm) + user = db.query(User).filter(User.email == form_data.username).first() + + if not user or not verify_password(form_data.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Inactive user" + ) + + # Create access token + access_token = create_access_token( + data={"sub": str(user.id), "email": user.email} + ) + + return { + "access_token": access_token, + "token_type": "bearer", + "user": user + } diff --git a/backend/api/routers/events.py b/backend/api/routers/events.py new file mode 100644 index 0000000..d8b2517 --- /dev/null +++ b/backend/api/routers/events.py @@ -0,0 +1,257 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List +from datetime import datetime +from config.database import get_db +from models.event import Event, EventParticipation, ParticipationStatus +from models.user import User +from models.notification import Notification, NotificationType +from schemas.event import EventCreate, EventUpdate, EventResponse, ParticipationUpdate +from utils.security import get_current_active_user +from utils.email import send_event_notification + +router = APIRouter() + +@router.post("/", response_model=EventResponse) +async def create_event( + event_data: EventCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Create a new event.""" + event = Event( + **event_data.dict(), + creator_id=current_user.id + ) + db.add(event) + db.commit() + db.refresh(event) + + # Create participations for all active users + users = db.query(User).filter(User.is_active == True).all() + for user in users: + participation = EventParticipation( + event_id=event.id, + user_id=user.id, + status=ParticipationStatus.PENDING + ) + db.add(participation) + + # Create notification + if user.id != current_user.id: + notification = Notification( + user_id=user.id, + type=NotificationType.EVENT_INVITATION, + title=f"Nouvel Ă©vĂ©nement: {event.title}", + message=f"{current_user.full_name} a créé un nouvel Ă©vĂ©nement", + link=f"/events/{event.id}" + ) + db.add(notification) + + # Send email notification (async task would be better) + try: + send_event_notification(user.email, event) + except: + pass # Don't fail if email sending fails + + db.commit() + return format_event_response(event, db) + +@router.get("/", response_model=List[EventResponse]) +async def get_events( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), + upcoming: bool = None +): + """Get all events, optionally filtered by upcoming status.""" + query = db.query(Event) + + if upcoming is True: + # Only upcoming events + query = query.filter(Event.date >= datetime.utcnow()) + elif upcoming is False: + # Only past events + query = query.filter(Event.date < datetime.utcnow()) + # If upcoming is None, return all events + + events = query.order_by(Event.date.desc()).all() + return [format_event_response(event, db) for event in events] + +@router.get("/upcoming", response_model=List[EventResponse]) +async def get_upcoming_events( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Get only upcoming events.""" + events = db.query(Event).filter( + Event.date >= datetime.utcnow() + ).order_by(Event.date).all() + return [format_event_response(event, db) for event in events] + +@router.get("/past", response_model=List[EventResponse]) +async def get_past_events( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Get only past events.""" + events = db.query(Event).filter( + Event.date < datetime.utcnow() + ).order_by(Event.date.desc()).all() + return [format_event_response(event, db) for event in events] + +@router.get("/{event_id}", response_model=EventResponse) +async def get_event( + event_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Get a specific event.""" + event = db.query(Event).filter(Event.id == event_id).first() + if not event: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Event not found" + ) + return format_event_response(event, db) + +@router.put("/{event_id}", response_model=EventResponse) +async def update_event( + event_id: int, + event_update: EventUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Update an event.""" + event = db.query(Event).filter(Event.id == event_id).first() + if not event: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Event not found" + ) + + if event.creator_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to update this event" + ) + + for field, value in event_update.dict(exclude_unset=True).items(): + setattr(event, field, value) + + db.commit() + db.refresh(event) + return format_event_response(event, db) + +@router.delete("/{event_id}") +async def delete_event( + event_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Delete an event.""" + event = db.query(Event).filter(Event.id == event_id).first() + if not event: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Event not found" + ) + + if event.creator_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to delete this event" + ) + + db.delete(event) + db.commit() + return {"message": "Event deleted successfully"} + +@router.put("/{event_id}/participation", response_model=EventResponse) +async def update_participation( + event_id: int, + participation_update: ParticipationUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Update user participation status for an event.""" + participation = db.query(EventParticipation).filter( + EventParticipation.event_id == event_id, + EventParticipation.user_id == current_user.id + ).first() + + if not participation: + # Create new participation if it doesn't exist + participation = EventParticipation( + event_id=event_id, + user_id=current_user.id, + status=participation_update.status + ) + db.add(participation) + else: + participation.status = participation_update.status + participation.response_date = datetime.utcnow() + + db.commit() + + # Update user attendance rate + update_user_attendance_rate(current_user, db) + + event = db.query(Event).filter(Event.id == event_id).first() + return format_event_response(event, db) + +def format_event_response(event: Event, db: Session) -> dict: + """Format event response with participation counts.""" + participations = [] + present_count = absent_count = maybe_count = pending_count = 0 + + for p in event.participations: + user = db.query(User).filter(User.id == p.user_id).first() + participations.append({ + "user_id": user.id, + "username": user.username, + "full_name": user.full_name, + "avatar_url": user.avatar_url, + "status": p.status, + "response_date": p.response_date + }) + + if p.status == ParticipationStatus.PRESENT: + present_count += 1 + elif p.status == ParticipationStatus.ABSENT: + absent_count += 1 + elif p.status == ParticipationStatus.MAYBE: + maybe_count += 1 + else: + pending_count += 1 + + return { + "id": event.id, + "title": event.title, + "description": event.description, + "location": event.location, + "date": event.date, + "end_date": event.end_date, + "creator_id": event.creator_id, + "cover_image": event.cover_image, + "created_at": event.created_at, + "updated_at": event.updated_at, + "creator_name": event.creator.full_name, + "participations": participations, + "present_count": present_count, + "absent_count": absent_count, + "maybe_count": maybe_count, + "pending_count": pending_count + } + +def update_user_attendance_rate(user: User, db: Session): + """Update user attendance rate based on past events.""" + past_participations = db.query(EventParticipation).join(Event).filter( + EventParticipation.user_id == user.id, + Event.date < datetime.utcnow(), + EventParticipation.status.in_([ParticipationStatus.PRESENT, ParticipationStatus.ABSENT]) + ).all() + + if past_participations: + present_count = sum(1 for p in past_participations if p.status == ParticipationStatus.PRESENT) + user.attendance_rate = (present_count / len(past_participations)) * 100 + db.commit() diff --git a/backend/api/routers/information.py b/backend/api/routers/information.py new file mode 100644 index 0000000..6d9d584 --- /dev/null +++ b/backend/api/routers/information.py @@ -0,0 +1,143 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List +from datetime import datetime +from config.database import get_db +from models.information import Information +from models.user import User +from schemas.information import InformationCreate, InformationUpdate, InformationResponse +from utils.security import get_current_active_user, get_admin_user + +router = APIRouter() + +@router.get("/", response_model=List[InformationResponse]) +async def get_informations( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), + category: str = None, + published_only: bool = True +): + """Get all informations, optionally filtered by category and published status.""" + query = db.query(Information) + + if published_only: + query = query.filter(Information.is_published == True) + + if category: + query = query.filter(Information.category == category) + + informations = query.order_by(Information.priority.desc(), Information.created_at.desc()).all() + return informations + +@router.get("/public", response_model=List[InformationResponse]) +async def get_public_informations( + db: Session = Depends(get_db), + category: str = None +): + """Get public informations without authentication.""" + query = db.query(Information).filter(Information.is_published == True) + + if category: + query = query.filter(Information.category == category) + + informations = query.order_by(Information.priority.desc(), Information.created_at.desc()).all() + return informations + +@router.get("/{information_id}", response_model=InformationResponse) +async def get_information( + information_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Get a specific information.""" + information = db.query(Information).filter(Information.id == information_id).first() + if not information: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Information not found" + ) + + if not information.is_published and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Information not found" + ) + + return information + +@router.post("/", response_model=InformationResponse) +async def create_information( + information_data: InformationCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """Create a new information (admin only).""" + information = Information(**information_data.dict()) + db.add(information) + db.commit() + db.refresh(information) + return information + +@router.put("/{information_id}", response_model=InformationResponse) +async def update_information( + information_id: int, + information_update: InformationUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """Update an information (admin only).""" + information = db.query(Information).filter(Information.id == information_id).first() + if not information: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Information not found" + ) + + for field, value in information_update.dict(exclude_unset=True).items(): + setattr(information, field, value) + + information.updated_at = datetime.utcnow() + db.commit() + db.refresh(information) + return information + +@router.delete("/{information_id}") +async def delete_information( + information_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """Delete an information (admin only).""" + information = db.query(Information).filter(Information.id == information_id).first() + if not information: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Information not found" + ) + + db.delete(information) + db.commit() + return {"message": "Information deleted successfully"} + +@router.put("/{information_id}/toggle-publish") +async def toggle_information_publish( + information_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """Toggle information published status (admin only).""" + information = db.query(Information).filter(Information.id == information_id).first() + if not information: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Information not found" + ) + + information.is_published = not information.is_published + information.updated_at = datetime.utcnow() + db.commit() + + return { + "message": f"Information {'published' if information.is_published else 'unpublished'} successfully", + "is_published": information.is_published + } diff --git a/backend/api/routers/notifications.py b/backend/api/routers/notifications.py new file mode 100644 index 0000000..9f41342 --- /dev/null +++ b/backend/api/routers/notifications.py @@ -0,0 +1,79 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List +from datetime import datetime +from config.database import get_db +from models.notification import Notification +from models.user import User +from schemas.notification import NotificationResponse +from utils.security import get_current_active_user + +router = APIRouter() + +@router.get("/", response_model=List[NotificationResponse]) +async def get_user_notifications( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), + limit: int = 50, + offset: int = 0 +): + """Get user notifications.""" + notifications = db.query(Notification).filter( + Notification.user_id == current_user.id + ).order_by(Notification.created_at.desc()).limit(limit).offset(offset).all() + + return notifications + +@router.put("/{notification_id}/read") +async def mark_notification_read( + notification_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Mark a notification as read.""" + notification = db.query(Notification).filter( + Notification.id == notification_id, + Notification.user_id == current_user.id + ).first() + + if not notification: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Notification not found" + ) + + notification.is_read = True + notification.read_at = datetime.utcnow() + db.commit() + + return {"message": "Notification marked as read"} + +@router.put("/read-all") +async def mark_all_notifications_read( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Mark all user notifications as read.""" + db.query(Notification).filter( + Notification.user_id == current_user.id, + Notification.is_read == False + ).update({ + "is_read": True, + "read_at": datetime.utcnow() + }) + db.commit() + + return {"message": "All notifications marked as read"} + +@router.get("/unread-count") +async def get_unread_count( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Get count of unread notifications.""" + count = db.query(Notification).filter( + Notification.user_id == current_user.id, + Notification.is_read == False + ).count() + + return {"unread_count": count} diff --git a/backend/api/routers/posts.py b/backend/api/routers/posts.py new file mode 100644 index 0000000..66ad9a5 --- /dev/null +++ b/backend/api/routers/posts.py @@ -0,0 +1,347 @@ +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File +from sqlalchemy.orm import Session +from typing import List +from datetime import datetime +import os +import uuid +from pathlib import Path +from PIL import Image +from config.database import get_db +from config.settings import settings +from models.post import Post, PostMention, PostLike, PostComment +from models.user import User +from models.notification import Notification, NotificationType +from utils.notification_service import NotificationService +from schemas.post import PostCreate, PostUpdate, PostResponse, PostCommentCreate +from utils.security import get_current_active_user +from utils.settings_service import SettingsService + +router = APIRouter() + +@router.post("/", response_model=PostResponse) +async def create_post( + post_data: PostCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Create a new post.""" + post = Post( + author_id=current_user.id, + content=post_data.content, + image_url=post_data.image_url + ) + db.add(post) + db.flush() # Get the post ID before creating mentions + + # Create mentions + for user_id in post_data.mentioned_user_ids: + mentioned_user = db.query(User).filter(User.id == user_id).first() + if mentioned_user: + mention = PostMention( + post_id=post.id, + mentioned_user_id=user_id + ) + db.add(mention) + + # Create notification for mentioned user + NotificationService.create_mention_notification( + db=db, + mentioned_user_id=user_id, + author=current_user, + content_type="post", + content_id=post.id + ) + + db.commit() + db.refresh(post) + return format_post_response(post, db) + +@router.get("/", response_model=List[PostResponse]) +async def get_posts( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), + limit: int = 50, + offset: int = 0 +): + """Get all posts.""" + posts = db.query(Post).order_by(Post.created_at.desc()).limit(limit).offset(offset).all() + return [format_post_response(post, db) for post in posts] + +@router.get("/{post_id}", response_model=PostResponse) +async def get_post( + post_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Get a specific post.""" + post = db.query(Post).filter(Post.id == post_id).first() + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Post not found" + ) + return format_post_response(post, db) + +@router.put("/{post_id}", response_model=PostResponse) +async def update_post( + post_id: int, + post_update: PostUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Update a post.""" + post = db.query(Post).filter(Post.id == post_id).first() + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Post not found" + ) + + if post.author_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to update this post" + ) + + for field, value in post_update.dict(exclude_unset=True).items(): + setattr(post, field, value) + + post.updated_at = datetime.utcnow() + db.commit() + db.refresh(post) + return format_post_response(post, db) + +@router.delete("/{post_id}") +async def delete_post( + post_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Delete a post.""" + post = db.query(Post).filter(Post.id == post_id).first() + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Post not found" + ) + + if post.author_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to delete this post" + ) + + db.delete(post) + db.commit() + return {"message": "Post deleted successfully"} + +@router.post("/upload-image") +async def upload_post_image( + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Upload an image for a post.""" + # Validate file type + if not SettingsService.is_file_type_allowed(db, file.content_type or "unknown"): + allowed_types = SettingsService.get_setting(db, "allowed_image_types", + ["image/jpeg", "image/png", "image/gif", "image/webp"]) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid file type. Allowed types: {', '.join(allowed_types)}" + ) + + # Check file size + contents = await file.read() + file_size = len(contents) + max_size = SettingsService.get_max_upload_size(db, file.content_type or "image/jpeg") + if file_size > max_size: + max_size_mb = max_size // (1024 * 1024) + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f"File too large. Maximum size: {max_size_mb}MB" + ) + + # Create posts directory + posts_dir = Path(settings.UPLOAD_PATH) / "posts" / str(current_user.id) + posts_dir.mkdir(parents=True, exist_ok=True) + + # Generate unique filename + file_extension = file.filename.split(".")[-1] + filename = f"{uuid.uuid4()}.{file_extension}" + file_path = posts_dir / filename + + # Save file + with open(file_path, "wb") as f: + f.write(contents) + + # Resize image if it's REALLY too large (4K support) + try: + img = Image.open(file_path) + if img.size[0] > 3840 or img.size[1] > 2160: # 4K threshold + img.thumbnail((3840, 2160), Image.Resampling.LANCZOS) + img.save(file_path, quality=95, optimize=True) # High quality + except Exception as e: + print(f"Error processing image: {e}") + + # Return the image URL + image_url = f"/posts/{current_user.id}/{filename}" + + return {"image_url": image_url} + +@router.post("/{post_id}/like") +async def toggle_post_like( + post_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Toggle like on a post.""" + post = db.query(Post).filter(Post.id == post_id).first() + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Post not found" + ) + + # Check if user already liked the post + existing_like = db.query(PostLike).filter( + PostLike.post_id == post_id, + PostLike.user_id == current_user.id + ).first() + + if existing_like: + # Unlike + db.delete(existing_like) + post.likes_count = max(0, post.likes_count - 1) + message = "Like removed" + is_liked = False + else: + # Like + like = PostLike(post_id=post_id, user_id=current_user.id) + db.add(like) + post.likes_count += 1 + message = "Post liked" + is_liked = True + + db.commit() + return {"message": message, "is_liked": is_liked, "likes_count": post.likes_count} + +@router.post("/{post_id}/comment") +async def add_post_comment( + post_id: int, + comment_data: PostCommentCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Add a comment to a post.""" + post = db.query(Post).filter(Post.id == post_id).first() + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Post not found" + ) + + comment = PostComment( + post_id=post_id, + author_id=current_user.id, + content=comment_data.content + ) + db.add(comment) + db.commit() + db.refresh(comment) + + return { + "message": "Comment added successfully", + "comment": { + "id": comment.id, + "content": comment.content, + "author_id": comment.author_id, + "author_name": current_user.full_name, + "author_avatar": current_user.avatar_url, + "created_at": comment.created_at + } + } + +@router.delete("/{post_id}/comment/{comment_id}") +async def delete_post_comment( + post_id: int, + comment_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Delete a comment from a post.""" + comment = db.query(PostComment).filter( + PostComment.id == comment_id, + PostComment.post_id == post_id + ).first() + + if not comment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Comment not found" + ) + + if comment.author_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to delete this comment" + ) + + db.delete(comment) + db.commit() + return {"message": "Comment deleted successfully"} + +def format_post_response(post: Post, db: Session) -> dict: + """Format post response with author and mentions information.""" + mentioned_users = [] + for mention in post.mentions: + user = mention.mentioned_user + mentioned_users.append({ + "id": user.id, + "username": user.username, + "full_name": user.full_name, + "avatar_url": user.avatar_url + }) + + # Get likes and comments + likes = [] + for like in post.likes: + user = db.query(User).filter(User.id == like.user_id).first() + if user: + likes.append({ + "id": like.id, + "user_id": user.id, + "username": user.username, + "full_name": user.full_name, + "avatar_url": user.avatar_url, + "created_at": like.created_at + }) + + comments = [] + for comment in post.comments: + user = db.query(User).filter(User.id == comment.author_id).first() + if user: + comments.append({ + "id": comment.id, + "content": comment.content, + "author_id": user.id, + "author_name": user.full_name, + "author_avatar": user.avatar_url, + "created_at": comment.created_at + }) + + return { + "id": post.id, + "author_id": post.author_id, + "content": post.content, + "image_url": post.image_url, + "likes_count": post.likes_count, + "comments_count": post.comments_count, + "created_at": post.created_at, + "updated_at": post.updated_at, + "author_name": post.author.full_name, + "author_avatar": post.author.avatar_url, + "mentioned_users": mentioned_users, + "likes": likes, + "comments": comments + } diff --git a/backend/api/routers/settings.py b/backend/api/routers/settings.py new file mode 100644 index 0000000..edfd405 --- /dev/null +++ b/backend/api/routers/settings.py @@ -0,0 +1,287 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List, Dict, Any +from datetime import datetime +from config.database import get_db +from models.settings import SystemSettings +from models.user import User +from schemas.settings import ( + SystemSettingCreate, + SystemSettingUpdate, + SystemSettingResponse, + SettingsCategoryResponse, + UploadLimitsResponse +) +from utils.security import get_admin_user + +router = APIRouter() + +@router.get("/", response_model=List[SystemSettingResponse]) +async def get_all_settings( + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """Get all system settings (admin only).""" + settings = db.query(SystemSettings).order_by(SystemSettings.category, SystemSettings.key).all() + return settings + +@router.get("/category/{category}", response_model=SettingsCategoryResponse) +async def get_settings_by_category( + category: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """Get settings by category (admin only).""" + settings = db.query(SystemSettings).filter(SystemSettings.category == category).all() + return SettingsCategoryResponse(category=category, settings=settings) + +@router.get("/upload-limits", response_model=UploadLimitsResponse) +async def get_upload_limits( + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """Get current upload limits configuration.""" + settings = db.query(SystemSettings).filter( + SystemSettings.category == "uploads" + ).all() + + # Convertir en dictionnaire pour faciliter l'accĂšs + settings_dict = {s.key: s.value for s in settings} + + # Debug: afficher les valeurs rĂ©cupĂ©rĂ©es + print(f"DEBUG - Upload limits from DB: {settings_dict}") + + return UploadLimitsResponse( + max_album_size_mb=int(settings_dict.get("max_album_size_mb", "100")), + max_vlog_size_mb=int(settings_dict.get("max_vlog_size_mb", "500")), + max_image_size_mb=int(settings_dict.get("max_image_size_mb", "10")), + max_video_size_mb=int(settings_dict.get("max_video_size_mb", "100")), + max_media_per_album=int(settings_dict.get("max_media_per_album", "50")), + allowed_image_types=settings_dict.get("allowed_image_types", "image/jpeg,image/png,image/gif,image/webp").split(","), + allowed_video_types=settings_dict.get("allowed_video_types", "video/mp4,video/mpeg,video/quicktime,video/webm").split(",") + ) + +@router.post("/", response_model=SystemSettingResponse) +async def create_setting( + setting_data: SystemSettingCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """Create a new system setting (admin only).""" + # VĂ©rifier si la clĂ© existe dĂ©jĂ  + existing = db.query(SystemSettings).filter(SystemSettings.key == setting_data.key).first() + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Setting key already exists" + ) + + setting = SystemSettings(**setting_data.dict()) + db.add(setting) + db.commit() + db.refresh(setting) + return setting + +@router.put("/{setting_key}", response_model=SystemSettingResponse) +async def update_setting( + setting_key: str, + setting_update: SystemSettingUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """Update a system setting (admin only).""" + setting = db.query(SystemSettings).filter(SystemSettings.key == setting_key).first() + if not setting: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Setting not found" + ) + + if not setting.is_editable: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This setting cannot be modified" + ) + + # Validation des valeurs selon le type de paramĂštre + if setting_key.startswith("max_") and setting_key.endswith("_mb"): + try: + size_mb = int(setting_update.value) + if size_mb <= 0 or size_mb > 10000: # Max 10GB + raise ValueError("Size must be between 1 and 10000 MB") + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid size value: {str(e)}" + ) + + setting.value = setting_update.value + setting.updated_at = datetime.utcnow() + db.commit() + db.refresh(setting) + return setting + +@router.delete("/{setting_key}") +async def delete_setting( + setting_key: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """Delete a system setting (admin only).""" + setting = db.query(SystemSettings).filter(SystemSettings.key == setting_key).first() + if not setting: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Setting not found" + ) + + if not setting.is_editable: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This setting cannot be deleted" + ) + + db.delete(setting) + db.commit() + return {"message": "Setting deleted successfully"} + +@router.get("/test-upload-limits") +async def test_upload_limits( + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """Test endpoint to verify upload limits are working correctly.""" + from utils.settings_service import SettingsService + + # Test image upload limit + image_limit = SettingsService.get_max_upload_size(db, "image/jpeg") + image_limit_mb = image_limit // (1024 * 1024) + + # Test video upload limit + video_limit = SettingsService.get_max_upload_size(db, "video/mp4") + video_limit_mb = video_limit // (1024 * 1024) + + # Test file type validation + image_allowed = SettingsService.is_file_type_allowed(db, "image/jpeg") + video_allowed = SettingsService.is_file_type_allowed(db, "video/mp4") + invalid_allowed = SettingsService.is_file_type_allowed(db, "application/pdf") + + return { + "image_upload_limit": { + "bytes": image_limit, + "mb": image_limit_mb, + "content_type": "image/jpeg" + }, + "video_upload_limit": { + "bytes": video_limit, + "mb": video_limit_mb, + "content_type": "video/mp4" + }, + "file_type_validation": { + "image_jpeg_allowed": image_allowed, + "video_mp4_allowed": video_allowed, + "pdf_not_allowed": not invalid_allowed + }, + "message": "Upload limits test completed" + } + +@router.get("/public/registration-status") +async def get_public_registration_status( + db: Session = Depends(get_db) +): + """Get registration status without authentication (public endpoint).""" + from utils.settings_service import SettingsService + + try: + enable_registration = SettingsService.get_setting(db, "enable_registration", True) + max_users = SettingsService.get_setting(db, "max_users", 50) + current_users_count = db.query(User).count() + + return { + "registration_enabled": enable_registration, + "max_users": max_users, + "current_users_count": current_users_count, + "can_register": enable_registration and current_users_count < max_users + } + except Exception as e: + # En cas d'erreur, on retourne des valeurs par dĂ©faut sĂ©curisĂ©es + return { + "registration_enabled": False, + "max_users": 50, + "current_users_count": 0, + "can_register": False + } + +@router.post("/initialize-defaults") +async def initialize_default_settings( + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """Initialize default system settings (admin only).""" + default_settings = [ + { + "key": "max_album_size_mb", + "value": "100", + "description": "Taille maximale des albums en MB", + "category": "uploads" + }, + { + "key": "max_vlog_size_mb", + "value": "500", + "description": "Taille maximale des vlogs en MB", + "category": "uploads" + }, + { + "key": "max_image_size_mb", + "value": "10", + "description": "Taille maximale des images en MB", + "category": "uploads" + }, + { + "key": "max_video_size_mb", + "value": "100", + "description": "Taille maximale des vidĂ©os en MB", + "category": "uploads" + }, + { + "key": "max_media_per_album", + "value": "50", + "description": "Nombre maximum de mĂ©dias par album", + "category": "uploads" + }, + { + "key": "allowed_image_types", + "value": "image/jpeg,image/png,image/gif,image/webp", + "description": "Types d'images autorisĂ©s (sĂ©parĂ©s par des virgules)", + "category": "uploads" + }, + { + "key": "allowed_video_types", + "value": "video/mp4,video/mpeg,video/quicktime,video/webm", + "description": "Types de vidĂ©os autorisĂ©s (sĂ©parĂ©s par des virgules)", + "category": "uploads" + }, + { + "key": "max_users", + "value": "50", + "description": "Nombre maximum d'utilisateurs", + "category": "general" + }, + { + "key": "enable_registration", + "value": "true", + "description": "Autoriser les nouvelles inscriptions", + "category": "general" + } + ] + + created_count = 0 + for setting_data in default_settings: + existing = db.query(SystemSettings).filter(SystemSettings.key == setting_data["key"]).first() + if not existing: + setting = SystemSettings(**setting_data) + db.add(setting) + created_count += 1 + + db.commit() + return {"message": f"{created_count} default settings created"} diff --git a/backend/api/routers/stats.py b/backend/api/routers/stats.py new file mode 100644 index 0000000..c39166b --- /dev/null +++ b/backend/api/routers/stats.py @@ -0,0 +1,314 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from sqlalchemy import func +from datetime import datetime, timedelta +from config.database import get_db +from models.user import User +from models.event import Event, EventParticipation, ParticipationStatus +from models.album import Album, Media +from models.post import Post +from models.vlog import Vlog +from utils.security import get_current_active_user + +router = APIRouter() + +@router.get("/overview") +async def get_overview_stats( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Get general overview statistics.""" + total_users = db.query(User).filter(User.is_active == True).count() + total_events = db.query(Event).count() + total_albums = db.query(Album).count() + total_posts = db.query(Post).count() + total_vlogs = db.query(Vlog).count() + total_media = db.query(Media).count() + + # Recent activity (last 30 days) + thirty_days_ago = datetime.utcnow() - timedelta(days=30) + recent_events = db.query(Event).filter(Event.created_at >= thirty_days_ago).count() + recent_posts = db.query(Post).filter(Post.created_at >= thirty_days_ago).count() + recent_vlogs = db.query(Vlog).filter(Vlog.created_at >= thirty_days_ago).count() + + return { + "total_users": total_users, + "total_events": total_events, + "total_albums": total_albums, + "total_posts": total_posts, + "total_vlogs": total_vlogs, + "total_media": total_media, + "recent_events": recent_events, + "recent_posts": recent_posts, + "recent_vlogs": recent_vlogs + } + +@router.get("/attendance") +async def get_attendance_stats( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Get attendance statistics for all users.""" + users = db.query(User).filter(User.is_active == True).all() + + attendance_stats = [] + for user in users: + # Get past events participation + past_participations = db.query(EventParticipation).join(Event).filter( + EventParticipation.user_id == user.id, + Event.date < datetime.utcnow() + ).all() + + total_past_events = len(past_participations) + present_count = sum(1 for p in past_participations if p.status == ParticipationStatus.PRESENT) + absent_count = sum(1 for p in past_participations if p.status == ParticipationStatus.ABSENT) + + # Get upcoming events participation + upcoming_participations = db.query(EventParticipation).join(Event).filter( + EventParticipation.user_id == user.id, + Event.date >= datetime.utcnow() + ).all() + + upcoming_present = sum(1 for p in upcoming_participations if p.status == ParticipationStatus.PRESENT) + upcoming_maybe = sum(1 for p in upcoming_participations if p.status == ParticipationStatus.MAYBE) + upcoming_pending = sum(1 for p in upcoming_participations if p.status == ParticipationStatus.PENDING) + + attendance_stats.append({ + "user_id": user.id, + "username": user.username, + "full_name": user.full_name, + "avatar_url": user.avatar_url, + "attendance_rate": user.attendance_rate, + "total_past_events": total_past_events, + "present_count": present_count, + "absent_count": absent_count, + "upcoming_present": upcoming_present, + "upcoming_maybe": upcoming_maybe, + "upcoming_pending": upcoming_pending + }) + + # Sort by attendance rate + attendance_stats.sort(key=lambda x: x["attendance_rate"], reverse=True) + + return { + "attendance_stats": attendance_stats, + "best_attendee": attendance_stats[0] if attendance_stats else None, + "worst_attendee": attendance_stats[-1] if attendance_stats else None + } + +@router.get("/fun") +async def get_fun_stats( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Get fun statistics about the group.""" + # Most active poster + post_counts = db.query( + User.id, + User.username, + User.full_name, + func.count(Post.id).label("post_count") + ).join(Post).group_by(User.id).order_by(func.count(Post.id).desc()).first() + + # Most mentioned user + mention_counts = db.query( + User.id, + User.username, + User.full_name, + func.count().label("mention_count") + ).join(Post.mentions).group_by(User.id).order_by(func.count().desc()).first() + + # Biggest vlogger + vlog_counts = db.query( + User.id, + User.username, + User.full_name, + func.count(Vlog.id).label("vlog_count") + ).join(Vlog).group_by(User.id).order_by(func.count(Vlog.id).desc()).first() + + # Photo addict (most albums created) + album_counts = db.query( + User.id, + User.username, + User.full_name, + func.count(Album.id).label("album_count") + ).join(Album).group_by(User.id).order_by(func.count(Album.id).desc()).first() + + # Event organizer (most events created) + event_counts = db.query( + User.id, + User.username, + User.full_name, + func.count(Event.id).label("event_count") + ).join(Event, Event.creator_id == User.id).group_by(User.id).order_by(func.count(Event.id).desc()).first() + + # Most viewed vlog + most_viewed_vlog = db.query(Vlog).order_by(Vlog.views_count.desc()).first() + + # Longest event streak (consecutive events attended) + # This would require more complex logic to calculate + + return { + "most_active_poster": { + "user_id": post_counts[0] if post_counts else None, + "username": post_counts[1] if post_counts else None, + "full_name": post_counts[2] if post_counts else None, + "post_count": post_counts[3] if post_counts else 0 + } if post_counts else None, + "most_mentioned": { + "user_id": mention_counts[0] if mention_counts else None, + "username": mention_counts[1] if mention_counts else None, + "full_name": mention_counts[2] if mention_counts else None, + "mention_count": mention_counts[3] if mention_counts else 0 + } if mention_counts else None, + "biggest_vlogger": { + "user_id": vlog_counts[0] if vlog_counts else None, + "username": vlog_counts[1] if vlog_counts else None, + "full_name": vlog_counts[2] if vlog_counts else None, + "vlog_count": vlog_counts[3] if vlog_counts else 0 + } if vlog_counts else None, + "photo_addict": { + "user_id": album_counts[0] if album_counts else None, + "username": album_counts[1] if album_counts else None, + "full_name": album_counts[2] if album_counts else None, + "album_count": album_counts[3] if album_counts else 0 + } if album_counts else None, + "event_organizer": { + "user_id": event_counts[0] if event_counts else None, + "username": event_counts[1] if event_counts else None, + "full_name": event_counts[2] if event_counts else None, + "event_count": event_counts[3] if event_counts else 0 + } if event_counts else None, + "most_viewed_vlog": { + "id": most_viewed_vlog.id, + "title": most_viewed_vlog.title, + "author_name": most_viewed_vlog.author.full_name, + "views_count": most_viewed_vlog.views_count + } if most_viewed_vlog else None + } + +@router.get("/user/{user_id}") +async def get_user_stats( + user_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Get statistics for a specific user.""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + return {"error": "User not found"} + + posts_count = db.query(Post).filter(Post.author_id == user_id).count() + vlogs_count = db.query(Vlog).filter(Vlog.author_id == user_id).count() + albums_count = db.query(Album).filter(Album.creator_id == user_id).count() + events_created = db.query(Event).filter(Event.creator_id == user_id).count() + + # Get participation stats + participations = db.query(EventParticipation).filter(EventParticipation.user_id == user_id).all() + present_count = sum(1 for p in participations if p.status == ParticipationStatus.PRESENT) + absent_count = sum(1 for p in participations if p.status == ParticipationStatus.ABSENT) + maybe_count = sum(1 for p in participations if p.status == ParticipationStatus.MAYBE) + + # Total views on vlogs + total_vlog_views = db.query(func.sum(Vlog.views_count)).filter(Vlog.author_id == user_id).scalar() or 0 + + return { + "user": { + "id": user.id, + "username": user.username, + "full_name": user.full_name, + "avatar_url": user.avatar_url, + "attendance_rate": user.attendance_rate, + "member_since": user.created_at + }, + "content_stats": { + "posts_count": posts_count, + "vlogs_count": vlogs_count, + "albums_count": albums_count, + "events_created": events_created, + "total_vlog_views": total_vlog_views + }, + "participation_stats": { + "total_events": len(participations), + "present_count": present_count, + "absent_count": absent_count, + "maybe_count": maybe_count + } + } + +@router.get("/activity/user/{user_id}") +async def get_user_activity( + user_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Get recent activity for a specific user.""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + return {"error": "User not found"} + + # Get recent posts + recent_posts = db.query(Post).filter( + Post.author_id == user_id + ).order_by(Post.created_at.desc()).limit(5).all() + + # Get recent vlogs + recent_vlogs = db.query(Vlog).filter( + Vlog.author_id == user_id + ).order_by(Vlog.created_at.desc()).limit(5).all() + + # Get recent albums + recent_albums = db.query(Album).filter( + Album.creator_id == user_id + ).order_by(Album.created_at.desc()).limit(5).all() + + # Get recent events created + recent_events = db.query(Event).filter( + Event.creator_id == user_id + ).order_by(Event.created_at.desc()).limit(5).all() + + # Combine and sort by date + activities = [] + + for post in recent_posts: + activities.append({ + "id": post.id, + "type": "post", + "description": f"A publiĂ© : {post.content[:50]}...", + "created_at": post.created_at, + "link": f"/posts/{post.id}" + }) + + for vlog in recent_vlogs: + activities.append({ + "id": vlog.id, + "type": "vlog", + "description": f"A publiĂ© un vlog : {vlog.title}", + "created_at": vlog.created_at, + "link": f"/vlogs/{vlog.id}" + }) + + for album in recent_albums: + activities.append({ + "id": album.id, + "type": "album", + "description": f"A créé un album : {album.title}", + "created_at": album.created_at, + "link": f"/albums/{album.id}" + }) + + for event in recent_events: + activities.append({ + "id": event.id, + "type": "event", + "description": f"A créé un Ă©vĂ©nement : {event.title}", + "created_at": event.created_at, + "link": f"/events/{event.id}" + }) + + # Sort by creation date (most recent first) + activities.sort(key=lambda x: x["created_at"], reverse=True) + + return { + "activity": activities[:10] # Return top 10 most recent activities + } diff --git a/backend/api/routers/tickets.py b/backend/api/routers/tickets.py new file mode 100644 index 0000000..f952f8f --- /dev/null +++ b/backend/api/routers/tickets.py @@ -0,0 +1,295 @@ +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form +from sqlalchemy.orm import Session +from typing import List, Optional +from datetime import datetime +import os +import uuid +from pathlib import Path +from PIL import Image +from config.database import get_db +from config.settings import settings +from models.ticket import Ticket, TicketType, TicketStatus, TicketPriority +from models.user import User +from schemas.ticket import TicketCreate, TicketUpdate, TicketResponse, TicketAdminUpdate +from utils.security import get_current_active_user, get_admin_user + +router = APIRouter() + +@router.get("/", response_model=List[TicketResponse]) +async def get_user_tickets( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Get current user's tickets.""" + tickets = db.query(Ticket).filter(Ticket.user_id == current_user.id).order_by(Ticket.created_at.desc()).all() + return [format_ticket_response(ticket, db) for ticket in tickets] + +@router.get("/admin", response_model=List[TicketResponse]) +async def get_all_tickets_admin( + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user), + status_filter: Optional[TicketStatus] = None, + type_filter: Optional[TicketType] = None, + priority_filter: Optional[TicketPriority] = None +): + """Get all tickets (admin only).""" + query = db.query(Ticket) + + if status_filter: + query = query.filter(Ticket.status == status_filter) + if type_filter: + query = query.filter(Ticket.ticket_type == type_filter) + if priority_filter: + query = query.filter(Ticket.priority == priority_filter) + + tickets = query.order_by(Ticket.created_at.desc()).all() + return [format_ticket_response(ticket, db) for ticket in tickets] + +@router.get("/{ticket_id}", response_model=TicketResponse) +async def get_ticket( + ticket_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Get a specific ticket.""" + ticket = db.query(Ticket).filter(Ticket.id == ticket_id).first() + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Ticket not found" + ) + + # Users can only see their own tickets, admins can see all + if ticket.user_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to view this ticket" + ) + + return format_ticket_response(ticket, db) + +@router.post("/", response_model=TicketResponse) +async def create_ticket( + title: str = Form(...), + description: str = Form(...), + ticket_type: str = Form("other"), # Changed from TicketType to str + priority: str = Form("medium"), # Changed from TicketPriority to str + screenshot: Optional[UploadFile] = File(None), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Create a new ticket.""" + print(f"DEBUG - Received ticket data: title='{title}', description='{description}', ticket_type='{ticket_type}', priority='{priority}'") + + # Validate and convert ticket_type + try: + ticket_type_enum = TicketType(ticket_type) + print(f"DEBUG - Ticket type converted successfully: {ticket_type_enum}") + except ValueError as e: + print(f"DEBUG - Error converting ticket_type '{ticket_type}': {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid ticket type: {ticket_type}. Valid types: {[t.value for t in TicketType]}" + ) + + # Validate and convert priority + try: + priority_enum = TicketPriority(priority) + print(f"DEBUG - Priority converted successfully: {priority_enum}") + except ValueError as e: + print(f"DEBUG - Error converting priority '{priority}': {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid priority: {priority}. Valid priorities: {[p.value for p in TicketPriority]}" + ) + + print("DEBUG - Starting ticket creation...") + + # Create tickets directory + tickets_dir = Path(settings.UPLOAD_PATH) / "tickets" / str(current_user.id) + tickets_dir.mkdir(parents=True, exist_ok=True) + print(f"DEBUG - Created tickets directory: {tickets_dir}") + + screenshot_path = None + if screenshot: + print(f"DEBUG - Processing screenshot: {screenshot.filename}, content_type: {screenshot.content_type}") + # Validate file type + if not screenshot.content_type.startswith('image/'): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Screenshot must be an image file" + ) + + # Generate unique filename + file_extension = screenshot.filename.split(".")[-1] + filename = f"ticket_{uuid.uuid4()}.{file_extension}" + file_path = tickets_dir / filename + + # Save and optimize image + contents = await screenshot.read() + with open(file_path, "wb") as f: + f.write(contents) + + # Optimize image + try: + img = Image.open(file_path) + if img.size[0] > 1920 or img.size[1] > 1080: + img.thumbnail((1920, 1080), Image.Resampling.LANCZOS) + img.save(file_path, quality=85, optimize=True) + except Exception as e: + print(f"Error optimizing screenshot: {e}") + + screenshot_path = f"/tickets/{current_user.id}/{filename}" + print(f"DEBUG - Screenshot saved: {screenshot_path}") + + print("DEBUG - Creating ticket object...") + ticket = Ticket( + title=title, + description=description, + ticket_type=ticket_type_enum, + priority=priority_enum, + user_id=current_user.id, + screenshot_path=screenshot_path + ) + + print("DEBUG - Adding ticket to database...") + db.add(ticket) + db.commit() + db.refresh(ticket) + print(f"DEBUG - Ticket created successfully with ID: {ticket.id}") + + return format_ticket_response(ticket, db) + +@router.put("/{ticket_id}", response_model=TicketResponse) +async def update_ticket( + ticket_id: int, + ticket_update: TicketUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Update a ticket.""" + ticket = db.query(Ticket).filter(Ticket.id == ticket_id).first() + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Ticket not found" + ) + + # Users can only update their own tickets, admins can update all + if ticket.user_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to update this ticket" + ) + + # Regular users can only update title, description, and type + if not current_user.is_admin: + update_data = ticket_update.dict(exclude_unset=True) + allowed_fields = ['title', 'description', 'ticket_type'] + update_data = {k: v for k, v in update_data.items() if k in allowed_fields} + else: + update_data = ticket_update.dict(exclude_unset=True) + + # Update resolved_at if status is resolved + if 'status' in update_data and update_data['status'] == TicketStatus.RESOLVED: + update_data['resolved_at'] = datetime.utcnow() + + for field, value in update_data.items(): + setattr(ticket, field, value) + + ticket.updated_at = datetime.utcnow() + db.commit() + db.refresh(ticket) + + return format_ticket_response(ticket, db) + +@router.put("/{ticket_id}/admin", response_model=TicketResponse) +async def update_ticket_admin( + ticket_id: int, + ticket_update: TicketAdminUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """Update a ticket as admin.""" + ticket = db.query(Ticket).filter(Ticket.id == ticket_id).first() + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Ticket not found" + ) + + update_data = ticket_update.dict(exclude_unset=True) + + # Update resolved_at if status is resolved + if 'status' in update_data and update_data['status'] == TicketStatus.RESOLVED: + update_data['resolved_at'] = datetime.utcnow() + + for field, value in update_data.items(): + setattr(ticket, field, value) + + ticket.updated_at = datetime.utcnow() + db.commit() + db.refresh(ticket) + + return format_ticket_response(ticket, db) + +@router.delete("/{ticket_id}") +async def delete_ticket( + ticket_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Delete a ticket.""" + ticket = db.query(Ticket).filter(Ticket.id == ticket_id).first() + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Ticket not found" + ) + + # Users can only delete their own tickets, admins can delete all + if ticket.user_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to delete this ticket" + ) + + # Delete screenshot if exists + if ticket.screenshot_path: + try: + screenshot_file = settings.UPLOAD_PATH + ticket.screenshot_path + if os.path.exists(screenshot_file): + os.remove(screenshot_file) + except Exception as e: + print(f"Error deleting screenshot: {e}") + + db.delete(ticket) + db.commit() + + return {"message": "Ticket deleted successfully"} + +def format_ticket_response(ticket: Ticket, db: Session) -> dict: + """Format ticket response with user information.""" + user = db.query(User).filter(User.id == ticket.user_id).first() + assigned_admin = None + if ticket.assigned_to: + assigned_admin = db.query(User).filter(User.id == ticket.assigned_to).first() + + return { + "id": ticket.id, + "title": ticket.title, + "description": ticket.description, + "ticket_type": ticket.ticket_type, + "status": ticket.status, + "priority": ticket.priority, + "user_id": ticket.user_id, + "assigned_to": ticket.assigned_to, + "screenshot_path": ticket.screenshot_path, + "admin_notes": ticket.admin_notes, + "created_at": ticket.created_at, + "updated_at": ticket.updated_at, + "resolved_at": ticket.resolved_at, + "user_name": user.full_name if user else "Unknown", + "user_email": user.email if user else "Unknown", + "assigned_admin_name": assigned_admin.full_name if assigned_admin else None + } diff --git a/backend/api/routers/users.py b/backend/api/routers/users.py new file mode 100644 index 0000000..af8db27 --- /dev/null +++ b/backend/api/routers/users.py @@ -0,0 +1,111 @@ +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File +from sqlalchemy.orm import Session +from typing import List +import os +import uuid +from PIL import Image +from config.database import get_db +from config.settings import settings +from models.user import User +from schemas.user import UserResponse, UserUpdate +from utils.security import get_current_active_user +from utils.settings_service import SettingsService +from pathlib import Path + +router = APIRouter() + +@router.get("/me", response_model=UserResponse) +async def get_current_user_info(current_user: User = Depends(get_current_active_user)): + """Get current user information.""" + return current_user + +@router.get("/", response_model=List[UserResponse]) +async def get_all_users( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Get all users.""" + users = db.query(User).filter(User.is_active == True).all() + return users + +@router.get("/{user_id}", response_model=UserResponse) +async def get_user( + user_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Get a specific user by ID.""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + return user + +@router.put("/me", response_model=UserResponse) +async def update_current_user( + user_update: UserUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Update current user information.""" + if user_update.full_name: + current_user.full_name = user_update.full_name + if user_update.bio is not None: + current_user.bio = user_update.bio + if user_update.avatar_url is not None: + current_user.avatar_url = user_update.avatar_url + + db.commit() + db.refresh(current_user) + return current_user + +@router.post("/me/avatar", response_model=UserResponse) +async def upload_avatar( + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Upload user avatar.""" + # Validate file type + if not SettingsService.is_file_type_allowed(db, file.content_type or "unknown"): + allowed_types = SettingsService.get_setting(db, "allowed_image_types", + ["image/jpeg", "image/png", "image/gif", "image/webp"]) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid file type. Allowed types: {', '.join(allowed_types)}" + ) + + # Create user directory + user_dir = Path(settings.UPLOAD_PATH) / "avatars" / str(current_user.id) + user_dir.mkdir(parents=True, exist_ok=True) + + # Generate unique filename + file_extension = file.filename.split(".")[-1] + filename = f"{uuid.uuid4()}.{file_extension}" + file_path = user_dir / filename + + # Save and resize image + contents = await file.read() + with open(file_path, "wb") as f: + f.write(contents) + + # Resize image to 800x800 with high quality + try: + img = Image.open(file_path) + img.thumbnail((800, 800)) + img.save(file_path, quality=95, optimize=True) + except Exception as e: + os.remove(file_path) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error processing image" + ) + + # Update user avatar URL + current_user.avatar_url = f"/avatars/{current_user.id}/{filename}" + db.commit() + db.refresh(current_user) + + return current_user diff --git a/backend/api/routers/vlogs.py b/backend/api/routers/vlogs.py new file mode 100644 index 0000000..0482f2b --- /dev/null +++ b/backend/api/routers/vlogs.py @@ -0,0 +1,374 @@ +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form +from sqlalchemy.orm import Session +from typing import List +import os +import uuid +from pathlib import Path +from config.database import get_db +from config.settings import settings +from models.vlog import Vlog, VlogLike, VlogComment +from models.user import User +from schemas.vlog import VlogCreate, VlogUpdate, VlogResponse, VlogCommentCreate +from utils.security import get_current_active_user +from utils.video_utils import generate_video_thumbnail, get_video_duration +from utils.settings_service import SettingsService + +router = APIRouter() + +@router.post("/", response_model=VlogResponse) +async def create_vlog( + vlog_data: VlogCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Create a new vlog.""" + vlog = Vlog( + author_id=current_user.id, + **vlog_data.dict() + ) + db.add(vlog) + db.commit() + db.refresh(vlog) + return format_vlog_response(vlog, db, current_user.id) + +@router.get("/", response_model=List[VlogResponse]) +async def get_vlogs( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), + limit: int = 20, + offset: int = 0 +): + """Get all vlogs.""" + vlogs = db.query(Vlog).order_by(Vlog.created_at.desc()).limit(limit).offset(offset).all() + return [format_vlog_response(vlog, db, current_user.id) for vlog in vlogs] + +@router.get("/{vlog_id}", response_model=VlogResponse) +async def get_vlog( + vlog_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Get a specific vlog.""" + vlog = db.query(Vlog).filter(Vlog.id == vlog_id).first() + if not vlog: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Vlog not found" + ) + + # Increment view count + vlog.views_count += 1 + db.commit() + + return format_vlog_response(vlog, db, current_user.id) + +@router.put("/{vlog_id}", response_model=VlogResponse) +async def update_vlog( + vlog_id: int, + vlog_update: VlogUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Update a vlog.""" + vlog = db.query(Vlog).filter(Vlog.id == vlog_id).first() + if not vlog: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Vlog not found" + ) + + if vlog.author_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to update this vlog" + ) + + for field, value in vlog_update.dict(exclude_unset=True).items(): + setattr(vlog, field, value) + + db.commit() + db.refresh(vlog) + return format_vlog_response(vlog, db, current_user.id) + +@router.delete("/{vlog_id}") +async def delete_vlog( + vlog_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Delete a vlog.""" + vlog = db.query(Vlog).filter(Vlog.id == vlog_id).first() + if not vlog: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Vlog not found" + ) + + if vlog.author_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to delete this vlog" + ) + + # Delete video files + try: + if vlog.video_url: + os.remove(settings.UPLOAD_PATH + vlog.video_url) + if vlog.thumbnail_url: + os.remove(settings.UPLOAD_PATH + vlog.thumbnail_url) + except: + pass + + db.delete(vlog) + db.commit() + return {"message": "Vlog deleted successfully"} + +@router.post("/{vlog_id}/like") +async def toggle_vlog_like( + vlog_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Toggle like on a vlog.""" + vlog = db.query(Vlog).filter(Vlog.id == vlog_id).first() + if not vlog: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Vlog not found" + ) + + existing_like = db.query(VlogLike).filter( + VlogLike.vlog_id == vlog_id, + VlogLike.user_id == current_user.id + ).first() + + if existing_like: + # Unlike + db.delete(existing_like) + vlog.likes_count = max(0, vlog.likes_count - 1) + message = "Like removed" + else: + # Like + like = VlogLike(vlog_id=vlog_id, user_id=current_user.id) + db.add(like) + vlog.likes_count += 1 + message = "Vlog liked" + + db.commit() + return {"message": message, "likes_count": vlog.likes_count} + +@router.post("/{vlog_id}/comment") +async def add_vlog_comment( + vlog_id: int, + comment_data: VlogCommentCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Add a comment to a vlog.""" + vlog = db.query(Vlog).filter(Vlog.id == vlog_id).first() + if not vlog: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Vlog not found" + ) + + comment = VlogComment( + vlog_id=vlog_id, + user_id=current_user.id, + content=comment_data.content + ) + db.add(comment) + db.commit() + db.refresh(comment) + + return { + "message": "Comment added successfully", + "comment": { + "id": comment.id, + "content": comment.content, + "user_id": comment.user_id, + "username": current_user.username, + "full_name": current_user.full_name, + "avatar_url": current_user.avatar_url, + "created_at": comment.created_at + } + } + +@router.delete("/{vlog_id}/comment/{comment_id}") +async def delete_vlog_comment( + vlog_id: int, + comment_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Delete a comment from a vlog.""" + comment = db.query(VlogComment).filter( + VlogComment.id == comment_id, + VlogComment.vlog_id == vlog_id + ).first() + + if not comment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Comment not found" + ) + + if comment.user_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to delete this comment" + ) + + db.delete(comment) + db.commit() + return {"message": "Comment deleted successfully"} + +@router.post("/upload") +async def upload_vlog_video( + title: str = Form(...), + description: str = Form(None), + video: UploadFile = File(...), + thumbnail: UploadFile = File(None), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Upload a vlog video.""" + # Validate video file + if not SettingsService.is_file_type_allowed(db, video.content_type or "unknown"): + allowed_types = SettingsService.get_setting(db, "allowed_video_types", + ["video/mp4", "video/mpeg", "video/quicktime", "video/webm"]) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid video type. Allowed types: {', '.join(allowed_types)}" + ) + + # Check file size + video_content = await video.read() + max_size = SettingsService.get_max_upload_size(db, video.content_type or "video/mp4") + if len(video_content) > max_size: + max_size_mb = max_size // (1024 * 1024) + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f"Video file too large. Maximum size: {max_size_mb}MB" + ) + + # Create vlog directory + vlog_dir = Path(settings.UPLOAD_PATH) / "vlogs" / str(current_user.id) + vlog_dir.mkdir(parents=True, exist_ok=True) + + # Save video + video_extension = video.filename.split(".")[-1] + video_filename = f"{uuid.uuid4()}.{video_extension}" + video_path = vlog_dir / video_filename + + with open(video_path, "wb") as f: + f.write(video_content) + + # Process thumbnail + thumbnail_url = None + if thumbnail: + if thumbnail.content_type not in settings.ALLOWED_IMAGE_TYPES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid thumbnail type" + ) + + thumbnail_content = await thumbnail.read() + thumbnail_extension = thumbnail.filename.split(".")[-1] + thumbnail_filename = f"thumb_{uuid.uuid4()}.{thumbnail_extension}" + thumbnail_path = vlog_dir / thumbnail_filename + + with open(thumbnail_path, "wb") as f: + f.write(thumbnail_content) + + thumbnail_url = f"/vlogs/{current_user.id}/{thumbnail_filename}" + else: + # Generate automatic thumbnail from video + try: + thumbnail_filename = f"auto_thumb_{uuid.uuid4()}.jpg" + thumbnail_path = vlog_dir / thumbnail_filename + + if generate_video_thumbnail(str(video_path), str(thumbnail_path)): + thumbnail_url = f"/vlogs/{current_user.id}/{thumbnail_filename}" + except Exception as e: + print(f"Error generating automatic thumbnail: {e}") + # Continue without thumbnail if generation fails + + # Get video duration + duration = None + try: + duration = int(get_video_duration(str(video_path))) + except Exception as e: + print(f"Error getting video duration: {e}") + + # Create vlog record + vlog = Vlog( + author_id=current_user.id, + title=title, + description=description, + video_url=f"/vlogs/{current_user.id}/{video_filename}", + thumbnail_url=thumbnail_url, + duration=duration + ) + db.add(vlog) + db.commit() + db.refresh(vlog) + + return format_vlog_response(vlog, db, current_user.id) + +def format_vlog_response(vlog: Vlog, db: Session, current_user_id: int) -> dict: + """Format vlog response with author information, likes, and comments.""" + # Check if current user liked this vlog + is_liked = db.query(VlogLike).filter( + VlogLike.vlog_id == vlog.id, + VlogLike.user_id == current_user_id + ).first() is not None + + # Format likes + likes = [] + for like in vlog.likes: + user = db.query(User).filter(User.id == like.user_id).first() + if user: + likes.append({ + "id": like.id, + "user_id": user.id, + "username": user.username, + "full_name": user.full_name, + "avatar_url": user.avatar_url, + "created_at": like.created_at + }) + + # Format comments + comments = [] + for comment in vlog.comments: + user = db.query(User).filter(User.id == comment.user_id).first() + if user: + comments.append({ + "id": comment.id, + "user_id": user.id, + "username": user.username, + "full_name": user.full_name, + "avatar_url": user.avatar_url, + "content": comment.content, + "created_at": comment.created_at, + "updated_at": comment.updated_at + }) + + return { + "id": vlog.id, + "author_id": vlog.author_id, + "title": vlog.title, + "description": vlog.description, + "video_url": vlog.video_url, + "thumbnail_url": vlog.thumbnail_url, + "duration": vlog.duration, + "views_count": vlog.views_count, + "likes_count": vlog.likes_count, + "created_at": vlog.created_at, + "updated_at": vlog.updated_at, + "author_name": vlog.author.full_name, + "author_avatar": vlog.author.avatar_url, + "is_liked": is_liked, + "likes": likes, + "comments": comments + } diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..2e0ebb1 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,230 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from contextlib import asynccontextmanager +import os +from pathlib import Path + +from config.settings import settings +from config.database import engine, Base +from api.routers import auth, users, events, albums, posts, vlogs, stats, admin, notifications, settings as settings_router, information, tickets +from utils.init_db import init_database +from utils.settings_service import SettingsService +from config.database import SessionLocal + +# Create uploads directory if it doesn't exist +Path(settings.UPLOAD_PATH).mkdir(parents=True, exist_ok=True) + +def init_default_settings(): + """Initialize default system settings.""" + db = SessionLocal() + try: + # Check if settings already exist + from models.settings import SystemSettings + existing_settings = db.query(SystemSettings).count() + + if existing_settings == 0: + print("Initializing default system settings...") + + default_settings = [ + { + "key": "max_album_size_mb", + "value": "100", + "description": "Taille maximale des albums en MB", + "category": "uploads" + }, + { + "key": "max_vlog_size_mb", + "value": "500", + "description": "Taille maximale des vlogs en MB", + "category": "uploads" + }, + { + "key": "max_image_size_mb", + "value": "10", + "description": "Taille maximale des images en MB", + "category": "uploads" + }, + { + "key": "max_video_size_mb", + "value": "100", + "description": "Taille maximale des vidĂ©os en MB", + "category": "uploads" + }, + { + "key": "max_media_per_album", + "value": "50", + "description": "Nombre maximum de mĂ©dias par album", + "category": "uploads" + }, + { + "key": "allowed_image_types", + "value": "image/jpeg,image/png,image/gif,image/webp", + "description": "Types d'images autorisĂ©s (sĂ©parĂ©s par des virgules)", + "category": "uploads" + }, + { + "key": "allowed_video_types", + "value": "video/mp4,video/mpeg,video/quicktime,video/webm", + "description": "Types de vidĂ©os autorisĂ©s (sĂ©parĂ©s par des virgules)", + "category": "uploads" + }, + { + "key": "max_users", + "value": "50", + "description": "Nombre maximum d'utilisateurs", + "category": "general" + }, + { + "key": "enable_registration", + "value": "true", + "description": "Autoriser les nouvelles inscriptions", + "category": "general" + } + ] + + for setting_data in default_settings: + setting = SystemSettings(**setting_data) + db.add(setting) + + db.commit() + print(f"Created {len(default_settings)} default settings") + else: + print("System settings already exist, checking for missing settings...") + + # Check for missing settings and add them + all_settings = [ + { + "key": "max_album_size_mb", + "value": "100", + "description": "Taille maximale des albums en MB", + "category": "uploads" + }, + { + "key": "max_vlog_size_mb", + "value": "500", + "description": "Taille maximale des vlogs en MB", + "category": "uploads" + }, + { + "key": "max_image_size_mb", + "value": "10", + "description": "Taille maximale des images en MB", + "category": "uploads" + }, + { + "key": "max_video_size_mb", + "value": "100", + "description": "Taille maximale des vidĂ©os en MB", + "category": "uploads" + }, + { + "key": "max_media_per_album", + "value": "50", + "description": "Nombre maximum de mĂ©dias par album", + "category": "uploads" + }, + { + "key": "allowed_image_types", + "value": "image/jpeg,image/png,image/gif,image/webp", + "description": "Types d'images autorisĂ©s (sĂ©parĂ©s par des virgules)", + "category": "uploads" + }, + { + "key": "allowed_video_types", + "value": "video/mp4,video/mpeg,video/quicktime,video/webm", + "description": "Types de vidĂ©os autorisĂ©s (sĂ©parĂ©s par des virgules)", + "category": "uploads" + }, + { + "key": "max_users", + "value": "50", + "description": "Nombre maximum d'utilisateurs", + "category": "general" + }, + { + "key": "enable_registration", + "value": "true", + "description": "Autoriser les nouvelles inscriptions", + "category": "general" + } + ] + + added_count = 0 + for setting_data in all_settings: + existing = db.query(SystemSettings).filter(SystemSettings.key == setting_data["key"]).first() + if not existing: + setting = SystemSettings(**setting_data) + db.add(setting) + added_count += 1 + + if added_count > 0: + db.commit() + print(f"Added {added_count} missing settings") + else: + print("All settings are already present") + + except Exception as e: + print(f"Error initializing settings: {e}") + db.rollback() + finally: + db.close() + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + print("Starting LeDiscord backend...") + # Create tables + Base.metadata.create_all(bind=engine) + # Initialize database with admin user + init_database() + # Initialize default settings + init_default_settings() + yield + # Shutdown + print("Shutting down LeDiscord backend...") + +app = FastAPI( + title="LeDiscord API", + description="API pour la plateforme communautaire LeDiscord", + version="1.0.0", + lifespan=lifespan +) + +# Configure CORS +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Mount static files for uploads +app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_PATH), name="uploads") + +# Include routers +app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"]) +app.include_router(users.router, prefix="/api/users", tags=["Users"]) +app.include_router(events.router, prefix="/api/events", tags=["Events"]) +app.include_router(albums.router, prefix="/api/albums", tags=["Albums"]) +app.include_router(posts.router, prefix="/api/posts", tags=["Posts"]) +app.include_router(vlogs.router, prefix="/api/vlogs", tags=["Vlogs"]) +app.include_router(stats.router, prefix="/api/stats", tags=["Statistics"]) +app.include_router(admin.router, prefix="/api/admin", tags=["Admin"]) +app.include_router(notifications.router, prefix="/api/notifications", tags=["Notifications"]) +app.include_router(settings_router.router, prefix="/api/settings", tags=["Settings"]) +app.include_router(information.router, prefix="/api/information", tags=["Information"]) +app.include_router(tickets.router, prefix="/api/tickets", tags=["Tickets"]) + +@app.get("/") +async def root(): + return { + "message": "Bienvenue sur LeDiscord API", + "version": "1.0.0", + "docs": "/docs" + } + +@app.get("/health") +async def health_check(): + return {"status": "healthy"} diff --git a/backend/config/database.py b/backend/config/database.py new file mode 100644 index 0000000..e19baa1 --- /dev/null +++ b/backend/config/database.py @@ -0,0 +1,16 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from .settings import settings + +engine = create_engine(settings.DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/config/settings.py b/backend/config/settings.py new file mode 100644 index 0000000..94705bd --- /dev/null +++ b/backend/config/settings.py @@ -0,0 +1,37 @@ +from typing import List +import os + +class Settings: + # Database + DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql://lediscord_user:lediscord_password@postgres:5432/lediscord") + + # JWT + JWT_SECRET_KEY: str = "your-super-secret-jwt-key-change-me" + JWT_ALGORITHM: str = "HS256" + JWT_EXPIRATION_MINUTES: int = 10080 # 7 days + + # Upload + UPLOAD_PATH: str = "/app/uploads" + MAX_UPLOAD_SIZE: int = 100 * 1024 * 1024 # 100MB + ALLOWED_IMAGE_TYPES: List[str] = ["image/jpeg", "image/png", "image/gif", "image/webp"] + ALLOWED_VIDEO_TYPES: List[str] = ["video/mp4", "video/mpeg", "video/quicktime", "video/webm"] + + # CORS - Fixed list, no environment parsing + CORS_ORIGINS: List[str] = ["http://localhost:5173", "http://localhost:3000"] + + # Email + SMTP_HOST: str = "smtp.gmail.com" + SMTP_PORT: int = 587 + SMTP_USER: str = "" + SMTP_PASSWORD: str = "" + SMTP_FROM: str = "noreply@lediscord.com" + + # Admin + ADMIN_EMAIL: str = "admin@lediscord.com" + ADMIN_PASSWORD: str = "admin123" + + # App + APP_NAME: str = "LeDiscord" + APP_URL: str = "http://localhost:5173" + +settings = Settings() diff --git a/backend/models/__init__.py b/backend/models/__init__.py new file mode 100644 index 0000000..47a6757 --- /dev/null +++ b/backend/models/__init__.py @@ -0,0 +1,30 @@ +from .user import User +from .event import Event, EventParticipation +from .album import Album, Media, MediaLike +from .post import Post, PostMention +from .vlog import Vlog, VlogLike, VlogComment +from .notification import Notification +from .settings import SystemSettings +from .information import Information +from .ticket import Ticket, TicketType, TicketStatus, TicketPriority + +__all__ = [ + "User", + "Event", + "EventParticipation", + "Album", + "Media", + "MediaLike", + "Post", + "PostMention", + "Vlog", + "VlogLike", + "VlogComment", + "Notification", + "SystemSettings", + "Information", + "Ticket", + "TicketType", + "TicketStatus", + "TicketPriority" +] diff --git a/backend/models/album.py b/backend/models/album.py new file mode 100644 index 0000000..7f39276 --- /dev/null +++ b/backend/models/album.py @@ -0,0 +1,58 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Enum as SQLEnum +from sqlalchemy.orm import relationship +from datetime import datetime +import enum +from config.database import Base + +class MediaType(enum.Enum): + IMAGE = "image" + VIDEO = "video" + +class Album(Base): + __tablename__ = "albums" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String, nullable=False) + description = Column(Text) + creator_id = Column(Integer, ForeignKey("users.id"), nullable=False) + event_id = Column(Integer, ForeignKey("events.id"), nullable=True) + cover_image = Column(String) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + creator = relationship("User", back_populates="albums") + event = relationship("Event", back_populates="albums") + media = relationship("Media", back_populates="album", cascade="all, delete-orphan") + +class Media(Base): + __tablename__ = "media" + + id = Column(Integer, primary_key=True, index=True) + album_id = Column(Integer, ForeignKey("albums.id"), nullable=False) + file_path = Column(String, nullable=False) + thumbnail_path = Column(String) + media_type = Column(SQLEnum(MediaType), nullable=False) + caption = Column(Text) + file_size = Column(Integer) # in bytes + width = Column(Integer) + height = Column(Integer) + duration = Column(Integer) # in seconds for videos + likes_count = Column(Integer, default=0) + created_at = Column(DateTime, default=datetime.utcnow) + + # Relationships + album = relationship("Album", back_populates="media") + likes = relationship("MediaLike", back_populates="media", cascade="all, delete-orphan") + +class MediaLike(Base): + __tablename__ = "media_likes" + + id = Column(Integer, primary_key=True, index=True) + media_id = Column(Integer, ForeignKey("media.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + + # Relationships + media = relationship("Media", back_populates="likes") + user = relationship("User", back_populates="media_likes") diff --git a/backend/models/event.py b/backend/models/event.py new file mode 100644 index 0000000..b9a436c --- /dev/null +++ b/backend/models/event.py @@ -0,0 +1,46 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Enum as SQLEnum, Float +from sqlalchemy.orm import relationship +from datetime import datetime +import enum +from config.database import Base + +class ParticipationStatus(enum.Enum): + PRESENT = "present" + ABSENT = "absent" + MAYBE = "maybe" + PENDING = "pending" + +class Event(Base): + __tablename__ = "events" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String, nullable=False) + description = Column(Text) + location = Column(String) + latitude = Column(Float, nullable=True) # CoordonnĂ©e latitude + longitude = Column(Float, nullable=True) # CoordonnĂ©e longitude + date = Column(DateTime, nullable=False) + end_date = Column(DateTime) + creator_id = Column(Integer, ForeignKey("users.id"), nullable=False) + cover_image = Column(String) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + creator = relationship("User", back_populates="created_events") + participations = relationship("EventParticipation", back_populates="event", cascade="all, delete-orphan") + albums = relationship("Album", back_populates="event") + +class EventParticipation(Base): + __tablename__ = "event_participations" + + id = Column(Integer, primary_key=True, index=True) + event_id = Column(Integer, ForeignKey("events.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + status = Column(SQLEnum(ParticipationStatus), default=ParticipationStatus.PENDING) + response_date = Column(DateTime, default=datetime.utcnow) + created_at = Column(DateTime, default=datetime.utcnow) + + # Relationships + event = relationship("Event", back_populates="participations") + user = relationship("User", back_populates="event_participations") diff --git a/backend/models/information.py b/backend/models/information.py new file mode 100644 index 0000000..1f41904 --- /dev/null +++ b/backend/models/information.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, Integer, String, DateTime, Text, Boolean +from datetime import datetime +from config.database import Base + +class Information(Base): + __tablename__ = "informations" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String, nullable=False) + content = Column(Text, nullable=False) + category = Column(String, default="general") # general, release, upcoming, etc. + is_published = Column(Boolean, default=True) + priority = Column(Integer, default=0) # Pour l'ordre d'affichage + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/backend/models/notification.py b/backend/models/notification.py new file mode 100644 index 0000000..8cc8bdf --- /dev/null +++ b/backend/models/notification.py @@ -0,0 +1,29 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean, Text, Enum as SQLEnum +from sqlalchemy.orm import relationship +from datetime import datetime +import enum +from config.database import Base + +class NotificationType(enum.Enum): + EVENT_INVITATION = "event_invitation" + EVENT_REMINDER = "event_reminder" + POST_MENTION = "post_mention" + NEW_ALBUM = "new_album" + NEW_VLOG = "new_vlog" + SYSTEM = "system" + +class Notification(Base): + __tablename__ = "notifications" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + type = Column(SQLEnum(NotificationType), nullable=False) + title = Column(String, nullable=False) + message = Column(Text, nullable=False) + link = Column(String) # Link to the related content + is_read = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.utcnow) + read_at = Column(DateTime, nullable=True) + + # Relationships + user = relationship("User", back_populates="notifications") diff --git a/backend/models/post.py b/backend/models/post.py new file mode 100644 index 0000000..6eb1383 --- /dev/null +++ b/backend/models/post.py @@ -0,0 +1,60 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text +from sqlalchemy.orm import relationship +from datetime import datetime +from config.database import Base + +class Post(Base): + __tablename__ = "posts" + + id = Column(Integer, primary_key=True, index=True) + author_id = Column(Integer, ForeignKey("users.id"), nullable=False) + content = Column(Text, nullable=False) + image_url = Column(String) + likes_count = Column(Integer, default=0) + comments_count = Column(Integer, default=0) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + author = relationship("User", back_populates="posts") + mentions = relationship("PostMention", back_populates="post", cascade="all, delete-orphan") + likes = relationship("PostLike", back_populates="post", cascade="all, delete-orphan") + comments = relationship("PostComment", back_populates="post", cascade="all, delete-orphan") + +class PostMention(Base): + __tablename__ = "post_mentions" + + id = Column(Integer, primary_key=True, index=True) + post_id = Column(Integer, ForeignKey("posts.id"), nullable=False) + mentioned_user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + + # Relationships + post = relationship("Post", back_populates="mentions") + mentioned_user = relationship("User", back_populates="mentions") + +class PostLike(Base): + __tablename__ = "post_likes" + + id = Column(Integer, primary_key=True, index=True) + post_id = Column(Integer, ForeignKey("posts.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + + # Relationships + post = relationship("Post", back_populates="likes") + user = relationship("User", back_populates="post_likes") + +class PostComment(Base): + __tablename__ = "post_comments" + + id = Column(Integer, primary_key=True, index=True) + post_id = Column(Integer, ForeignKey("posts.id"), nullable=False) + author_id = Column(Integer, ForeignKey("users.id"), nullable=False) + content = Column(Text, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + post = relationship("Post", back_populates="comments") + author = relationship("User", back_populates="post_comments") diff --git a/backend/models/settings.py b/backend/models/settings.py new file mode 100644 index 0000000..9954c83 --- /dev/null +++ b/backend/models/settings.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime +from datetime import datetime +from config.database import Base + +class SystemSettings(Base): + __tablename__ = "system_settings" + + id = Column(Integer, primary_key=True, index=True) + key = Column(String, unique=True, nullable=False, index=True) + value = Column(String, nullable=False) + description = Column(String) + category = Column(String, default="general") # general, uploads, notifications, etc. + is_editable = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/backend/models/ticket.py b/backend/models/ticket.py new file mode 100644 index 0000000..9f67864 --- /dev/null +++ b/backend/models/ticket.py @@ -0,0 +1,45 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Enum as SQLEnum +from sqlalchemy.orm import relationship +from datetime import datetime +import enum +from config.database import Base + +class TicketType(enum.Enum): + BUG = "bug" + FEATURE_REQUEST = "feature_request" + IMPROVEMENT = "improvement" + SUPPORT = "support" + OTHER = "other" + +class TicketStatus(enum.Enum): + OPEN = "open" + IN_PROGRESS = "in_progress" + RESOLVED = "resolved" + CLOSED = "closed" + +class TicketPriority(enum.Enum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + URGENT = "urgent" + +class Ticket(Base): + __tablename__ = "tickets" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String, nullable=False) + description = Column(Text, nullable=False) + ticket_type = Column(SQLEnum(TicketType), nullable=False, default=TicketType.OTHER) + status = Column(SQLEnum(TicketStatus), nullable=False, default=TicketStatus.OPEN) + priority = Column(SQLEnum(TicketPriority), nullable=False, default=TicketPriority.MEDIUM) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + assigned_to = Column(Integer, ForeignKey("users.id"), nullable=True) + screenshot_path = Column(String, nullable=True) + admin_notes = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + resolved_at = Column(DateTime, nullable=True) + + # Relationships + user = relationship("User", foreign_keys=[user_id], back_populates="tickets") + assigned_admin = relationship("User", foreign_keys=[assigned_to]) diff --git a/backend/models/user.py b/backend/models/user.py new file mode 100644 index 0000000..19c34cf --- /dev/null +++ b/backend/models/user.py @@ -0,0 +1,35 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Float +from sqlalchemy.orm import relationship +from datetime import datetime +from config.database import Base + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String, unique=True, index=True, nullable=False) + username = Column(String, unique=True, index=True, nullable=False) + full_name = Column(String, nullable=False) + hashed_password = Column(String, nullable=False) + is_active = Column(Boolean, default=True) + is_admin = Column(Boolean, default=False) + avatar_url = Column(String, nullable=True) + bio = Column(String, nullable=True) + attendance_rate = Column(Float, default=0.0) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + event_participations = relationship("EventParticipation", back_populates="user", cascade="all, delete-orphan") + created_events = relationship("Event", back_populates="creator", cascade="all, delete-orphan") + albums = relationship("Album", back_populates="creator", cascade="all, delete-orphan") + posts = relationship("Post", back_populates="author", cascade="all, delete-orphan") + mentions = relationship("PostMention", back_populates="mentioned_user", cascade="all, delete-orphan") + vlogs = relationship("Vlog", back_populates="author", cascade="all, delete-orphan") + notifications = relationship("Notification", back_populates="user", cascade="all, delete-orphan") + vlog_likes = relationship("VlogLike", back_populates="user", cascade="all, delete-orphan") + vlog_comments = relationship("VlogComment", back_populates="user", cascade="all, delete-orphan") + media_likes = relationship("MediaLike", back_populates="user", cascade="all, delete-orphan") + post_likes = relationship("PostLike", back_populates="user", cascade="all, delete-orphan") + post_comments = relationship("PostComment", back_populates="author", cascade="all, delete-orphan") + tickets = relationship("Ticket", foreign_keys="[Ticket.user_id]", back_populates="user") diff --git a/backend/models/vlog.py b/backend/models/vlog.py new file mode 100644 index 0000000..f17b4de --- /dev/null +++ b/backend/models/vlog.py @@ -0,0 +1,50 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text +from sqlalchemy.orm import relationship +from datetime import datetime +from config.database import Base + +class Vlog(Base): + __tablename__ = "vlogs" + + id = Column(Integer, primary_key=True, index=True) + author_id = Column(Integer, ForeignKey("users.id"), nullable=False) + title = Column(String, nullable=False) + description = Column(Text) + video_url = Column(String, nullable=False) + thumbnail_url = Column(String) + duration = Column(Integer) # in seconds + views_count = Column(Integer, default=0) + likes_count = Column(Integer, default=0) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + author = relationship("User", back_populates="vlogs") + likes = relationship("VlogLike", back_populates="vlog", cascade="all, delete-orphan") + comments = relationship("VlogComment", back_populates="vlog", cascade="all, delete-orphan") + +class VlogLike(Base): + __tablename__ = "vlog_likes" + + id = Column(Integer, primary_key=True, index=True) + vlog_id = Column(Integer, ForeignKey("vlogs.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + + # Relationships + vlog = relationship("Vlog", back_populates="likes") + user = relationship("User", back_populates="vlog_likes") + +class VlogComment(Base): + __tablename__ = "vlog_comments" + + id = Column(Integer, primary_key=True, index=True) + vlog_id = Column(Integer, ForeignKey("vlogs.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + content = Column(Text, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + vlog = relationship("Vlog", back_populates="comments") + user = relationship("User", back_populates="vlog_comments") diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..2cd0b41 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,21 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +sqlalchemy==2.0.23 +alembic==1.12.1 +psycopg2-binary==2.9.9 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.6 +python-dotenv==1.0.0 +pydantic==2.5.0 +pydantic[email]==2.5.0 +pydantic-settings==2.1.0 +aiofiles==23.2.1 +pillow==10.1.0 +httpx==0.25.2 +redis==5.0.1 +celery==5.3.4 +flower==2.0.1 +python-magic==0.4.27 +numpy==1.26.4 +opencv-python==4.8.1.78 diff --git a/backend/schemas/__init__.py b/backend/schemas/__init__.py new file mode 100644 index 0000000..b064cec --- /dev/null +++ b/backend/schemas/__init__.py @@ -0,0 +1,21 @@ +from .user import UserCreate, UserUpdate, UserResponse, UserLogin, Token +from .event import EventCreate, EventUpdate, EventResponse, ParticipationUpdate +from .album import AlbumCreate, AlbumUpdate, AlbumResponse, MediaResponse, MediaLikeResponse +from .post import PostCreate, PostUpdate, PostResponse +from .vlog import VlogCreate, VlogUpdate, VlogResponse, VlogCommentCreate, VlogLikeResponse, VlogCommentResponse +from .notification import NotificationResponse +from .settings import SystemSettingCreate, SystemSettingUpdate, SystemSettingResponse, SettingsCategoryResponse, UploadLimitsResponse +from .information import InformationCreate, InformationUpdate, InformationResponse +from .ticket import TicketCreate, TicketUpdate, TicketResponse, TicketAdminUpdate + +__all__ = [ + "UserCreate", "UserUpdate", "UserResponse", "UserLogin", "Token", + "EventCreate", "EventUpdate", "EventResponse", "ParticipationUpdate", + "AlbumCreate", "AlbumUpdate", "AlbumResponse", "MediaResponse", "MediaLikeResponse", + "PostCreate", "PostUpdate", "PostResponse", + "VlogCreate", "VlogUpdate", "VlogResponse", "VlogCommentCreate", "VlogLikeResponse", "VlogCommentResponse", + "NotificationResponse", + "SystemSettingCreate", "SystemSettingUpdate", "SystemSettingResponse", "SettingsCategoryResponse", "UploadLimitsResponse", + "InformationCreate", "InformationUpdate", "InformationResponse", + "TicketCreate", "TicketUpdate", "TicketResponse", "TicketAdminUpdate" +] diff --git a/backend/schemas/album.py b/backend/schemas/album.py new file mode 100644 index 0000000..5f4399b --- /dev/null +++ b/backend/schemas/album.py @@ -0,0 +1,61 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime +from models.album import MediaType + +class AlbumBase(BaseModel): + title: str = Field(..., min_length=1, max_length=200) + description: Optional[str] = None + event_id: Optional[int] = None + +class AlbumCreate(AlbumBase): + cover_image: Optional[str] = None + +class AlbumUpdate(BaseModel): + title: Optional[str] = Field(None, min_length=1, max_length=200) + description: Optional[str] = None + event_id: Optional[int] = None + cover_image: Optional[str] = None + +class MediaLikeResponse(BaseModel): + id: int + user_id: int + username: str + full_name: str + avatar_url: Optional[str] + created_at: datetime + + class Config: + from_attributes = True + +class MediaResponse(BaseModel): + id: int + file_path: str + thumbnail_path: Optional[str] + media_type: MediaType + caption: Optional[str] + file_size: int + width: Optional[int] + height: Optional[int] + duration: Optional[int] + likes_count: int + is_liked: Optional[bool] = None + likes: List[MediaLikeResponse] = [] + created_at: datetime + + class Config: + from_attributes = True + +class AlbumResponse(AlbumBase): + id: int + creator_id: int + creator_name: str + cover_image: Optional[str] + created_at: datetime + media_count: int = 0 + media: List[MediaResponse] = [] + event_title: Optional[str] = None + top_media: List[MediaResponse] = [] + + class Config: + from_attributes = True diff --git a/backend/schemas/event.py b/backend/schemas/event.py new file mode 100644 index 0000000..4461228 --- /dev/null +++ b/backend/schemas/event.py @@ -0,0 +1,55 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime +from models.event import ParticipationStatus + +class EventBase(BaseModel): + title: str = Field(..., min_length=1, max_length=200) + description: Optional[str] = None + location: Optional[str] = None + latitude: Optional[float] = None + longitude: Optional[float] = None + date: datetime + end_date: Optional[datetime] = None + +class EventCreate(EventBase): + cover_image: Optional[str] = None + +class EventUpdate(BaseModel): + title: Optional[str] = Field(None, min_length=1, max_length=200) + description: Optional[str] = None + location: Optional[str] = None + latitude: Optional[float] = None + longitude: Optional[float] = None + date: Optional[datetime] = None + end_date: Optional[datetime] = None + cover_image: Optional[str] = None + +class ParticipationResponse(BaseModel): + user_id: int + username: str + full_name: str + avatar_url: Optional[str] + status: ParticipationStatus + response_date: datetime + + class Config: + from_attributes = True + +class EventResponse(EventBase): + id: int + creator_id: int + creator_name: str + cover_image: Optional[str] + created_at: datetime + participations: List[ParticipationResponse] = [] + present_count: int = 0 + absent_count: int = 0 + maybe_count: int = 0 + pending_count: int = 0 + + class Config: + from_attributes = True + +class ParticipationUpdate(BaseModel): + status: ParticipationStatus diff --git a/backend/schemas/information.py b/backend/schemas/information.py new file mode 100644 index 0000000..57df811 --- /dev/null +++ b/backend/schemas/information.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel, Field +from typing import Optional +from datetime import datetime + +class InformationBase(BaseModel): + title: str = Field(..., min_length=1, max_length=200) + content: str = Field(..., min_length=1) + category: str = Field(default="general") + is_published: bool = Field(default=True) + priority: int = Field(default=0, ge=0) + +class InformationCreate(InformationBase): + pass + +class InformationUpdate(BaseModel): + title: Optional[str] = Field(None, min_length=1, max_length=200) + content: Optional[str] = Field(None, min_length=1) + category: Optional[str] = None + is_published: Optional[bool] = None + priority: Optional[int] = Field(None, ge=0) + +class InformationResponse(InformationBase): + id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True diff --git a/backend/schemas/notification.py b/backend/schemas/notification.py new file mode 100644 index 0000000..ab8a17f --- /dev/null +++ b/backend/schemas/notification.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime +from models.notification import NotificationType + +class NotificationResponse(BaseModel): + id: int + type: NotificationType + title: str + message: str + link: Optional[str] + is_read: bool + created_at: datetime + read_at: Optional[datetime] + + class Config: + from_attributes = True diff --git a/backend/schemas/post.py b/backend/schemas/post.py new file mode 100644 index 0000000..717db07 --- /dev/null +++ b/backend/schemas/post.py @@ -0,0 +1,54 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime + +class PostBase(BaseModel): + content: str = Field(..., min_length=1, max_length=5000) + +class PostCreate(PostBase): + image_url: Optional[str] = None + mentioned_user_ids: List[int] = [] + +class PostUpdate(BaseModel): + content: Optional[str] = Field(None, min_length=1, max_length=5000) + image_url: Optional[str] = None + +class PostCommentCreate(BaseModel): + content: str = Field(..., min_length=1, max_length=500) + +class MentionedUser(BaseModel): + id: int + username: str + full_name: str + avatar_url: Optional[str] + + class Config: + from_attributes = True + +class PostCommentResponse(BaseModel): + id: int + content: str + author_id: int + author_name: str + author_avatar: Optional[str] + created_at: datetime + + class Config: + from_attributes = True + +class PostResponse(PostBase): + id: int + author_id: int + author_name: str + author_avatar: Optional[str] + image_url: Optional[str] + likes_count: int = 0 + comments_count: int = 0 + is_liked: Optional[bool] = None + created_at: datetime + updated_at: datetime + mentioned_users: List[MentionedUser] = [] + comments: List[PostCommentResponse] = [] + + class Config: + from_attributes = True diff --git a/backend/schemas/settings.py b/backend/schemas/settings.py new file mode 100644 index 0000000..ccaac05 --- /dev/null +++ b/backend/schemas/settings.py @@ -0,0 +1,40 @@ +from pydantic import BaseModel, Field +from typing import Optional, Dict, Any +from datetime import datetime + +class SystemSettingBase(BaseModel): + key: str = Field(..., description="ClĂ© unique du paramĂštre") + value: str = Field(..., description="Valeur du paramĂštre") + description: Optional[str] = Field(None, description="Description du paramĂštre") + category: str = Field(default="general", description="CatĂ©gorie du paramĂštre") + +class SystemSettingCreate(SystemSettingBase): + pass + +class SystemSettingUpdate(BaseModel): + value: str = Field(..., description="Nouvelle valeur du paramĂštre") + +class SystemSettingResponse(SystemSettingBase): + id: int + is_editable: bool + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + +class SettingsCategoryResponse(BaseModel): + category: str + settings: list[SystemSettingResponse] + + class Config: + from_attributes = True + +class UploadLimitsResponse(BaseModel): + max_album_size_mb: int + max_vlog_size_mb: int + max_image_size_mb: int + max_video_size_mb: int + max_media_per_album: int + allowed_image_types: list[str] + allowed_video_types: list[str] diff --git a/backend/schemas/ticket.py b/backend/schemas/ticket.py new file mode 100644 index 0000000..205b182 --- /dev/null +++ b/backend/schemas/ticket.py @@ -0,0 +1,47 @@ +from pydantic import BaseModel, Field +from typing import Optional +from datetime import datetime +from models.ticket import TicketType, TicketStatus, TicketPriority + +class TicketBase(BaseModel): + title: str = Field(..., min_length=1, max_length=200) + description: str = Field(..., min_length=1) + ticket_type: TicketType = Field(default=TicketType.OTHER) + priority: TicketPriority = Field(default=TicketPriority.MEDIUM) + +class TicketCreate(TicketBase): + pass + +class TicketUpdate(BaseModel): + title: Optional[str] = Field(None, min_length=1, max_length=200) + description: Optional[str] = Field(None, min_length=1) + ticket_type: Optional[TicketType] = None + status: Optional[TicketStatus] = None + priority: Optional[TicketPriority] = None + assigned_to: Optional[int] = None + admin_notes: Optional[str] = None + +class TicketResponse(TicketBase): + id: int + status: TicketStatus + user_id: int + assigned_to: Optional[int] + screenshot_path: Optional[str] + admin_notes: Optional[str] + created_at: datetime + updated_at: datetime + resolved_at: Optional[datetime] + + # User information + user_name: str + user_email: str + assigned_admin_name: Optional[str] = None + + class Config: + from_attributes = True + +class TicketAdminUpdate(BaseModel): + status: Optional[TicketStatus] = None + priority: Optional[TicketPriority] = None + assigned_to: Optional[int] = None + admin_notes: Optional[str] = None diff --git a/backend/schemas/user.py b/backend/schemas/user.py new file mode 100644 index 0000000..0fed88b --- /dev/null +++ b/backend/schemas/user.py @@ -0,0 +1,41 @@ +from pydantic import BaseModel, EmailStr, Field +from typing import Optional +from datetime import datetime + +class UserBase(BaseModel): + email: EmailStr + username: str = Field(..., min_length=3, max_length=50) + full_name: str = Field(..., min_length=1, max_length=100) + +class UserCreate(UserBase): + password: str = Field(..., min_length=6) + +class UserUpdate(BaseModel): + full_name: Optional[str] = Field(None, min_length=1, max_length=100) + bio: Optional[str] = Field(None, max_length=500) + avatar_url: Optional[str] = None + +class UserResponse(UserBase): + id: int + is_active: bool + is_admin: bool + avatar_url: Optional[str] + bio: Optional[str] + attendance_rate: float + created_at: datetime + + class Config: + from_attributes = True + +class UserLogin(BaseModel): + email: EmailStr + password: str + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + user: UserResponse + +class TokenData(BaseModel): + user_id: Optional[int] = None + email: Optional[str] = None diff --git a/backend/schemas/vlog.py b/backend/schemas/vlog.py new file mode 100644 index 0000000..3d4bd7c --- /dev/null +++ b/backend/schemas/vlog.py @@ -0,0 +1,63 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime + +class VlogBase(BaseModel): + title: str = Field(..., min_length=1, max_length=200) + description: Optional[str] = None + +class VlogCreate(VlogBase): + video_url: str + thumbnail_url: Optional[str] = None + duration: Optional[int] = None + +class VlogUpdate(BaseModel): + title: Optional[str] = Field(None, min_length=1, max_length=200) + description: Optional[str] = None + thumbnail_url: Optional[str] = None + +class VlogLikeResponse(BaseModel): + id: int + user_id: int + username: str + full_name: str + avatar_url: Optional[str] + created_at: datetime + + class Config: + from_attributes = True + +class VlogCommentResponse(BaseModel): + id: int + user_id: int + username: str + full_name: str + avatar_url: Optional[str] + content: str + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + +class VlogResponse(VlogBase): + id: int + author_id: int + author_name: str + author_avatar: Optional[str] + video_url: str + thumbnail_url: Optional[str] + duration: Optional[int] + views_count: int + likes_count: int + created_at: datetime + updated_at: datetime + is_liked: Optional[bool] = None + likes: List[VlogLikeResponse] = [] + comments: List[VlogCommentResponse] = [] + + class Config: + from_attributes = True + +class VlogCommentCreate(BaseModel): + content: str = Field(..., min_length=1, max_length=1000) diff --git a/backend/utils/email.py b/backend/utils/email.py new file mode 100644 index 0000000..d0b89e8 --- /dev/null +++ b/backend/utils/email.py @@ -0,0 +1,72 @@ +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from config.settings import settings + +def send_email(to_email: str, subject: str, body: str, html_body: str = None): + """Send an email notification.""" + if not settings.SMTP_USER or not settings.SMTP_PASSWORD: + print(f"Email configuration missing, skipping email to {to_email}") + return + + msg = MIMEMultipart('alternative') + msg['Subject'] = subject + msg['From'] = settings.SMTP_FROM + msg['To'] = to_email + + # Add plain text part + text_part = MIMEText(body, 'plain') + msg.attach(text_part) + + # Add HTML part if provided + if html_body: + html_part = MIMEText(html_body, 'html') + msg.attach(html_part) + + try: + with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server: + server.starttls() + server.login(settings.SMTP_USER, settings.SMTP_PASSWORD) + server.send_message(msg) + print(f"Email sent successfully to {to_email}") + except Exception as e: + print(f"Failed to send email to {to_email}: {e}") + +def send_event_notification(to_email: str, event): + """Send event notification email.""" + subject = f"Nouvel Ă©vĂ©nement: {event.title}" + + body = f""" + Bonjour, + + Un nouvel Ă©vĂ©nement a Ă©tĂ© créé sur LeDiscord: + + {event.title} + Date: {event.date.strftime('%d/%m/%Y Ă  %H:%M')} + Lieu: {event.location or 'Non spĂ©cifiĂ©'} + + {event.description or ''} + + Connectez-vous pour indiquer votre prĂ©sence: {settings.APP_URL}/events/{event.id} + + À bientĂŽt ! + L'Ă©quipe LeDiscord + """ + + html_body = f""" + + +

Nouvel événement sur LeDiscord

+

{event.title}

+

Date: {event.date.strftime('%d/%m/%Y Ă  %H:%M')}

+

Lieu: {event.location or 'Non spécifié'}

+ {f'

{event.description}

' if event.description else ''} + + Indiquer ma prĂ©sence + + + + """ + + send_email(to_email, subject, body, html_body) diff --git a/backend/utils/init_db.py b/backend/utils/init_db.py new file mode 100644 index 0000000..192e1a8 --- /dev/null +++ b/backend/utils/init_db.py @@ -0,0 +1,32 @@ +from sqlalchemy.orm import Session +from config.database import SessionLocal +from config.settings import settings +from models.user import User +from utils.security import get_password_hash + +def init_database(): + """Initialize database with default admin user.""" + db = SessionLocal() + try: + # Check if admin user exists + admin = db.query(User).filter(User.email == settings.ADMIN_EMAIL).first() + if not admin: + # Create admin user + admin = User( + email=settings.ADMIN_EMAIL, + username="admin", + full_name="Administrator", + hashed_password=get_password_hash(settings.ADMIN_PASSWORD), + is_active=True, + is_admin=True + ) + db.add(admin) + db.commit() + print(f"Admin user created: {settings.ADMIN_EMAIL}") + else: + print(f"Admin user already exists: {settings.ADMIN_EMAIL}") + except Exception as e: + print(f"Error initializing database: {e}") + db.rollback() + finally: + db.close() diff --git a/backend/utils/notification_service.py b/backend/utils/notification_service.py new file mode 100644 index 0000000..bc01506 --- /dev/null +++ b/backend/utils/notification_service.py @@ -0,0 +1,136 @@ +from sqlalchemy.orm import Session +from models.notification import Notification, NotificationType +from models.user import User +from models.post import Post +from models.vlog import Vlog +from models.album import Album +from models.event import Event +from datetime import datetime + +class NotificationService: + """Service for managing notifications.""" + + @staticmethod + def create_mention_notification( + db: Session, + mentioned_user_id: int, + author: User, + content_type: str, + content_id: int, + content_preview: str = None + ): + """Create a notification for a user mention.""" + if mentioned_user_id == author.id: + return # Don't notify self + + notification = Notification( + user_id=mentioned_user_id, + type=NotificationType.POST_MENTION, + title="Vous avez Ă©tĂ© mentionnĂ©", + message=f"{author.full_name} vous a mentionnĂ© dans un(e) {content_type}", + link=f"/{content_type}s/{content_id}", + is_read=False, + created_at=datetime.utcnow() + ) + + db.add(notification) + db.commit() + return notification + + @staticmethod + def create_event_notification( + db: Session, + user_id: int, + event: Event, + author: User + ): + """Create a notification for a new event.""" + if user_id == author.id: + return # Don't notify creator + + notification = Notification( + user_id=user_id, + type=NotificationType.EVENT_INVITATION, + title="Nouvel Ă©vĂ©nement", + message=f"{author.full_name} a créé un nouvel Ă©vĂ©nement : {event.title}", + link=f"/events/{event.id}", + is_read=False, + created_at=datetime.utcnow() + ) + + db.add(notification) + db.commit() + return notification + + @staticmethod + def create_album_notification( + db: Session, + user_id: int, + album: Album, + author: User + ): + """Create a notification for a new album.""" + if user_id == author.id: + return # Don't notify creator + + notification = Notification( + user_id=user_id, + type=NotificationType.NEW_ALBUM, + title="Nouvel album", + message=f"{author.full_name} a créé un nouvel album : {album.title}", + link=f"/albums/{album.id}", + is_read=False, + created_at=datetime.utcnow() + ) + + db.add(notification) + db.commit() + return notification + + @staticmethod + def create_vlog_notification( + db: Session, + user_id: int, + vlog: Vlog, + author: User + ): + """Create a notification for a new vlog.""" + if user_id == author.id: + return # Don't notify creator + + notification = Notification( + user_id=user_id, + type=NotificationType.NEW_VLOG, + title="Nouveau vlog", + message=f"{author.full_name} a publiĂ© un nouveau vlog : {vlog.title}", + link=f"/vlogs/{vlog.id}", + is_read=False, + created_at=datetime.utcnow() + ) + + db.add(notification) + db.commit() + return notification + + @staticmethod + def create_system_notification( + db: Session, + user_id: int, + title: str, + message: str, + link: str = None + ): + """Create a system notification.""" + notification = Notification( + user_id=user_id, + type=NotificationType.SYSTEM, + title=title, + message=message, + link=link, + is_read=False, + created_at=datetime.utcnow() + ) + + db.add(notification) + db.commit() + return notification diff --git a/backend/utils/security.py b/backend/utils/security.py new file mode 100644 index 0000000..c50f881 --- /dev/null +++ b/backend/utils/security.py @@ -0,0 +1,74 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.orm import Session +from config.settings import settings +from config.database import get_db +from models.user import User +from schemas.user import TokenData + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a plain password against a hashed password.""" + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password: str) -> str: + """Generate a password hash.""" + return pwd_context.hash(password) + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """Create a JWT access token.""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.JWT_EXPIRATION_MINUTES) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + return encoded_jwt + +def verify_token(token: str, credentials_exception) -> TokenData: + """Verify a JWT token and return the token data.""" + try: + payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) + user_id: int = payload.get("sub") + email: str = payload.get("email") + if user_id is None: + raise credentials_exception + token_data = TokenData(user_id=user_id, email=email) + return token_data + except JWTError: + raise credentials_exception + +async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User: + """Get the current authenticated user.""" + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + token_data = verify_token(token, credentials_exception) + user = db.query(User).filter(User.id == token_data.user_id).first() + if user is None: + raise credentials_exception + return user + +async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User: + """Get the current active user.""" + if not current_user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return current_user + +async def get_admin_user(current_user: User = Depends(get_current_active_user)) -> User: + """Get the current admin user.""" + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + return current_user diff --git a/backend/utils/settings_service.py b/backend/utils/settings_service.py new file mode 100644 index 0000000..e18125b --- /dev/null +++ b/backend/utils/settings_service.py @@ -0,0 +1,92 @@ +from sqlalchemy.orm import Session +from models.settings import SystemSettings +from typing import Optional, Dict, Any +import json + +class SettingsService: + """Service for managing system settings.""" + + @staticmethod + def get_setting(db: Session, key: str, default: Any = None) -> Any: + """Get a setting value by key.""" + setting = db.query(SystemSettings).filter(SystemSettings.key == key).first() + if not setting: + return default + + # Essayer de convertir en type appropriĂ© + value = setting.value + + # BoolĂ©ens + if value.lower() in ['true', 'false']: + return value.lower() == 'true' + + # Nombres entiers + try: + return int(value) + except ValueError: + pass + + # Nombres flottants + try: + return float(value) + except ValueError: + pass + + # JSON + if value.startswith('{') or value.startswith('['): + try: + return json.loads(value) + except json.JSONDecodeError: + pass + + # Liste sĂ©parĂ©e par des virgules + if ',' in value and not value.startswith('{') and not value.startswith('['): + return [item.strip() for item in value.split(',')] + + return value + + @staticmethod + def get_upload_limits(db: Session) -> Dict[str, Any]: + """Get all upload-related settings.""" + return { + "max_album_size_mb": SettingsService.get_setting(db, "max_album_size_mb", 100), + "max_vlog_size_mb": SettingsService.get_setting(db, "max_vlog_size_mb", 500), + "max_image_size_mb": SettingsService.get_setting(db, "max_image_size_mb", 10), + "max_video_size_mb": SettingsService.get_setting(db, "max_video_size_mb", 100), + "allowed_image_types": SettingsService.get_setting(db, "allowed_image_types", + ["image/jpeg", "image/png", "image/gif", "image/webp"]), + "allowed_video_types": SettingsService.get_setting(db, "allowed_video_types", + ["video/mp4", "video/mpeg", "video/quicktime", "video/webm"]) + } + + @staticmethod + def get_max_upload_size(db: Session, content_type: str) -> int: + """Get max upload size for a specific content type.""" + if content_type.startswith('image/'): + max_size_mb = SettingsService.get_setting(db, "max_image_size_mb", 10) + max_size_bytes = max_size_mb * 1024 * 1024 + print(f"DEBUG - Image upload limit: {max_size_mb}MB = {max_size_bytes} bytes") + return max_size_bytes + elif content_type.startswith('video/'): + max_size_mb = SettingsService.get_setting(db, "max_video_size_mb", 100) + max_size_bytes = max_size_mb * 1024 * 1024 + print(f"DEBUG - Video upload limit: {max_size_mb}MB = {max_size_bytes} bytes") + return max_size_bytes + else: + default_size = 10 * 1024 * 1024 # 10MB par dĂ©faut + print(f"DEBUG - Default upload limit: 10MB = {default_size} bytes") + return default_size + + @staticmethod + def is_file_type_allowed(db: Session, content_type: str) -> bool: + """Check if a file type is allowed.""" + if content_type.startswith('image/'): + allowed_types = SettingsService.get_setting(db, "allowed_image_types", + ["image/jpeg", "image/png", "image/gif", "image/webp"]) + elif content_type.startswith('video/'): + allowed_types = SettingsService.get_setting(db, "allowed_video_types", + ["video/mp4", "video/mpeg", "video/quicktime", "video/webm"]) + else: + return False + + return content_type in allowed_types diff --git a/backend/utils/video_utils.py b/backend/utils/video_utils.py new file mode 100644 index 0000000..03d6f74 --- /dev/null +++ b/backend/utils/video_utils.py @@ -0,0 +1,94 @@ +import cv2 +import os +from pathlib import Path +from PIL import Image +import tempfile + +def generate_video_thumbnail(video_path: str, output_path: str, frame_time: float = 1.0) -> bool: + """ + Generate a thumbnail from a video at a specific time. + + Args: + video_path: Path to the video file + output_path: Path where to save the thumbnail + frame_time: Time in seconds to extract the frame (default: 1 second) + + Returns: + bool: True if successful, False otherwise + """ + try: + # Open video file + cap = cv2.VideoCapture(video_path) + + if not cap.isOpened(): + return False + + # Get video properties + fps = cap.get(cv2.CAP_PROP_FPS) + total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + + if total_frames == 0: + return False + + # Calculate frame number for the specified time + frame_number = min(int(fps * frame_time), total_frames - 1) + + # Set position to the specified frame + cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number) + + # Read the frame + ret, frame = cap.read() + + if not ret: + return False + + # Convert BGR to RGB + frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + + # Convert to PIL Image + pil_image = Image.fromarray(frame_rgb) + + # Resize to thumbnail size (400x400) + pil_image.thumbnail((400, 400), Image.Resampling.LANCZOS) + + # Save thumbnail + pil_image.save(output_path, "JPEG", quality=85, optimize=True) + + # Release video capture + cap.release() + + return True + + except Exception as e: + print(f"Error generating thumbnail: {e}") + return False + +def get_video_duration(video_path: str) -> float: + """ + Get the duration of a video file in seconds. + + Args: + video_path: Path to the video file + + Returns: + float: Duration in seconds, or 0 if error + """ + try: + cap = cv2.VideoCapture(video_path) + + if not cap.isOpened(): + return 0 + + fps = cap.get(cv2.CAP_PROP_FPS) + total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + + cap.release() + + if fps > 0 and total_frames > 0: + return total_frames / fps + + return 0 + + except Exception as e: + print(f"Error getting video duration: {e}") + return 0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..275852f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,70 @@ +services: + postgres: + image: postgres:15-alpine + container_name: lediscord_db + environment: + POSTGRES_DB: lediscord + POSTGRES_USER: lediscord_user + POSTGRES_PASSWORD: ${DB_PASSWORD:-lediscord_password} + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "0.0.0.0:5432:5432" + networks: + - lediscord_network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U lediscord_user -d lediscord"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: lediscord_backend + environment: + DATABASE_URL: postgresql://lediscord_user:${DB_PASSWORD:-lediscord_password}@postgres:5432/lediscord + JWT_SECRET_KEY: ${JWT_SECRET_KEY:-your-super-secret-jwt-key-change-me} + JWT_ALGORITHM: HS256 + JWT_EXPIRATION_MINUTES: 10080 + UPLOAD_PATH: /app/uploads + CORS_ORIGINS: http://localhost:5173,http://localhost:3000 + SMTP_HOST: ${SMTP_HOST:-smtp.gmail.com} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + volumes: + - ./backend:/app + - ${UPLOAD_PATH:-./uploads}:/app/uploads + ports: + - "8000:8000" + depends_on: + postgres: + condition: service_healthy + networks: + - lediscord_network + command: uvicorn app:app --host 0.0.0.0 --port 8000 --reload + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: lediscord_frontend + volumes: + - ./frontend:/app + - /app/node_modules + ports: + - "5173:5173" + networks: + - lediscord_network + environment: + - VITE_API_URL=http://localhost:8000 + command: npm run dev -- --host + +networks: + lediscord_network: + driver: bridge + +volumes: + postgres_data: diff --git a/env.example b/env.example new file mode 100644 index 0000000..db57e17 --- /dev/null +++ b/env.example @@ -0,0 +1,39 @@ +# =========================================== +# LeDiscord - Configuration d'environnement +# =========================================== +# Copiez ce fichier vers .env et modifiez les valeurs + +# =========================================== +# BASE DE DONNÉES +# =========================================== +DB_PASSWORD=lediscord_password_change_me + +# =========================================== +# JWT (SÉCURITÉ) +# =========================================== +JWT_SECRET_KEY=your-super-secret-jwt-key-change-me-to-something-very-long-and-random + +# =========================================== +# EMAIL (OPTIONNEL) +# =========================================== +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=your-email@gmail.com +SMTP_PASSWORD=your-app-password + +# =========================================== +# UPLOAD ET STOCKAGE +# =========================================== +UPLOAD_PATH=./uploads + +# =========================================== +# ADMIN PAR DÉFAUT +# =========================================== +ADMIN_EMAIL=admin@lediscord.com +ADMIN_PASSWORD=admin123_change_me + +# =========================================== +# CORS (DÉVELOPPEMENT) +# =========================================== +# Ces valeurs sont utilisĂ©es par dĂ©faut dans le code +# CORS_ORIGINS=http://localhost:5173,http://localhost:3000 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..d6a1f77 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,18 @@ +FROM node:18-alpine + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm install + +# Copy application files +COPY . . + +# Expose port +EXPOSE 5173 + +# Run the application +CMD ["npm", "run", "dev"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..1eae32e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + LeDiscord - Notre espace + + + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..48a9a7f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,28 @@ +{ + "name": "lediscord-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.3.8", + "vue-router": "^4.2.5", + "pinia": "^2.1.7", + "axios": "^1.6.2", + "@vueuse/core": "^10.6.1", + "date-fns": "^2.30.0", + "lucide-vue-next": "^0.294.0", + "vue-toastification": "^2.0.0-rc.5", + "video.js": "^8.6.1" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^4.5.0", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.31", + "tailwindcss": "^3.3.5", + "vite": "^5.0.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..fa91e46 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/components/MentionInput.vue b/frontend/src/components/MentionInput.vue new file mode 100644 index 0000000..7268e9c --- /dev/null +++ b/frontend/src/components/MentionInput.vue @@ -0,0 +1,230 @@ + + + diff --git a/frontend/src/components/UserAvatar.vue b/frontend/src/components/UserAvatar.vue new file mode 100644 index 0000000..6c9efa8 --- /dev/null +++ b/frontend/src/components/UserAvatar.vue @@ -0,0 +1,78 @@ + + + diff --git a/frontend/src/components/VideoPlayer.vue b/frontend/src/components/VideoPlayer.vue new file mode 100644 index 0000000..3cb45c0 --- /dev/null +++ b/frontend/src/components/VideoPlayer.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/frontend/src/components/VlogComments.vue b/frontend/src/components/VlogComments.vue new file mode 100644 index 0000000..64e712b --- /dev/null +++ b/frontend/src/components/VlogComments.vue @@ -0,0 +1,203 @@ + + + diff --git a/frontend/src/layouts/AuthLayout.vue b/frontend/src/layouts/AuthLayout.vue new file mode 100644 index 0000000..fe0ebca --- /dev/null +++ b/frontend/src/layouts/AuthLayout.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/layouts/DefaultLayout.vue b/frontend/src/layouts/DefaultLayout.vue new file mode 100644 index 0000000..8645de6 --- /dev/null +++ b/frontend/src/layouts/DefaultLayout.vue @@ -0,0 +1,284 @@ + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..5b447b6 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,33 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import Toast from 'vue-toastification' +import 'vue-toastification/dist/index.css' + +import App from './App.vue' +import router from './router' +import './style.css' + +const app = createApp(App) +const pinia = createPinia() + +// Toast configuration +const toastOptions = { + position: 'top-right', + timeout: 3000, + closeOnClick: true, + pauseOnFocusLoss: true, + pauseOnHover: true, + draggable: true, + draggablePercent: 0.6, + showCloseButtonOnHover: false, + hideProgressBar: false, + closeButton: 'button', + icon: true, + rtl: false +} + +app.use(pinia) +app.use(router) +app.use(Toast, toastOptions) + +app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..7e9cf3d --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,143 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '@/stores/auth' + +// Views +import Home from '@/views/Home.vue' +import Login from '@/views/Login.vue' +import Register from '@/views/Register.vue' +import Events from '@/views/Events.vue' +import EventDetail from '@/views/EventDetail.vue' +import Albums from '@/views/Albums.vue' +import AlbumDetail from '@/views/AlbumDetail.vue' +import Vlogs from '@/views/Vlogs.vue' +import VlogDetail from '@/views/VlogDetail.vue' +import Posts from '@/views/Posts.vue' +import Profile from '@/views/Profile.vue' +import UserProfile from '@/views/UserProfile.vue' +import Stats from '@/views/Stats.vue' +import Admin from '@/views/Admin.vue' +import Information from '@/views/Information.vue' +import MyTickets from '@/views/MyTickets.vue' + + +const routes = [ + { + path: '/', + name: 'Home', + component: Home, + meta: { requiresAuth: true } + }, + { + path: '/login', + name: 'Login', + component: Login, + meta: { layout: 'auth' } + }, + { + path: '/register', + name: 'Register', + component: Register, + meta: { layout: 'auth' } + }, + { + path: '/events', + name: 'Events', + component: Events, + meta: { requiresAuth: true } + }, + { + path: '/events/:id', + name: 'EventDetail', + component: EventDetail, + meta: { requiresAuth: true } + }, + { + path: '/albums', + name: 'Albums', + component: Albums, + meta: { requiresAuth: true } + }, + { + path: '/albums/:id', + name: 'AlbumDetail', + component: AlbumDetail, + meta: { requiresAuth: true } + }, + { + path: '/vlogs', + name: 'Vlogs', + component: Vlogs, + meta: { requiresAuth: true } + }, + { + path: '/vlogs/:id', + name: 'VlogDetail', + component: VlogDetail, + meta: { requiresAuth: true } + }, + { + path: '/posts', + name: 'Posts', + component: Posts, + meta: { requiresAuth: true } + }, + { + path: '/profile', + name: 'Profile', + component: Profile, + meta: { requiresAuth: true } + }, + { + path: '/profile/:id', + name: 'UserProfile', + component: UserProfile, + meta: { requiresAuth: true } + }, + { + path: '/stats', + name: 'Stats', + component: Stats, + meta: { requiresAuth: true } + }, + { + path: '/admin', + name: 'Admin', + component: Admin, + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: '/information', + name: 'Information', + component: Information, + meta: { requiresAuth: true } + }, + { + path: '/my-tickets', + name: 'MyTickets', + component: MyTickets, + meta: { requiresAuth: true } + } + +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +// Navigation guard +router.beforeEach((to, from, next) => { + const authStore = useAuthStore() + + if (to.meta.requiresAuth && !authStore.isAuthenticated) { + next('/login') + } else if (to.meta.requiresAdmin && !authStore.isAdmin) { + next('/') + } else if ((to.name === 'Login' || to.name === 'Register') && authStore.isAuthenticated) { + next('/') + } else { + next() + } +}) + +export default router diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js new file mode 100644 index 0000000..110ec23 --- /dev/null +++ b/frontend/src/stores/auth.js @@ -0,0 +1,197 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import axios from '@/utils/axios' +import router from '@/router' +import { useToast } from 'vue-toastification' + +export const useAuthStore = defineStore('auth', () => { + const user = ref(null) + const token = ref(localStorage.getItem('token')) + const toast = useToast() + + const isAuthenticated = computed(() => !!token.value) + const isAdmin = computed(() => user.value?.is_admin || false) + + if (token.value) { + axios.defaults.headers.common['Authorization'] = `Bearer ${token.value}` + } + + async function login(email, password) { + try { + // Pour OAuth2PasswordRequestForm, on doit envoyer en format x-www-form-urlencoded + const formData = new URLSearchParams() + formData.append('username', email) // OAuth2PasswordRequestForm expects username field + formData.append('password', password) + + const response = await axios.post('/api/auth/login', formData.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }) + const { access_token, user: userData } = response.data + + token.value = access_token + user.value = userData + localStorage.setItem('token', access_token) + axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}` + + toast.success(`Bienvenue ${userData.full_name} !`) + router.push('/') + + return { success: true } + } catch (error) { + toast.error(error.response?.data?.detail || 'Erreur de connexion') + return { success: false, error: error.response?.data?.detail } + } + } + + async function register(userData) { + try { + const response = await axios.post('/api/auth/register', userData) + const { access_token, user: newUser } = response.data + + token.value = access_token + user.value = newUser + localStorage.setItem('token', access_token) + axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}` + + toast.success('Inscription réussie !') + router.push('/') + + return { success: true } + } catch (error) { + toast.error(error.response?.data?.detail || 'Erreur lors de l\'inscription') + return { success: false, error: error.response?.data?.detail } + } + } + + async function logout() { + token.value = null + user.value = null + localStorage.removeItem('token') + delete axios.defaults.headers.common['Authorization'] + router.push('/login') + toast.info('Déconnexion réussie') + } + + async function fetchCurrentUser() { + if (!token.value) return + + try { + const response = await axios.get('/api/users/me') + user.value = response.data + } catch (error) { + console.error('Error fetching user:', error) + if (error.response?.status === 401) { + logout() + } + } + } + + async function updateProfile(profileData) { + try { + const response = await axios.put('/api/users/me', profileData) + user.value = response.data + toast.success('Profil mis à jour') + return { success: true, data: response.data } + } catch (error) { + toast.error('Erreur lors de la mise à jour du profil') + return { success: false, error: error.response?.data?.detail } + } + } + + async function uploadAvatar(file) { + try { + const formData = new FormData() + formData.append('file', file) + + const response = await axios.post('/api/users/me/avatar', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }) + + user.value = response.data + toast.success('Avatar mis à jour') + return { success: true, data: response.data } + } catch (error) { + console.error('Error uploading avatar:', error) + toast.error('Erreur lors de l\'upload de l\'avatar') + return { success: false, error: error.response?.data?.detail || 'Erreur inconnue' } + } + } + + // Notifications + const notifications = ref([]) + const unreadCount = ref(0) + + async function fetchNotifications() { + if (!token.value) return + + try { + const response = await axios.get('/api/notifications?limit=50') + notifications.value = response.data + unreadCount.value = notifications.value.filter(n => !n.is_read).length + } catch (error) { + console.error('Error fetching notifications:', error) + } + } + + async function markNotificationRead(notificationId) { + try { + await axios.put(`/api/notifications/${notificationId}/read`) + const notification = notifications.value.find(n => n.id === notificationId) + if (notification && !notification.is_read) { + notification.is_read = true + notification.read_at = new Date().toISOString() + unreadCount.value = Math.max(0, unreadCount.value - 1) + } + } catch (error) { + console.error('Error marking notification read:', error) + } + } + + async function markAllNotificationsRead() { + try { + await axios.put('/api/notifications/read-all') + notifications.value.forEach(n => { + n.is_read = true + n.read_at = new Date().toISOString() + }) + unreadCount.value = 0 + } catch (error) { + console.error('Error marking all notifications read:', error) + } + } + + async function fetchUnreadCount() { + if (!token.value) return + + try { + const response = await axios.get('/api/notifications/unread-count') + unreadCount.value = response.data.unread_count + } catch (error) { + console.error('Error fetching unread count:', error) + } + } + + return { + user, + token, + isAuthenticated, + isAdmin, + login, + register, + logout, + fetchCurrentUser, + updateProfile, + uploadAvatar, + // Notifications + notifications, + unreadCount, + fetchNotifications, + markNotificationRead, + markAllNotificationsRead, + fetchUnreadCount + } +}) diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..aeb178f --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,72 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + body { + @apply bg-gray-50 text-gray-900 antialiased; + } +} + +@layer components { + .btn { + @apply inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 transition-all duration-200; + } + + .btn-primary { + @apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500 shadow-lg hover:shadow-xl; + } + + .btn-secondary { + @apply btn bg-white text-secondary-700 border-secondary-200 hover:bg-secondary-50 focus:ring-primary-500 hover:border-primary-300; + } + + .card { + @apply bg-white rounded-xl shadow-sm border border-secondary-100 overflow-hidden hover:shadow-md transition-shadow; + } + + .input { + @apply block w-full px-3 py-2 border border-secondary-300 rounded-lg shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm transition-colors; + } + + .label { + @apply block text-sm font-medium text-secondary-700 mb-1; + } + + .line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + /* Discord-style gradients */ + .bg-gradient-discord { + @apply bg-gradient-to-br from-primary-50 via-white to-secondary-50; + } + + .bg-gradient-primary { + @apply bg-gradient-to-r from-primary-500 to-primary-600; + } + + .bg-gradient-secondary { + @apply bg-gradient-to-r from-secondary-500 to-secondary-600; + } + + /* Status colors */ + .status-online { + @apply bg-success-500; + } + + .status-offline { + @apply bg-secondary-400; + } + + .status-away { + @apply bg-warning-500; + } + + .status-dnd { + @apply bg-accent-500; + } +} diff --git a/frontend/src/utils/axios.js b/frontend/src/utils/axios.js new file mode 100644 index 0000000..c80009f --- /dev/null +++ b/frontend/src/utils/axios.js @@ -0,0 +1,73 @@ +import axios from 'axios' +import { useToast } from 'vue-toastification' +import router from '@/router' + +const instance = axios.create({ + baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000', + timeout: 30000, + headers: { + 'Content-Type': 'application/json' + } +}) + +// Request interceptor +instance.interceptors.request.use( + config => { + const token = localStorage.getItem('token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + error => { + return Promise.reject(error) + } +) + +// Response interceptor +instance.interceptors.response.use( + response => response, + error => { + const toast = useToast() + + if (error.response?.status === 401) { + // Ne pas rediriger si on est déjà sur une page d'auth + const currentRoute = router.currentRoute.value + if (!currentRoute.path.includes('/login') && !currentRoute.path.includes('/register')) { + localStorage.removeItem('token') + router.push('/login') + toast.error('Session expirée, veuillez vous reconnecter') + } + } else if (error.response?.status === 403) { + toast.error('AccÚs non autorisé') + } else if (error.response?.status === 500) { + toast.error('Erreur serveur, veuillez réessayer plus tard') + } + + return Promise.reject(error) + } +) + +export default instance + +// Fonction utilitaire pour construire les URLs des médias +export function getMediaUrl(path) { + if (!path) return null + if (typeof path !== 'string') return path + if (path.startsWith('http')) return path + + const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000' + + // Déjà un chemin uploads complet + if (path.startsWith('/uploads/')) { + return `${baseUrl}${path}` + } + + // Chemins relatifs issus de l'API (ex: /avatars/..., /vlogs/..., /albums/...) + if (path.startsWith('/')) { + return `${baseUrl}/uploads${path}` + } + + // Fallback + return `${baseUrl}/uploads/${path}` +} diff --git a/frontend/src/views/Admin.vue b/frontend/src/views/Admin.vue new file mode 100644 index 0000000..d5eb1e0 --- /dev/null +++ b/frontend/src/views/Admin.vue @@ -0,0 +1,2129 @@ + + + + + diff --git a/frontend/src/views/AlbumDetail.vue b/frontend/src/views/AlbumDetail.vue new file mode 100644 index 0000000..6caae81 --- /dev/null +++ b/frontend/src/views/AlbumDetail.vue @@ -0,0 +1,862 @@ + + + diff --git a/frontend/src/views/Posts.vue b/frontend/src/views/Posts.vue new file mode 100644 index 0000000..74c10c1 --- /dev/null +++ b/frontend/src/views/Posts.vue @@ -0,0 +1,521 @@ + + + diff --git a/frontend/src/views/Profile.vue b/frontend/src/views/Profile.vue new file mode 100644 index 0000000..2ee8498 --- /dev/null +++ b/frontend/src/views/Profile.vue @@ -0,0 +1,253 @@ +