Merge pull request 'fix+feat(everything): lot of things' (#3) from develop into prod
Some checks failed
Deploy to Production / build-and-deploy (push) Failing after 1m3s
Some checks failed
Deploy to Production / build-and-deploy (push) Failing after 1m3s
Reviewed-on: https://git.local.evan.casa/evan/LeDiscord/pulls/3
This commit was merged in pull request #3.
This commit is contained in:
137
backend/MIGRATIONS.md
Normal file
137
backend/MIGRATIONS.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Guide des Migrations Alembic
|
||||
|
||||
Ce projet utilise Alembic pour gérer les migrations de base de données de manière versionnée et contrôlée.
|
||||
|
||||
## 🚀 Démarrage rapide
|
||||
|
||||
### Première utilisation
|
||||
|
||||
Si c'est la première fois que vous utilisez Alembic sur ce projet :
|
||||
|
||||
```bash
|
||||
# Depuis le conteneur backend
|
||||
docker compose exec backend alembic revision --autogenerate -m "Initial migration"
|
||||
|
||||
# Appliquer la migration
|
||||
docker compose exec backend alembic upgrade head
|
||||
```
|
||||
|
||||
### Commandes courantes
|
||||
|
||||
```bash
|
||||
# Créer une nouvelle migration (autogenerate depuis les modèles)
|
||||
docker compose exec backend alembic revision --autogenerate -m "Description de la migration"
|
||||
|
||||
# Appliquer toutes les migrations en attente
|
||||
docker compose exec backend alembic upgrade head
|
||||
|
||||
# Revenir en arrière d'une migration
|
||||
docker compose exec backend alembic downgrade -1
|
||||
|
||||
# Voir la migration actuelle
|
||||
docker compose exec backend alembic current
|
||||
|
||||
# Voir l'historique des migrations
|
||||
docker compose exec backend alembic history
|
||||
```
|
||||
|
||||
## 📋 Workflow de développement
|
||||
|
||||
### 1. Modifier un modèle
|
||||
|
||||
Éditez les fichiers dans `backend/models/` pour ajouter/modifier des colonnes, tables, etc.
|
||||
|
||||
### 2. Créer une migration
|
||||
|
||||
```bash
|
||||
docker compose exec backend alembic revision --autogenerate -m "Ajout du champ X à la table Y"
|
||||
```
|
||||
|
||||
Alembic va automatiquement détecter les changements dans vos modèles SQLAlchemy.
|
||||
|
||||
### 3. Vérifier la migration
|
||||
|
||||
Ouvrez le fichier généré dans `backend/migrations/versions/` et vérifiez que les changements sont corrects.
|
||||
|
||||
### 4. Appliquer la migration
|
||||
|
||||
```bash
|
||||
docker compose exec backend alembic upgrade head
|
||||
```
|
||||
|
||||
### 5. Tester
|
||||
|
||||
Vérifiez que votre application fonctionne correctement avec les nouvelles migrations.
|
||||
|
||||
## ⚠️ Notes importantes
|
||||
|
||||
- **Ne modifiez jamais manuellement** les fichiers de migration existants qui ont déjà été appliqués
|
||||
- **Toujours tester** les migrations en développement avant de les appliquer en production
|
||||
- **Sauvegardez votre base de données** avant d'appliquer des migrations en production
|
||||
- Les migrations sont **versionnées** : chaque migration a un ID unique et un historique
|
||||
|
||||
## 🔄 Migration manuelle (sans autogenerate)
|
||||
|
||||
Si vous avez besoin de créer une migration manuelle (pour des données, des index complexes, etc.) :
|
||||
|
||||
```bash
|
||||
docker compose exec backend alembic revision -m "Description"
|
||||
```
|
||||
|
||||
Puis éditez le fichier généré dans `backend/migrations/versions/` pour ajouter votre logique.
|
||||
|
||||
## 🐛 Dépannage
|
||||
|
||||
### Migration en conflit
|
||||
|
||||
Si vous avez des conflits de migration :
|
||||
|
||||
```bash
|
||||
# Voir l'état actuel
|
||||
docker compose exec backend alembic current
|
||||
|
||||
# Voir l'historique
|
||||
docker compose exec backend alembic history
|
||||
|
||||
# Revenir à une version spécifique
|
||||
docker compose exec backend alembic downgrade <revision_id>
|
||||
```
|
||||
|
||||
### Migration qui échoue
|
||||
|
||||
Si une migration échoue :
|
||||
|
||||
1. Vérifiez les logs : `docker compose logs backend`
|
||||
2. Vérifiez l'état de la base : `docker compose exec backend alembic current`
|
||||
3. Si nécessaire, corrigez la migration et réessayez
|
||||
|
||||
### Réinitialiser les migrations (⚠️ DANGEREUX)
|
||||
|
||||
**ATTENTION** : Cela supprime toutes les données !
|
||||
|
||||
```bash
|
||||
# Supprimer toutes les tables
|
||||
docker compose exec backend alembic downgrade base
|
||||
|
||||
# Recréer depuis le début
|
||||
docker compose exec backend alembic upgrade head
|
||||
```
|
||||
|
||||
## 📁 Structure des fichiers
|
||||
|
||||
```
|
||||
backend/
|
||||
├── alembic.ini # Configuration Alembic
|
||||
├── migrations/
|
||||
│ ├── env.py # Configuration de l'environnement
|
||||
│ ├── script.py.mako # Template pour les migrations
|
||||
│ ├── versions/ # Fichiers de migration (générés)
|
||||
│ └── README.md # Documentation
|
||||
└── models/ # Modèles SQLAlchemy
|
||||
```
|
||||
|
||||
## 🔗 Ressources
|
||||
|
||||
- [Documentation Alembic](https://alembic.sqlalchemy.org/)
|
||||
- [SQLAlchemy Documentation](https://docs.sqlalchemy.org/)
|
||||
|
||||
115
backend/alembic.ini
Normal file
115
backend/alembic.ini
Normal file
@@ -0,0 +1,115 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = migrations
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python-dateutil library that can be
|
||||
# installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to dateutil.tz.gettz()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to migrations/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# post_write_hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||
# post_write_hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
|
||||
@@ -19,15 +19,51 @@ async def create_event(
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""Create a new event."""
|
||||
event_dict = event_data.dict(exclude={'invited_user_ids'})
|
||||
event = Event(
|
||||
**event_data.dict(),
|
||||
**event_dict,
|
||||
creator_id=current_user.id
|
||||
)
|
||||
db.add(event)
|
||||
db.commit()
|
||||
db.refresh(event)
|
||||
|
||||
# Create participations for all active users
|
||||
# Gérer les invitations selon le type d'événement
|
||||
if event.is_private:
|
||||
# Événement privé : inviter uniquement les utilisateurs sélectionnés
|
||||
invited_user_ids = event_data.invited_user_ids or []
|
||||
# Toujours inclure le créateur
|
||||
if current_user.id not in invited_user_ids:
|
||||
invited_user_ids.append(current_user.id)
|
||||
|
||||
for user_id in invited_user_ids:
|
||||
user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
|
||||
if user:
|
||||
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"Invitation à un événement privé: {event.title}",
|
||||
message=f"{current_user.full_name} vous a invité à un événement privé",
|
||||
link=f"/events/{event.id}"
|
||||
)
|
||||
db.add(notification)
|
||||
|
||||
# Send email notification
|
||||
try:
|
||||
send_event_notification(user.email, event)
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
# Événement public : inviter tous les utilisateurs actifs
|
||||
users = db.query(User).filter(User.is_active == True).all()
|
||||
for user in users:
|
||||
participation = EventParticipation(
|
||||
@@ -48,11 +84,11 @@ async def create_event(
|
||||
)
|
||||
db.add(notification)
|
||||
|
||||
# Send email notification (async task would be better)
|
||||
# Send email notification
|
||||
try:
|
||||
send_event_notification(user.email, event)
|
||||
except:
|
||||
pass # Don't fail if email sending fails
|
||||
pass
|
||||
|
||||
db.commit()
|
||||
return format_event_response(event, db)
|
||||
@@ -75,7 +111,23 @@ async def get_events(
|
||||
# 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]
|
||||
|
||||
# Filtrer les événements privés : ne montrer que ceux où l'utilisateur est invité
|
||||
filtered_events = []
|
||||
for event in events:
|
||||
if event.is_private:
|
||||
# Vérifier si l'utilisateur est invité (a une participation)
|
||||
participation = db.query(EventParticipation).filter(
|
||||
EventParticipation.event_id == event.id,
|
||||
EventParticipation.user_id == current_user.id
|
||||
).first()
|
||||
if participation or event.creator_id == current_user.id or current_user.is_admin:
|
||||
filtered_events.append(event)
|
||||
else:
|
||||
# Événement public : visible par tous
|
||||
filtered_events.append(event)
|
||||
|
||||
return [format_event_response(event, db) for event in filtered_events]
|
||||
|
||||
@router.get("/upcoming", response_model=List[EventResponse])
|
||||
async def get_upcoming_events(
|
||||
@@ -86,7 +138,21 @@ async def get_upcoming_events(
|
||||
events = db.query(Event).options(joinedload(Event.creator)).filter(
|
||||
Event.date >= datetime.utcnow()
|
||||
).order_by(Event.date).all()
|
||||
return [format_event_response(event, db) for event in events]
|
||||
|
||||
# Filtrer les événements privés
|
||||
filtered_events = []
|
||||
for event in events:
|
||||
if event.is_private:
|
||||
participation = db.query(EventParticipation).filter(
|
||||
EventParticipation.event_id == event.id,
|
||||
EventParticipation.user_id == current_user.id
|
||||
).first()
|
||||
if participation or event.creator_id == current_user.id or current_user.is_admin:
|
||||
filtered_events.append(event)
|
||||
else:
|
||||
filtered_events.append(event)
|
||||
|
||||
return [format_event_response(event, db) for event in filtered_events]
|
||||
|
||||
@router.get("/past", response_model=List[EventResponse])
|
||||
async def get_past_events(
|
||||
@@ -97,7 +163,21 @@ async def get_past_events(
|
||||
events = db.query(Event).options(joinedload(Event.creator)).filter(
|
||||
Event.date < datetime.utcnow()
|
||||
).order_by(Event.date.desc()).all()
|
||||
return [format_event_response(event, db) for event in events]
|
||||
|
||||
# Filtrer les événements privés
|
||||
filtered_events = []
|
||||
for event in events:
|
||||
if event.is_private:
|
||||
participation = db.query(EventParticipation).filter(
|
||||
EventParticipation.event_id == event.id,
|
||||
EventParticipation.user_id == current_user.id
|
||||
).first()
|
||||
if participation or event.creator_id == current_user.id or current_user.is_admin:
|
||||
filtered_events.append(event)
|
||||
else:
|
||||
filtered_events.append(event)
|
||||
|
||||
return [format_event_response(event, db) for event in filtered_events]
|
||||
|
||||
@router.get("/{event_id}", response_model=EventResponse)
|
||||
async def get_event(
|
||||
@@ -112,6 +192,19 @@ async def get_event(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Event not found"
|
||||
)
|
||||
|
||||
# Vérifier l'accès pour les événements privés
|
||||
if event.is_private:
|
||||
participation = db.query(EventParticipation).filter(
|
||||
EventParticipation.event_id == event.id,
|
||||
EventParticipation.user_id == current_user.id
|
||||
).first()
|
||||
if not participation and event.creator_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this private event"
|
||||
)
|
||||
|
||||
return format_event_response(event, db)
|
||||
|
||||
@router.put("/{event_id}", response_model=EventResponse)
|
||||
@@ -166,6 +259,74 @@ async def delete_event(
|
||||
db.commit()
|
||||
return {"message": "Event deleted successfully"}
|
||||
|
||||
@router.post("/{event_id}/invite", response_model=EventResponse)
|
||||
async def invite_users_to_event(
|
||||
event_id: int,
|
||||
user_ids: List[int],
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""Invite users to a private 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"
|
||||
)
|
||||
|
||||
# Vérifier que l'événement est privé et que l'utilisateur est le créateur ou admin
|
||||
if not event.is_private:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="This endpoint is only for private events"
|
||||
)
|
||||
|
||||
if event.creator_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only the event creator can invite users"
|
||||
)
|
||||
|
||||
# Inviter les utilisateurs
|
||||
for user_id in user_ids:
|
||||
# Vérifier si l'utilisateur existe et est actif
|
||||
user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
|
||||
if not user:
|
||||
continue
|
||||
|
||||
# Vérifier si l'utilisateur n'est pas déjà invité
|
||||
existing_participation = db.query(EventParticipation).filter(
|
||||
EventParticipation.event_id == event_id,
|
||||
EventParticipation.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not existing_participation:
|
||||
participation = EventParticipation(
|
||||
event_id=event.id,
|
||||
user_id=user.id,
|
||||
status=ParticipationStatus.PENDING
|
||||
)
|
||||
db.add(participation)
|
||||
|
||||
# Créer une notification
|
||||
notification = Notification(
|
||||
user_id=user.id,
|
||||
type=NotificationType.EVENT_INVITATION,
|
||||
title=f"Invitation à un événement privé: {event.title}",
|
||||
message=f"{current_user.full_name} vous a invité à un événement privé",
|
||||
link=f"/events/{event.id}"
|
||||
)
|
||||
db.add(notification)
|
||||
|
||||
# Envoyer un email
|
||||
try:
|
||||
send_event_notification(user.email, event)
|
||||
except:
|
||||
pass
|
||||
|
||||
db.commit()
|
||||
return format_event_response(event, db)
|
||||
|
||||
@router.put("/{event_id}/participation", response_model=EventResponse)
|
||||
async def update_participation(
|
||||
event_id: int,
|
||||
@@ -174,6 +335,25 @@ async def update_participation(
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""Update user participation status for 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"
|
||||
)
|
||||
|
||||
# Pour les événements privés, vérifier que l'utilisateur est invité
|
||||
if event.is_private:
|
||||
participation_check = db.query(EventParticipation).filter(
|
||||
EventParticipation.event_id == event_id,
|
||||
EventParticipation.user_id == current_user.id
|
||||
).first()
|
||||
if not participation_check and event.creator_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You are not invited to this private event"
|
||||
)
|
||||
|
||||
participation = db.query(EventParticipation).filter(
|
||||
EventParticipation.event_id == event_id,
|
||||
EventParticipation.user_id == current_user.id
|
||||
@@ -236,6 +416,7 @@ def format_event_response(event: Event, db: Session) -> dict:
|
||||
"end_date": event.end_date,
|
||||
"creator_id": event.creator_id,
|
||||
"cover_image": event.cover_image,
|
||||
"is_private": event.is_private if event.is_private is not None else False,
|
||||
"created_at": event.created_at,
|
||||
"updated_at": event.updated_at,
|
||||
"creator_name": creator.full_name if creator else "Unknown",
|
||||
|
||||
@@ -175,8 +175,9 @@ def init_default_settings():
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
print("Starting LeDiscord backend...")
|
||||
# Create tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
# Note: Database migrations are handled by Alembic
|
||||
# Run migrations manually with: alembic upgrade head
|
||||
# Base.metadata.create_all(bind=engine) # Disabled in favor of Alembic migrations
|
||||
# Initialize database with admin user
|
||||
init_database()
|
||||
# Initialize default settings
|
||||
|
||||
43
backend/migrations/README.md
Normal file
43
backend/migrations/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Migrations Alembic
|
||||
|
||||
Ce dossier contient les migrations de base de données gérées par Alembic.
|
||||
|
||||
## Commandes utiles
|
||||
|
||||
### Créer une nouvelle migration
|
||||
```bash
|
||||
alembic revision --autogenerate -m "Description de la migration"
|
||||
```
|
||||
|
||||
### Appliquer les migrations
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
### Revenir en arrière d'une migration
|
||||
```bash
|
||||
alembic downgrade -1
|
||||
```
|
||||
|
||||
### Voir l'historique des migrations
|
||||
```bash
|
||||
alembic history
|
||||
```
|
||||
|
||||
### Voir la migration actuelle
|
||||
```bash
|
||||
alembic current
|
||||
```
|
||||
|
||||
### Créer une migration vide (sans autogenerate)
|
||||
```bash
|
||||
alembic revision -m "Description de la migration"
|
||||
```
|
||||
|
||||
## Notes importantes
|
||||
|
||||
- Les migrations sont automatiquement détectées depuis les modèles SQLAlchemy dans `models/`
|
||||
- Ne modifiez jamais manuellement les fichiers de migration existants
|
||||
- Testez toujours les migrations en développement avant de les appliquer en production
|
||||
- En cas de problème, vous pouvez toujours revenir en arrière avec `alembic downgrade`
|
||||
|
||||
104
backend/migrations/env.py
Normal file
104
backend/migrations/env.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
from config.database import Base
|
||||
from config.settings import settings
|
||||
|
||||
# Import all models so Alembic can detect them
|
||||
from models import (
|
||||
User,
|
||||
Event,
|
||||
EventParticipation,
|
||||
Album,
|
||||
Media,
|
||||
MediaLike,
|
||||
Post,
|
||||
PostMention,
|
||||
PostLike,
|
||||
PostComment,
|
||||
Vlog,
|
||||
VlogLike,
|
||||
VlogComment,
|
||||
Notification,
|
||||
SystemSettings,
|
||||
Information,
|
||||
Ticket
|
||||
)
|
||||
|
||||
# set the sqlalchemy.url from settings
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
|
||||
27
backend/migrations/script.py.mako
Normal file
27
backend/migrations/script.py.mako
Normal file
@@ -0,0 +1,27 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Add is_private field to events
|
||||
|
||||
Revision ID: 89527c8da8e1
|
||||
Revises:
|
||||
Create Date: 2026-01-25 18:43:00.881982
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '89527c8da8e1'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
# Ajouter la colonne avec nullable=True d'abord
|
||||
op.add_column('events', sa.Column('is_private', sa.Boolean(), nullable=True))
|
||||
# Mettre à jour toutes les lignes existantes avec False
|
||||
op.execute("UPDATE events SET is_private = FALSE WHERE is_private IS NULL")
|
||||
# Rendre la colonne non-nullable avec une valeur par défaut
|
||||
op.alter_column('events', 'is_private',
|
||||
existing_type=sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default='false')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('events', 'is_private')
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,7 +1,7 @@
|
||||
from .user import User
|
||||
from .event import Event, EventParticipation
|
||||
from .album import Album, Media, MediaLike
|
||||
from .post import Post, PostMention
|
||||
from .post import Post, PostMention, PostLike, PostComment
|
||||
from .vlog import Vlog, VlogLike, VlogComment
|
||||
from .notification import Notification
|
||||
from .settings import SystemSettings
|
||||
@@ -17,6 +17,8 @@ __all__ = [
|
||||
"MediaLike",
|
||||
"Post",
|
||||
"PostMention",
|
||||
"PostLike",
|
||||
"PostComment",
|
||||
"Vlog",
|
||||
"VlogLike",
|
||||
"VlogComment",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Enum as SQLEnum, Float
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Enum as SQLEnum, Float, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
@@ -23,6 +23,7 @@ class Event(Base):
|
||||
end_date = Column(DateTime)
|
||||
creator_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
cover_image = Column(String)
|
||||
is_private = Column(Boolean, default=False) # Événement privé ou public
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ class EventBase(BaseModel):
|
||||
|
||||
class EventCreate(EventBase):
|
||||
cover_image: Optional[str] = None
|
||||
is_private: bool = False
|
||||
invited_user_ids: Optional[List[int]] = None # Liste des IDs des utilisateurs invités (pour événements privés)
|
||||
|
||||
class EventUpdate(BaseModel):
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||
@@ -24,6 +26,7 @@ class EventUpdate(BaseModel):
|
||||
date: Optional[datetime] = None
|
||||
end_date: Optional[datetime] = None
|
||||
cover_image: Optional[str] = None
|
||||
is_private: Optional[bool] = None
|
||||
|
||||
class ParticipationResponse(BaseModel):
|
||||
user_id: int
|
||||
@@ -42,6 +45,7 @@ class EventResponse(EventBase):
|
||||
creator_name: str
|
||||
creator_avatar: Optional[str] = None
|
||||
cover_image: Optional[str]
|
||||
is_private: bool = False
|
||||
created_at: datetime
|
||||
participations: List[ParticipationResponse] = []
|
||||
present_count: int = 0
|
||||
|
||||
42
backend/scripts/migrate.sh
Executable file
42
backend/scripts/migrate.sh
Executable file
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
# Script helper pour gérer les migrations Alembic
|
||||
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
create)
|
||||
if [ -z "$2" ]; then
|
||||
echo "Usage: ./scripts/migrate.sh create 'message de migration'"
|
||||
exit 1
|
||||
fi
|
||||
alembic revision --autogenerate -m "$2"
|
||||
;;
|
||||
upgrade)
|
||||
alembic upgrade head
|
||||
;;
|
||||
downgrade)
|
||||
if [ -z "$2" ]; then
|
||||
alembic downgrade -1
|
||||
else
|
||||
alembic downgrade "$2"
|
||||
fi
|
||||
;;
|
||||
current)
|
||||
alembic current
|
||||
;;
|
||||
history)
|
||||
alembic history
|
||||
;;
|
||||
*)
|
||||
echo "Usage: ./scripts/migrate.sh {create|upgrade|downgrade|current|history}"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " create 'message' - Créer une nouvelle migration"
|
||||
echo " upgrade - Appliquer toutes les migrations en attente"
|
||||
echo " downgrade [rev] - Revenir en arrière (par défaut -1)"
|
||||
echo " current - Afficher la migration actuelle"
|
||||
echo " history - Afficher l'historique des migrations"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -1,15 +1,67 @@
|
||||
FROM node:18-alpine
|
||||
# Stage 1 : Build prod
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files first for better caching
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
|
||||
# Run the application in development mode
|
||||
CMD ["npm", "run", "dev"]
|
||||
ARG VITE_API_URL=https://api.lediscord.com
|
||||
ARG VITE_APP_URL=https://lediscord.com
|
||||
ARG VITE_UPLOAD_URL=https://api.lediscord.com/uploads
|
||||
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
ENV VITE_APP_URL=$VITE_APP_URL
|
||||
ENV VITE_UPLOAD_URL=$VITE_UPLOAD_URL
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2 : Image finale avec les deux modes
|
||||
FROM node:20-alpine
|
||||
|
||||
RUN apk add --no-cache nginx
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copier les sources pour le mode dev
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
# Copier le build prod
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Config nginx
|
||||
RUN echo 'server { \
|
||||
listen 8080; \
|
||||
root /usr/share/nginx/html; \
|
||||
index index.html; \
|
||||
location / { \
|
||||
try_files $uri $uri/ /index.html; \
|
||||
} \
|
||||
location /assets { \
|
||||
expires 1y; \
|
||||
add_header Cache-Control "public, immutable"; \
|
||||
} \
|
||||
}' > /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Script d'entrée
|
||||
COPY <<EOF /entrypoint.sh
|
||||
#!/bin/sh
|
||||
if [ "\$MODE" = "dev" ]; then
|
||||
echo "🔧 Mode DEVELOPPEMENT"
|
||||
exec npm run dev -- --host 0.0.0.0 --port 8080
|
||||
else
|
||||
echo "🚀 Mode PRODUCTION"
|
||||
exec nginx -g "daemon off;"
|
||||
fi
|
||||
EOF
|
||||
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["/entrypoint.sh"]
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="video-player-container">
|
||||
<div class="video-player-container px-2 sm:px-0">
|
||||
<div class="relative">
|
||||
<!-- Video.js Player -->
|
||||
<div data-vjs-player>
|
||||
|
||||
@@ -71,7 +71,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Participation Badge -->
|
||||
<div class="absolute top-2 right-2">
|
||||
<div class="absolute top-2 right-2 flex gap-1">
|
||||
<span
|
||||
v-if="event.is_private"
|
||||
class="px-2 py-1 rounded-full text-xs font-medium text-white bg-purple-600"
|
||||
title="Événement privé"
|
||||
>
|
||||
🔒 Privé
|
||||
</span>
|
||||
<span
|
||||
v-if="getUserParticipation(event)"
|
||||
class="px-2 py-1 rounded-full text-xs font-medium text-white"
|
||||
@@ -235,6 +242,68 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Type d'événement -->
|
||||
<div>
|
||||
<label class="label">Type d'événement</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="newEvent.is_private"
|
||||
:value="false"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm">Public (visible par tous)</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="newEvent.is_private"
|
||||
:value="true"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm">Privé (invitations uniquement)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sélection des invités pour événements privés -->
|
||||
<div v-if="newEvent.is_private">
|
||||
<label class="label">Inviter des membres</label>
|
||||
<div class="border border-gray-300 rounded-lg p-3 max-h-48 overflow-y-auto">
|
||||
<div
|
||||
v-for="user in users.filter(u => u.id !== authStore.user?.id)"
|
||||
:key="user.id"
|
||||
class="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
v-if="user.avatar_url"
|
||||
:src="getMediaUrl(user.avatar_url)"
|
||||
:alt="user.full_name"
|
||||
class="w-8 h-8 rounded-full mr-2 object-cover"
|
||||
>
|
||||
<div v-else class="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center mr-2">
|
||||
<User class="w-4 h-4 text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900">{{ user.full_name }}</div>
|
||||
<div class="text-xs text-gray-500">@{{ user.username }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="user.id"
|
||||
v-model="newEvent.invited_user_ids"
|
||||
class="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
Sélectionnez les membres à inviter à cet événement privé
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@@ -294,7 +363,9 @@ const newEvent = ref({
|
||||
description: '',
|
||||
date: '',
|
||||
location: '',
|
||||
end_date: null
|
||||
end_date: null,
|
||||
is_private: false,
|
||||
invited_user_ids: []
|
||||
})
|
||||
|
||||
const eventMentions = ref([])
|
||||
@@ -399,6 +470,12 @@ async function quickParticipation(eventId, status) {
|
||||
async function createEvent() {
|
||||
if (!newEvent.value.title || !newEvent.value.date) return
|
||||
|
||||
// Vérifier que des invités sont sélectionnés pour les événements privés
|
||||
if (newEvent.value.is_private && (!newEvent.value.invited_user_ids || newEvent.value.invited_user_ids.length === 0)) {
|
||||
toast.warning('Veuillez sélectionner au moins un membre à inviter pour un événement privé')
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
const eventData = {
|
||||
@@ -406,7 +483,9 @@ async function createEvent() {
|
||||
description: newEvent.value.description,
|
||||
date: new Date(newEvent.value.date).toISOString(),
|
||||
location: newEvent.value.location,
|
||||
end_date: newEvent.value.end_date ? new Date(newEvent.value.end_date).toISOString() : null
|
||||
end_date: newEvent.value.end_date ? new Date(newEvent.value.end_date).toISOString() : null,
|
||||
is_private: newEvent.value.is_private,
|
||||
invited_user_ids: newEvent.value.is_private ? newEvent.value.invited_user_ids : null
|
||||
}
|
||||
|
||||
await axios.post('/api/events', eventData)
|
||||
@@ -416,7 +495,7 @@ async function createEvent() {
|
||||
|
||||
showCreateModal.value = false
|
||||
resetForm()
|
||||
toast.success('Événement créé avec succès')
|
||||
toast.success(newEvent.value.is_private ? 'Événement privé créé avec succès' : 'Événement créé avec succès')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la création de l\'événement')
|
||||
}
|
||||
@@ -429,7 +508,9 @@ function resetForm() {
|
||||
description: '',
|
||||
date: '',
|
||||
location: '',
|
||||
end_date: null
|
||||
end_date: null,
|
||||
is_private: false,
|
||||
invited_user_ids: []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user