This commit is contained in:
Johan
2025-12-16 16:54:12 +01:00
commit 26016e93ba
59 changed files with 3632 additions and 0 deletions

0
src/app/__init__.py Normal file
View File

0
src/app/api/__init__.py Normal file
View File

10
src/app/api/deps.py Normal file
View File

@@ -0,0 +1,10 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import AsyncSessionLocal
# Injection de dépendances de la session SQLAlchemy (ORM)
async def get_db() -> AsyncSession:
"""
Dependency that provides a database session for a single request.
"""
async with AsyncSessionLocal() as session:
yield session

View File

@@ -0,0 +1,43 @@
from fastapi import Request, status
from fastapi.responses import JSONResponse
from app.core.exceptions import NotFoundBLLException, ValidationBLLException, DALException, BLLException
async def not_found_bll_exception_handler(request: Request, exc: NotFoundBLLException):
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={"detail": str(exc)},
)
async def validation_bll_exception_handler(request: Request, exc: ValidationBLLException):
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": str(exc)},
)
async def bll_exception_handler(request: Request, exc: BLLException):
"""Gestionnaire pour les autres erreurs métier non spécifiques."""
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": str(exc)},
)
async def dal_exception_handler(request: Request, exc: DALException):
# En production, on devrait logger l'exception originale: exc.original_exception
print(f"DAL Error: {exc.original_exception}") # Pour le debug
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "Une erreur interne est survenue au niveau de la base de données."},
)
# Note : cela évite de devoir gérer les exceptions de type DAL et BLL dans les routeurs FastAPI directement :
#
# @router.get("/movies/{movie_id}", response_model=movie_schemas.Movie)
# async def read_movie(movie_id: int, db: AsyncSession = Depends(get_db)):
# """Récupère les détails d'un film spécifique par son ID."""
# try:
# return await movie_service.get_movie_by_id(db=db, movie_id=movie_id)
# except NotFoundError as e:
# raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# except DALException as e:
# # Logguer l'erreur originale (e.original_exception) serait une bonne pratique ici
# raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Erreur interne du serveur.")

View File

View File

@@ -0,0 +1,12 @@
from fastapi import APIRouter, Depends
from typing import List
from sqlalchemy.ext.asyncio import AsyncSession
import app.schemas.genre as genre_schemas
from app.api.deps import get_db
import app.services.genre as genre_service
router = APIRouter()
@router.get("/genres/", response_model=List[genre_schemas.GenreRead])
async def read_genres(db: AsyncSession = Depends(get_db)):
return await genre_service.get_genres(db)

View File

@@ -0,0 +1,24 @@
from fastapi import APIRouter, Depends
from typing import List
from sqlalchemy.ext.asyncio import AsyncSession
import app.schemas.movie as movie_schemas
from app.api.deps import get_db
import app.services.movie as movie_service
from fastapi import status
router = APIRouter()
@router.post("/movies/", response_model=movie_schemas.MovieRead, status_code=status.HTTP_201_CREATED)
async def create_movie(movie: movie_schemas.MovieCreate, db: AsyncSession = Depends(get_db)):
"""Crée un nouveau film."""
return await movie_service.create_movie(db=db, movie=movie)
@router.get("/movies/", response_model=List[movie_schemas.MovieRead])
async def read_movies(skip: int = 0, limit: int = 100, db: AsyncSession = Depends(get_db)):
"""Récupère une liste de films."""
return await movie_service.get_movies(db, skip=skip, limit=limit)
@router.get("/movies/{movie_id}", response_model=movie_schemas.MovieRead)
async def read_movie(movie_id: int, db: AsyncSession = Depends(get_db)):
"""Récupère les détails d'un film spécifique par son ID."""
return await movie_service.get_movie_by_id(db=db, movie_id=movie_id)

View File

