initial commit - LeDiscord plateforme des copains

This commit is contained in:
EvanChal
2025-08-21 00:28:21 +02:00
commit b7a84a53aa
93 changed files with 16247 additions and 0 deletions

29
backend/Dockerfile Normal file
View File

@@ -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"]

1
backend/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Backend package initialization

View File

@@ -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"]

View File

@@ -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)}"
)

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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}

View File

@@ -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
}

View File

@@ -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"}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

230
backend/app.py Normal file
View File

@@ -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"}

View File

@@ -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()

View File

@@ -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()

View File

@@ -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"
]

58
backend/models/album.py Normal file
View File

@@ -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")

46
backend/models/event.py Normal file
View File

@@ -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")

View File

@@ -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)

View File

@@ -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")

60
backend/models/post.py Normal file
View File

@@ -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")

View File

@@ -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)

45
backend/models/ticket.py Normal file
View File

@@ -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])

35
backend/models/user.py Normal file
View File

@@ -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")

50
backend/models/vlog.py Normal file
View File

@@ -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")

21
backend/requirements.txt Normal file
View File

@@ -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

View File

@@ -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"
]

61
backend/schemas/album.py Normal file
View File

@@ -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

55
backend/schemas/event.py Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

54
backend/schemas/post.py Normal file
View File

@@ -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

View File

@@ -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]

47
backend/schemas/ticket.py Normal file
View File

@@ -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

41
backend/schemas/user.py Normal file
View File

@@ -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

63
backend/schemas/vlog.py Normal file
View File

@@ -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)

72
backend/utils/email.py Normal file
View File

@@ -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"""
<html>
<body style="font-family: Arial, sans-serif;">
<h2>Nouvel événement sur LeDiscord</h2>
<h3>{event.title}</h3>
<p><strong>Date:</strong> {event.date.strftime('%d/%m/%Y à %H:%M')}</p>
<p><strong>Lieu:</strong> {event.location or 'Non spécifié'}</p>
{f'<p>{event.description}</p>' if event.description else ''}
<a href="{settings.APP_URL}/events/{event.id}"
style="display: inline-block; padding: 10px 20px; background-color: #007bff; color: white; text-decoration: none; border-radius: 5px;">
Indiquer ma présence
</a>
</body>
</html>
"""
send_email(to_email, subject, body, html_body)

32
backend/utils/init_db.py Normal file
View File

@@ -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()

View File

@@ -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

74
backend/utils/security.py Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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