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)
|
current_user: User = Depends(get_current_active_user)
|
||||||
):
|
):
|
||||||
"""Create a new event."""
|
"""Create a new event."""
|
||||||
|
event_dict = event_data.dict(exclude={'invited_user_ids'})
|
||||||
event = Event(
|
event = Event(
|
||||||
**event_data.dict(),
|
**event_dict,
|
||||||
creator_id=current_user.id
|
creator_id=current_user.id
|
||||||
)
|
)
|
||||||
db.add(event)
|
db.add(event)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(event)
|
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()
|
users = db.query(User).filter(User.is_active == True).all()
|
||||||
for user in users:
|
for user in users:
|
||||||
participation = EventParticipation(
|
participation = EventParticipation(
|
||||||
@@ -48,11 +84,11 @@ async def create_event(
|
|||||||
)
|
)
|
||||||
db.add(notification)
|
db.add(notification)
|
||||||
|
|
||||||
# Send email notification (async task would be better)
|
# Send email notification
|
||||||
try:
|
try:
|
||||||
send_event_notification(user.email, event)
|
send_event_notification(user.email, event)
|
||||||
except:
|
except:
|
||||||
pass # Don't fail if email sending fails
|
pass
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return format_event_response(event, db)
|
return format_event_response(event, db)
|
||||||
@@ -75,7 +111,23 @@ async def get_events(
|
|||||||
# If upcoming is None, return all events
|
# If upcoming is None, return all events
|
||||||
|
|
||||||
events = query.order_by(Event.date.desc()).all()
|
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])
|
@router.get("/upcoming", response_model=List[EventResponse])
|
||||||
async def get_upcoming_events(
|
async def get_upcoming_events(
|
||||||
@@ -86,7 +138,21 @@ async def get_upcoming_events(
|
|||||||
events = db.query(Event).options(joinedload(Event.creator)).filter(
|
events = db.query(Event).options(joinedload(Event.creator)).filter(
|
||||||
Event.date >= datetime.utcnow()
|
Event.date >= datetime.utcnow()
|
||||||
).order_by(Event.date).all()
|
).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])
|
@router.get("/past", response_model=List[EventResponse])
|
||||||
async def get_past_events(
|
async def get_past_events(
|
||||||
@@ -97,7 +163,21 @@ async def get_past_events(
|
|||||||
events = db.query(Event).options(joinedload(Event.creator)).filter(
|
events = db.query(Event).options(joinedload(Event.creator)).filter(
|
||||||
Event.date < datetime.utcnow()
|
Event.date < datetime.utcnow()
|
||||||
).order_by(Event.date.desc()).all()
|
).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)
|
@router.get("/{event_id}", response_model=EventResponse)
|
||||||
async def get_event(
|
async def get_event(
|
||||||
@@ -112,6 +192,19 @@ async def get_event(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="Event 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)
|
return format_event_response(event, db)
|
||||||
|
|
||||||
@router.put("/{event_id}", response_model=EventResponse)
|
@router.put("/{event_id}", response_model=EventResponse)
|
||||||
@@ -166,6 +259,74 @@ async def delete_event(
|
|||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "Event deleted successfully"}
|
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)
|
@router.put("/{event_id}/participation", response_model=EventResponse)
|
||||||
async def update_participation(
|
async def update_participation(
|
||||||
event_id: int,
|
event_id: int,
|
||||||
@@ -174,6 +335,25 @@ async def update_participation(
|
|||||||
current_user: User = Depends(get_current_active_user)
|
current_user: User = Depends(get_current_active_user)
|
||||||
):
|
):
|
||||||
"""Update user participation status for an event."""
|
"""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(
|
participation = db.query(EventParticipation).filter(
|
||||||
EventParticipation.event_id == event_id,
|
EventParticipation.event_id == event_id,
|
||||||
EventParticipation.user_id == current_user.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,
|
"end_date": event.end_date,
|
||||||
"creator_id": event.creator_id,
|
"creator_id": event.creator_id,
|
||||||
"cover_image": event.cover_image,
|
"cover_image": event.cover_image,
|
||||||
|
"is_private": event.is_private if event.is_private is not None else False,
|
||||||
"created_at": event.created_at,
|
"created_at": event.created_at,
|
||||||
"updated_at": event.updated_at,
|
"updated_at": event.updated_at,
|
||||||
"creator_name": creator.full_name if creator else "Unknown",
|
"creator_name": creator.full_name if creator else "Unknown",
|
||||||
|
|||||||
@@ -175,8 +175,9 @@ def init_default_settings():
|
|||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Startup
|
# Startup
|
||||||
print("Starting LeDiscord backend...")
|
print("Starting LeDiscord backend...")
|
||||||
# Create tables
|
# Note: Database migrations are handled by Alembic
|
||||||
Base.metadata.create_all(bind=engine)
|
# Run migrations manually with: alembic upgrade head
|
||||||
|
# Base.metadata.create_all(bind=engine) # Disabled in favor of Alembic migrations
|
||||||
# Initialize database with admin user
|
# Initialize database with admin user
|
||||||
init_database()
|
init_database()
|
||||||
# Initialize default settings
|
# 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 .user import User
|
||||||
from .event import Event, EventParticipation
|
from .event import Event, EventParticipation
|
||||||
from .album import Album, Media, MediaLike
|
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 .vlog import Vlog, VlogLike, VlogComment
|
||||||
from .notification import Notification
|
from .notification import Notification
|
||||||
from .settings import SystemSettings
|
from .settings import SystemSettings
|
||||||
@@ -17,6 +17,8 @@ __all__ = [
|
|||||||
"MediaLike",
|
"MediaLike",
|
||||||
"Post",
|
"Post",
|
||||||
"PostMention",
|
"PostMention",
|
||||||
|
"PostLike",
|
||||||
|
"PostComment",
|
||||||
"Vlog",
|
"Vlog",
|
||||||
"VlogLike",
|
"VlogLike",
|
||||||
"VlogComment",
|
"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 sqlalchemy.orm import relationship
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import enum
|
import enum
|
||||||
@@ -23,6 +23,7 @@ class Event(Base):
|
|||||||
end_date = Column(DateTime)
|
end_date = Column(DateTime)
|
||||||
creator_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
creator_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
cover_image = Column(String)
|
cover_image = Column(String)
|
||||||
|
is_private = Column(Boolean, default=False) # Événement privé ou public
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ class EventBase(BaseModel):
|
|||||||
|
|
||||||
class EventCreate(EventBase):
|
class EventCreate(EventBase):
|
||||||
cover_image: Optional[str] = None
|
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):
|
class EventUpdate(BaseModel):
|
||||||
title: Optional[str] = Field(None, min_length=1, max_length=200)
|
title: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||||
@@ -24,6 +26,7 @@ class EventUpdate(BaseModel):
|
|||||||
date: Optional[datetime] = None
|
date: Optional[datetime] = None
|
||||||
end_date: Optional[datetime] = None
|
end_date: Optional[datetime] = None
|
||||||
cover_image: Optional[str] = None
|
cover_image: Optional[str] = None
|
||||||
|
is_private: Optional[bool] = None
|
||||||
|
|
||||||
class ParticipationResponse(BaseModel):
|
class ParticipationResponse(BaseModel):
|
||||||
user_id: int
|
user_id: int
|
||||||
@@ -42,6 +45,7 @@ class EventResponse(EventBase):
|
|||||||
creator_name: str
|
creator_name: str
|
||||||
creator_avatar: Optional[str] = None
|
creator_avatar: Optional[str] = None
|
||||||
cover_image: Optional[str]
|
cover_image: Optional[str]
|
||||||
|
is_private: bool = False
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
participations: List[ParticipationResponse] = []
|
participations: List[ParticipationResponse] = []
|
||||||
present_count: int = 0
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files first for better caching
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
# Copy application files
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Run the application in development mode
|
ARG VITE_API_URL=https://api.lediscord.com
|
||||||
CMD ["npm", "run", "dev"]
|
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>
|
<template>
|
||||||
<div class="video-player-container">
|
<div class="video-player-container px-2 sm:px-0">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<!-- Video.js Player -->
|
<!-- Video.js Player -->
|
||||||
<div data-vjs-player>
|
<div data-vjs-player>
|
||||||
|
|||||||
@@ -71,7 +71,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Participation Badge -->
|
<!-- 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
|
<span
|
||||||
v-if="getUserParticipation(event)"
|
v-if="getUserParticipation(event)"
|
||||||
class="px-2 py-1 rounded-full text-xs font-medium text-white"
|
class="px-2 py-1 rounded-full text-xs font-medium text-white"
|
||||||
@@ -235,6 +242,68 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="flex gap-3 pt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -294,7 +363,9 @@ const newEvent = ref({
|
|||||||
description: '',
|
description: '',
|
||||||
date: '',
|
date: '',
|
||||||
location: '',
|
location: '',
|
||||||
end_date: null
|
end_date: null,
|
||||||
|
is_private: false,
|
||||||
|
invited_user_ids: []
|
||||||
})
|
})
|
||||||
|
|
||||||
const eventMentions = ref([])
|
const eventMentions = ref([])
|
||||||
@@ -399,6 +470,12 @@ async function quickParticipation(eventId, status) {
|
|||||||
async function createEvent() {
|
async function createEvent() {
|
||||||
if (!newEvent.value.title || !newEvent.value.date) return
|
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
|
creating.value = true
|
||||||
try {
|
try {
|
||||||
const eventData = {
|
const eventData = {
|
||||||
@@ -406,7 +483,9 @@ async function createEvent() {
|
|||||||
description: newEvent.value.description,
|
description: newEvent.value.description,
|
||||||
date: new Date(newEvent.value.date).toISOString(),
|
date: new Date(newEvent.value.date).toISOString(),
|
||||||
location: newEvent.value.location,
|
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)
|
await axios.post('/api/events', eventData)
|
||||||
@@ -416,7 +495,7 @@ async function createEvent() {
|
|||||||
|
|
||||||
showCreateModal.value = false
|
showCreateModal.value = false
|
||||||
resetForm()
|
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) {
|
} catch (error) {
|
||||||
toast.error('Erreur lors de la création de l\'événement')
|
toast.error('Erreur lors de la création de l\'événement')
|
||||||
}
|
}
|
||||||
@@ -429,7 +508,9 @@ function resetForm() {
|
|||||||
description: '',
|
description: '',
|
||||||
date: '',
|
date: '',
|
||||||
location: '',
|
location: '',
|
||||||
end_date: null
|
end_date: null,
|
||||||
|
is_private: false,
|
||||||
|
invited_user_ids: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user