@@ -0,0 +1,21 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
import app.schemas.opinion as opinion_schemas
from app.api.deps import get_db
import app.services.opinion as opinion_service
from fastapi import Response, status
router = APIRouter()
@router.post("/movies/{movie_id}/opinions/", response_model=opinion_schemas.OpinionRead, status_code=status.HTTP_201_CREATED)
async def create_opinion_for_movie(
movie_id: int, opinion: opinion_schemas.OpinionCreate, db: AsyncSession = Depends(get_db)
):
"""Ajoute un avis à un film spécifique."""
return await opinion_service.create_opinion(db=db, movie_id=movie_id, opinion=opinion)
@router.delete("/opinions/{opinion_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_opinion(opinion_id: int, db: AsyncSession = Depends(get_db)):
"""Supprime un avis par son ID."""
await opinion_service.delete_opinion(db=db, opinion_id=opinion_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)

View File

@@ -0,0 +1,28 @@
from typing import List
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db
from app.schemas.participant import ParticipantRead, ParticipantCreate, ParticipantUpdate
from app.services import participant as participant_service
router = APIRouter()
@router.get("/participants/", response_model=List[ParticipantRead])
async def read_participants(db: AsyncSession = Depends(get_db)):
"""Récupère une liste de tous les participants."""
return await participant_service.get_participants(db)
@router.post("/participants/", response_model=ParticipantRead, status_code=status.HTTP_201_CREATED)
async def create_participant(
participant: ParticipantCreate, db: AsyncSession = Depends(get_db)
):
"""Crée un nouveau participant (acteur ou réalisateur)."""
return await participant_service.create_participant(db=db, participant=participant)
@router.patch("/participants/{participant_id}", response_model=ParticipantRead)
async def update_participant(
participant_id: int, participant_data: ParticipantUpdate, db: AsyncSession = Depends(get_db)
):
"""Met à jour un participant existant."""
return await participant_service.update_participant(db, participant_id, participant_data)

0
src/app/core/__init__.py Normal file
View File

38
src/app/core/config.py Normal file
View File

@@ -0,0 +1,38 @@
from typing import List
from pydantic import AnyHttpUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""
Classe de configuration qui charge les variables d'environnement.
"""
# Configuration du modèle Pydantic
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=True # Respecte la casse des variables
)
# Paramètres du projet
PROJECT_NAME: str = "FastAPI Project"
API_V1_STR: str = "/api/v1"
# Configuration de la base de données
# Le type hint `str` est suffisant, mais des types plus stricts peuvent être utilisés
DATABASE_URL: str = "sqlite+aiosqlite:///./local_dev.db"
# Configuration de la sécurité (JWT)
# SECRET_KEY: str
# ALGORITHM: str = "HS256"
#ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# Configuration CORS
# Pydantic va automatiquement convertir la chaîne de caractères séparée par des virgules
# en une liste de chaînes de caractères.
BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = []
# Création d'une instance unique des paramètres qui sera importée dans le reste de l'application
settings = Settings()

View File

@@ -0,0 +1,25 @@
class BaseAppException(Exception):
"""Exception de base pour l'application."""
pass
class DALException(BaseAppException):
"""Exception levée pour les erreurs de la couche d'accès aux données (DAL)."""
def __init__(self, message: str, original_exception: Exception = None):
self.message = message
self.original_exception = original_exception
super().__init__(self.message)
class BLLException(BaseAppException):
"""Exception de base pour les erreurs de la couche métier (BLL)."""
pass
class NotFoundBLLException(BLLException):
"""Levée lorsqu'une ressource n'est pas trouvée."""
def __init__(self, resource_name: str, resource_id: int | str):
message = f"{resource_name} avec l'ID '{resource_id}' non trouvé."
super().__init__(message)
class ValidationBLLException(BLLException):
"""Levée pour les erreurs de validation des règles métier."""
pass

0
src/app/db/__init__.py Normal file
View File

71
src/app/db/seeding.py Normal file
View File

@@ -0,0 +1,71 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.models.genre import Genre
from app.models.participant import Participant
from app.models.member import Member
from app.models.movie import Movie
from app.models.opinion import Opinion
async def seed_db(session: AsyncSession):
"""
Préremplit la base de données avec des données initiales si elle est vide.
"""
# 1. Vérifier si la BDD est déja remplie (ex: en comptant les genres)
result = await session.execute(select(Genre))
if result.scalars().first() is not None:
print("La base de données contient déja des données. Seeding annulé.")
return
print("Base de données vide. Début du seeding...")
# 2. Créer les Genres
genre1 = Genre(label="Science-Fiction")
genre2 = Genre(label="Comédie")
genre3 = Genre(label="Drame")
session.add_all([genre1, genre2, genre3])
await session.commit() # Commit pour que les objets aient un ID
# 3. Créer les Participants (Acteurs/Réalisateurs)
director1 = Participant(first_name="Christopher", last_name="Nolan")
actor1 = Participant(first_name="Leonardo", last_name="DiCaprio")
actor2 = Participant(first_name="Marion", last_name="Cotillard")
session.add_all([director1, actor1, actor2])
await session.commit()
# 4. Créer un Membre
member1 = Member(
login="testuser",
password="hashed_password_here", # En réalité, il faudrait hasher ce mot de passe
is_admin=False,
first_name="Test",
last_name="User"
)
session.add(member1)
await session.commit()
# 5. Créer un Film
movie1 = Movie(
title="Inception",
year=2010,
duration=148,
synopsis="Un voleur qui s'approprie des secrets... (etc)",
director_id=director1.id,
genre_id=genre1.id,
actors=[actor1, actor2] # La relation M2M est gérée par SQLAlchemy
)
session.add(movie1)
await session.commit()
# 6. Créer un Avis
opinion1 = Opinion(
note=5,
comment="Incroyable !",
member_id=member1.id,
movie_id=movie1.id
)
session.add(opinion1)
await session.commit()
print(f"Film '{movie1.title}' et ses relations ont été ajoutés.")

21
src/app/db/session.py Normal file
View File

@@ -0,0 +1,21 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
# Configuration conditionnelle de l'engine
if "sqlite+aiosqlite" in settings.DATABASE_URL:
# Configuration pour SQLite basé sur un fichier
engine = create_async_engine(
settings.DATABASE_URL,
echo=True,
connect_args={"check_same_thread": False} # Requis pour SQLite
)
else:
# Configuration par défaut pour les autres BDD (ex: mysql+asyncmy, postgresql+asyncpg, etc.)
engine = create_async_engine(settings.DATABASE_URL, echo=True)
# SessionMaker pour créer des sessions asynchrones
# expire_on_commit=False est important pour utiliser les objets après le commit dans un contexte async
AsyncSessionLocal = sessionmaker(
autocommit=False, autoflush=False, bind=engine, class_=AsyncSession, expire_on_commit=False
)

65
src/app/main.py Normal file
View File

@@ -0,0 +1,65 @@
from fastapi import FastAPI
from contextlib import asynccontextmanager
from app.core.config import settings
from fastapi.middleware.cors import CORSMiddleware
from app.db.session import engine, AsyncSessionLocal
from app.api.routers.opinions import router as opinions_router
from app.api.routers.movies import router as movies_router
from app.api.routers.genres import router as genres_router
from app.api.routers.participants import router as participants_router
from app.models import Base
from app.core.exceptions import NotFoundBLLException, ValidationBLLException, DALException, BLLException
from app.api.exception_handlers import (
not_found_bll_exception_handler,
validation_bll_exception_handler,
dal_exception_handler,
bll_exception_handler,
)
from app.db.seeding import seed_db
# Crée les tables dans la BDD au démarrage (pour le développement)
# En production, on utiliserait un outil de migration comme Alembic.
@asynccontextmanager
async def lifespan(myapp: FastAPI):
async with engine.begin() as conn:
#await conn.run_sync(Base.metadata.drop_all) Optionnel: pour repartir de zéro
await conn.run_sync(Base.metadata.create_all)
async with AsyncSessionLocal() as session:
await seed_db(session)
yield
app = FastAPI(
title="API Filmothèque",
description="Une API pour gérer une collection de films, réalisée avec FastAPI et SQLAlchemy async.",
version="1.0.0",
lifespan=lifespan
)
if settings.BACKEND_CORS_ORIGINS:
app.add_middleware(
CORSMiddleware,
allow_origins=[str(origin).rstrip('/') for origin in settings.BACKEND_CORS_ORIGINS],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(opinions_router, prefix=settings.API_V1_STR, tags=["Opinions"])
app.include_router(movies_router, prefix=settings.API_V1_STR, tags=["Movies"])
app.include_router(genres_router, prefix=settings.API_V1_STR, tags=["Genres"])
app.include_router(participants_router, prefix=settings.API_V1_STR, tags=["Participants"])
# Ajouter les gestionnaires d'exceptions
app.add_exception_handler(NotFoundBLLException, not_found_bll_exception_handler)
app.add_exception_handler(ValidationBLLException, validation_bll_exception_handler)
app.add_exception_handler(BLLException, bll_exception_handler) # Gestionnaire plus générique
app.add_exception_handler(DALException, dal_exception_handler)
@app.get("/", tags=["Root"])
def read_root():
"""
Un endpoint simple pour vérifier que l'API est en ligne.
"""
return {"message": "Welcome to this fantastic API!"}

View File

@@ -0,0 +1,13 @@
# Eviter les erreurs liés à l'importation circulaire
# Exemple : sqlalchemy.exc.InvalidRequestError: When initializing mapper Mapper[Movie(movies)], expression 'Genre' failed to locate a name ('Genre'). If this is a class name, consider adding this relationship() to the <class 'app.models.film.Film'> class after both dependent classes have been defined
from .base_class import Base
from .genre import Genre
from .member import Member
from .movie import Movie
from .opinion import Opinion
from .participant import Participant
from .person import Person
# L'import des associations (ex : personnefilm) n'est en général pas nécessaire ici car elles sont gérées dans les modèles eux-mêmes

View File

@@ -0,0 +1,3 @@
from sqlalchemy.orm import declarative_base
Base = declarative_base()

13
src/app/models/genre.py Normal file
View File

@@ -0,0 +1,13 @@
from sqlalchemy import (
String,
)
from sqlalchemy.orm import (
Mapped,
mapped_column,
)
from .base_class import Base
class Genre(Base):
__tablename__ = "genres"
id: Mapped[int] = mapped_column(primary_key=True)
label: Mapped[str] = mapped_column(String(255))

33
src/app/models/member.py Normal file
View File

@@ -0,0 +1,33 @@
from sqlalchemy import (
String,
Boolean,
ForeignKey,
)
from sqlalchemy.orm import (
Mapped,
mapped_column,
relationship
)
from typing import List
from .person import Person
class Member(Person):
__tablename__ = "members"
# La clé primaire est aussi une clé étrangère vers la table parente
id: Mapped[int] = mapped_column(ForeignKey("persons.id"), primary_key=True)
# Champs spécifiques à Member
login: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
password: Mapped[str] = mapped_column(String(255), nullable=False)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
# La relation vers Opinion
opinions: Mapped[List["Opinion"]] = relationship(back_populates="member")
__mapper_args__ = {
"polymorphic_identity": "member",
}
def __repr__(self) -> str:
return f"Member(id={self.id}, login='{self.login}')"

36
src/app/models/movie.py Normal file
View File

@@ -0,0 +1,36 @@
from sqlalchemy import (
Integer,
String,
ForeignKey,
Text
)
from sqlalchemy.orm import (
Mapped,
mapped_column,
relationship
)
from typing import List
from .base_class import Base
from .movie_actors import movie_actors_association_table
class Movie(Base):
__tablename__ = "movies"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(250), nullable=False)
year: Mapped[int] = mapped_column(Integer)
duration: Mapped[int] = mapped_column(Integer)
synopsis: Mapped[str] = mapped_column(Text)
director_id: Mapped[int] = mapped_column(ForeignKey("participants.id"))
genre_id: Mapped[int] = mapped_column(ForeignKey("genres.id"))
director: Mapped["Participant"] = relationship(foreign_keys=[director_id])
genre: Mapped["Genre"] = relationship()
actors: Mapped[List["Participant"]] = relationship(
secondary=movie_actors_association_table
)
opinions: Mapped[List["Opinion"]] = relationship(
back_populates="movie", cascade="all, delete-orphan"
)

View File

@@ -0,0 +1,10 @@
from sqlalchemy import Column, ForeignKey, Table
from .base_class import Base
# Table d'association pour la relation Many-to-Many entre Film et Acteur
movie_actors_association_table = Table(
"movie_actors_association",
Base.metadata,
Column("movie_id", ForeignKey("movies.id"), primary_key=True),
Column("participant_id", ForeignKey("participants.id"), primary_key=True),
)

23
src/app/models/opinion.py Normal file
View File

@@ -0,0 +1,23 @@
from sqlalchemy import (
Integer,
ForeignKey,
Text
)
from sqlalchemy.orm import (
Mapped,
mapped_column,
relationship
)
from .base_class import Base
class Opinion(Base):
__tablename__ = "opinions"
id: Mapped[int] = mapped_column(primary_key=True)
note: Mapped[int] = mapped_column(Integer)
comment: Mapped[str] = mapped_column(Text)
member_id: Mapped[int] = mapped_column(ForeignKey("members.id"))
movie_id: Mapped[int] = mapped_column(ForeignKey("movies.id"))
member: Mapped["Member"] = relationship(back_populates="opinions")
movie: Mapped["Movie"] = relationship(back_populates="opinions")

View File

@@ -0,0 +1,21 @@
from sqlalchemy import (
ForeignKey,
)
from sqlalchemy.orm import (
Mapped,
mapped_column
)
from .person import Person
class Participant(Person):
__tablename__ = "participants"
# La clé primaire est aussi une clé étrangère vers la table parente
id: Mapped[int] = mapped_column(ForeignKey("persons.id"), primary_key=True)
__mapper_args__ = {
"polymorphic_identity": "participant",
}
def __repr__(self) -> str:
return f"Participant(id={self.id}, name='{self.first_name} {self.last_name}')"

21
src/app/models/person.py Normal file
View File

@@ -0,0 +1,21 @@
from sqlalchemy import (
String,
)
from sqlalchemy.orm import (
Mapped,
mapped_column
)
from .base_class import Base
class Person(Base):
__tablename__ = "persons"
id: Mapped[int] = mapped_column(primary_key=True)
last_name: Mapped[str] = mapped_column(String(255))
first_name: Mapped[str] = mapped_column(String(255))
# colonne discriminante pour la hiérarchie d'héritage
type: Mapped[str] = mapped_column(String(50))
__mapper_args__ = {
"polymorphic_identity": "person",
"polymorphic_on": "type",
}

View File

View File

@@ -0,0 +1,14 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
import app.models.genre as models
from sqlalchemy.exc import SQLAlchemyError
from app.core.exceptions import DALException
async def get_genres(db: AsyncSession):
"""Récupère tous les genres de la base de données."""
try:
stmt = select(models.Genre)
result = await db.execute(stmt)
return result.scalars().all()
except SQLAlchemyError as e:
raise DALException("Erreur lors de la récupération des genres", original_exception=e)

View File

@@ -0,0 +1,13 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.models.member import Member
from sqlalchemy.exc import SQLAlchemyError
from app.core.exceptions import DALException
async def get_member(db: AsyncSession, member_id: int):
"""Récupère un membre par son ID."""
try:
result = await db.execute(select(Member).where(Member.id == member_id))
return result.scalar_one_or_none()
except SQLAlchemyError as e:
raise DALException(f"Erreur lors de la récupération du membre {member_id}", original_exception=e)

View File

@@ -0,0 +1,78 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from sqlalchemy.exc import SQLAlchemyError
from app.core.exceptions import DALException
from app.models.movie import Movie
from app.models.opinion import Opinion
import app.schemas.movie as schemas
import app.models.person as person_models
async def get_movie(db: AsyncSession, movie_id: int):
"""Récupère un film par son ID avec ses relations."""
try:
stmt = (
select(Movie)
.where(Movie.id == movie_id)
.options(
selectinload(Movie.genre),
selectinload(Movie.director),
selectinload(Movie.actors),
selectinload(Movie.opinions),
selectinload(Movie.opinions).selectinload(Opinion.member)
)
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
except SQLAlchemyError as e:
raise DALException(f"Erreur lors de la récupération du film {movie_id}", original_exception=e)
async def get_movies(db: AsyncSession, skip: int = 0, limit: int = 100):
"""Récupère une liste de films avec leurs relations principales."""
try:
stmt = (
select(Movie)
.offset(skip).limit(limit)
.options(
selectinload(Movie.genre),
selectinload(Movie.director),
selectinload(Movie.actors),
selectinload(Movie.opinions),
selectinload(Movie.opinions).selectinload(Opinion.member)
)
.order_by(Movie.title)
)
result = await db.execute(stmt)
return result.scalars().all()
except SQLAlchemyError as e:
raise DALException("Erreur lors de la récupération des films", original_exception=e)
async def create_movie(db: AsyncSession, movie: schemas.MovieCreate):
"""Crée un nouveau film."""
try:
# Récupérer les objets acteurs à partir de leurs IDs
actors_result = await db.execute(
select(person_models.Person).where(person_models.Person.id.in_(movie.actors_ids))
)
actors = actors_result.scalars().all()
# Créer l'instance du film
db_movie = Movie(
title=movie.title,
year=movie.year,
duration=movie.duration,
synopsis=movie.synopsis,
genre_id=movie.genre_id,
director_id=movie.director_id,
actors=actors
)
db.add(db_movie)
await db.commit()
# Recharger les relations pour les retourner dans la réponse
await db.refresh(db_movie)
return await get_movie(db, db_movie.id)
except SQLAlchemyError as e:
await db.rollback() # IMPORTANT: annuler la transaction en cas d'erreur
raise DALException("Erreur lors de la création du film", original_exception=e)

View File

@@ -0,0 +1,60 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
import app.schemas.opinion as schemas
import app.models.opinion as models
from sqlalchemy.exc import SQLAlchemyError
from app.core.exceptions import DALException
async def create_opinion_for_movie(db: AsyncSession, opinion: schemas.OpinionCreate, movie_id: int):
"""Crée un avis pour un film donné."""
try:
db_opinion = models.Opinion(**opinion.model_dump(), movie_id=movie_id)
db.add(db_opinion)
await db.commit()
await db.refresh(db_opinion)
query = (
select(models.Opinion)
.where(models.Opinion.id == db_opinion.id)
.options(
selectinload(models.Opinion.member)
)
)
result = await db.execute(query)
return result.scalars().one()
except SQLAlchemyError as e:
await db.rollback() # IMPORTANT: annuler la transaction en cas d'erreur
raise DALException("Erreur lors de la création de l'avis", original_exception=e)
async def get_opinion(db: AsyncSession, opinion_id: int):
"""Récupère un avis par son ID."""
try:
stmt = select(models.Opinion).where(models.Opinion.id == opinion_id)
result = await db.execute(stmt)
return result.scalar_one_or_none()
except SQLAlchemyError as e:
raise DALException(f"Erreur lors de la récupération de l'avis {opinion_id}", original_exception=e)
async def delete_opinion_by_id(db: AsyncSession, opinion_id: int):
"""Supprime un avis de la base de données."""
try:
db_opinion = await get_opinion(db, opinion_id=opinion_id)
if db_opinion:
await db.delete(db_opinion)
await db.commit()
return db_opinion
except SQLAlchemyError as e:
await db.rollback() # IMPORTANT: annuler la transaction en cas d'erreur
raise DALException(f"Erreur lors de la suppression de l'avis {opinion_id}", original_exception=e)
async def delete_opinion(db: AsyncSession, db_opinion: models.Opinion):
"""Supprime un avis de la base de données."""
try:
if db_opinion:
await db.delete(db_opinion)
await db.commit()
except SQLAlchemyError as e:
await db.rollback() # IMPORTANT: annuler la transaction en cas d'erreur
raise DALException(f"Erreur lors de la suppression de l'avis {db_opinion.id}", original_exception=e)

View File

@@ -0,0 +1,60 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.models.participant import Participant as ParticipantModel
from app.schemas.participant import ParticipantCreate, ParticipantUpdate
from sqlalchemy.exc import SQLAlchemyError
from app.core.exceptions import DALException
async def get_participant(db: AsyncSession, participant_id: int):
"""Récupère un participant par son ID."""
try:
result = await db.execute(select(ParticipantModel).where(ParticipantModel.id == participant_id))
return result.scalar_one_or_none()
except SQLAlchemyError as e:
raise DALException(f"Erreur lors de la récupération du participant {participant_id}", original_exception=e)
async def get_participants(db: AsyncSession):
"""Récupère tous les participants de la base de données."""
try:
result = await db.execute(select(ParticipantModel).order_by(ParticipantModel.last_name))
return result.scalars().all()
except SQLAlchemyError as e:
raise DALException("Erreur lors de la récupération des participants", original_exception=e)
async def create_participant(db: AsyncSession, participant: ParticipantCreate) -> ParticipantModel:
"""Crée un nouveau participant."""
try:
db_participant = ParticipantModel(
first_name=participant.first_name,
last_name=participant.last_name
)
db.add(db_participant)
await db.commit()
await db.refresh(db_participant)
return db_participant
except SQLAlchemyError as e:
await db.rollback() # IMPORTANT: annuler la transaction en cas d'erreur
raise DALException("Erreur lors de la création du participant", original_exception=e)
async def update_participant(
db: AsyncSession, participant_id: int, participant_data: ParticipantUpdate
) -> ParticipantModel:
"""Met à jour les informations d'un participant existant."""
try:
db_participant = await get_participant(db, participant_id)
if not db_participant:
return None
# Met à jour les champs si la valeur n'est pas None
update_data = participant_data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(db_participant, key, value)
await db.commit()
await db.refresh(db_participant)
return db_participant
except SQLAlchemyError as e:
await db.rollback()
raise DALException(f"Erreur lors de la mise à jour du participant {participant_id}", original_exception=e)

View File

13
src/app/schemas/genre.py Normal file
View File

@@ -0,0 +1,13 @@
from pydantic import BaseModel, ConfigDict
class GenreBase(BaseModel):
label: str
class GenreCreate(GenreBase):
pass
class GenreRead(GenreBase):
id: int
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,9 @@
from pydantic import BaseModel, ConfigDict
class MemberBase(BaseModel):
login: str
class MemberRead(MemberBase):
id: int
model_config = ConfigDict(from_attributes=True)

29
src/app/schemas/movie.py Normal file
View File

@@ -0,0 +1,29 @@
from pydantic import BaseModel, ConfigDict
from typing import List, Optional
from .person import PersonRead
from .genre import GenreRead
from .opinion import OpinionRead
class MovieBase(BaseModel):
title: str
year: int
duration: Optional[int] = None
synopsis: Optional[str] = None
# Schéma pour la création : on utilise les IDs pour les relations
class MovieCreate(MovieBase):
genre_id: int
director_id: int
actors_ids: List[int] = []
# Schéma complet pour la lecture : on imbrique les objets complets
class MovieRead(MovieBase):
id: int
genre: GenreRead
director: PersonRead
actors: List[PersonRead] = []
opinions: List[OpinionRead] = []
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,22 @@
from pydantic import BaseModel, ConfigDict
from app.schemas.member import MemberRead
class OpinionBase(BaseModel):
note: int
comment: str
class OpinionCreate(OpinionBase):
member_id: int
# movie_id n'est pas nécessaire ici car il vient déjà de l'URL
pass
class OpinionRead(OpinionBase):
id: int
movie_id: int
member: MemberRead
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,23 @@
from pydantic import BaseModel, ConfigDict
from typing import Optional
from .person import PersonBase
# Schéma pour la création d'un Participant
class ParticipantCreate(PersonBase):
pass
# Schéma pour la mise à jour d'un Participant
class ParticipantUpdate(BaseModel):
# Les champs sont optionnels pour permettre des mises à jour partielles (PATCH)
last_name: Optional[str] = None
first_name: Optional[str] = None
model_config = ConfigDict(extra="forbid") # Empêche l'ajout de champs non définis
# Schéma complet pour la lecture d'un Participant
class ParticipantRead(PersonBase):
id: int
model_config = ConfigDict(from_attributes=True)

15
src/app/schemas/person.py Normal file
View File

@@ -0,0 +1,15 @@
from pydantic import BaseModel, ConfigDict
from typing import Optional
class PersonBase(BaseModel):
last_name: str
first_name: Optional[str] = None
class PersonCreate(PersonBase):
pass
class PersonRead(PersonBase):
id: int
model_config = ConfigDict(from_attributes=True)

View File

View File

@@ -0,0 +1,9 @@
from typing import List
from sqlalchemy.ext.asyncio import AsyncSession
import app.repositories.genre as genre_repository
import app.models.genre as genre_models
async def get_genres(db: AsyncSession) -> List[genre_models.Genre]:
"""Service pour récupérer une liste de genres."""
return await genre_repository.get_genres(db)

View File

@@ -0,0 +1,11 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.repositories import member as member_repository
from app.models.member import Member as MemberModel
from app.core.exceptions import NotFoundBLLException
async def get_member_by_id(db: AsyncSession, member_id: int) -> MemberModel:
"""Service pour récupérer un membre par son ID."""
db_member = await member_repository.get_member(db, member_id=member_id)
if db_member is None:
raise NotFoundBLLException(resource_name="Membre", resource_id=member_id)
return db_member

43
src/app/services/movie.py Normal file
View File

@@ -0,0 +1,43 @@
import datetime
from typing import List
from sqlalchemy.ext.asyncio import AsyncSession
import app.repositories.movie as movie_repository
import app.schemas.movie as movie_schemas
import app.models.movie as movie_models
from app.core.exceptions import NotFoundBLLException, ValidationBLLException
async def get_movies(db: AsyncSession, skip: int, limit: int) -> List[movie_models.Movie]:
"""Service pour récupérer une liste de films."""
# Actuellement un simple relais, mais la logique complexe (filtres, etc.) irait ici.
return await movie_repository.get_movies(db, skip=skip, limit=limit)
async def get_movie_by_id(db: AsyncSession, movie_id: int) -> movie_models.Movie:
"""Service pour récupérer un film par son ID."""
db_movie = await movie_repository.get_movie(db, movie_id=movie_id)
if db_movie is None:
# Utiliser notre exception métier, pas une exception HTTP, pour des raisons de séparation des préoccupations.
raise NotFoundBLLException(resource_name="Film", resource_id=movie_id)
return db_movie
async def create_movie(db: AsyncSession, movie: movie_schemas.MovieCreate) -> movie_models.Movie:
"""Service pour créer un nouveau film."""
# Règle métier 1 : le titre ne peut pas être vide
if not movie.title or not movie.title.strip():
raise ValidationBLLException("Le titre du film ne peut pas être vide.")
# Règle métier 2 : l'année doit être réaliste
current_year = datetime.date.today().year
if not (1888 <= movie.year <= current_year + 5):
raise ValidationBLLException(f"L'année du film doit être comprise entre 1888 et {current_year + 5}.")
# Règle métier 3 : valider l'existence des entités liées
# Ici, on pourrait aussi vérifier que genre_id, director_id et actors_ids existent
# en appelant leurs services/repositories respectifs pour une validation complète.
# await genre_service.get_genre_by_id(db, movie.genre_id)
# await participant_service.get_participant_by_id(db, movie.director_id)
# await participant_service.check_participants_ids(db, movie.actors_ids)
return await movie_repository.create_movie(db=db, movie=movie)

View File

@@ -0,0 +1,40 @@
from sqlalchemy.ext.asyncio import AsyncSession
import app.services.movie as movie_service
import app.services.member as member_service
import app.repositories.opinion as opinion_repository
import app.schemas.opinion as opinion_schemas
import app.models.opinion as opinion_models
from app.core.exceptions import NotFoundBLLException, ValidationBLLException
async def create_opinion(
db: AsyncSession, *, movie_id: int, opinion: opinion_schemas.OpinionCreate
) -> opinion_models.Opinion:
"""
Service pour créer un avis pour un film.
Contient la logique métier : vérifier que le film existe et que la note est valide.
"""
# Règle métier 1 : On ne peut pas noter un film qui n'existe pas.
# On utilise le service movie qui lève déjà une NotFoundError propre.
await movie_service.get_movie_by_id(db, movie_id=movie_id)
# Règle métier 2 : L'auteur de l'avis (Membre) doit exister.
await member_service.get_member_by_id(db, member_id=opinion.member_id)
# Règle métier 3 : La note doit être dans un intervalle valide (ex: 0 à 5)
if not (0 <= opinion.note <= 5):
raise ValidationBLLException("La note doit être comprise entre 0 et 5.")
# Appel au repositories pour la création pure
return await opinion_repository.create_opinion_for_movie(db=db, opinion=opinion, movie_id=movie_id)
async def delete_opinion(db: AsyncSession, opinion_id: int) -> opinion_models.Opinion:
"""Service pour supprimer un avis."""
db_opinion = await opinion_repository.get_opinion(db, opinion_id=opinion_id)
if db_opinion is None:
raise NotFoundBLLException(resource_name="Avis", resource_id=opinion_id)
await opinion_repository.delete_opinion(db, db_opinion=db_opinion)
return db_opinion

View File

@@ -0,0 +1,23 @@
from typing import List
from sqlalchemy.ext.asyncio import AsyncSession
from app.repositories import participant as participant_repository
from app.schemas.participant import ParticipantCreate, ParticipantUpdate
from app.models.participant import Participant as ParticipantModel
from app.core.exceptions import NotFoundBLLException
async def get_participants(db: AsyncSession) -> List[ParticipantModel]:
"""Service pour récupérer une liste de participants."""
return await participant_repository.get_participants(db)
async def create_participant(db: AsyncSession, participant: ParticipantCreate) -> ParticipantModel:
"""Service pour créer un nouveau participant."""
return await participant_repository.create_participant(db, participant=participant)
async def update_participant(
db: AsyncSession, participant_id: int, participant_data: ParticipantUpdate
) -> ParticipantModel:
"""Service pour mettre à jour un participant."""
db_participant = await participant_repository.update_participant(db, participant_id, participant_data)
if db_participant is None:
raise NotFoundBLLException(resource_name="Participant", resource_id=participant_id)
return db_participant

BIN
src/local_dev.db Normal file

Binary file not shown.