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

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/.idea

484
README.md Normal file
View File

@@ -0,0 +1,484 @@
# TP : Filmothèque (API REST)
## Informations générales
**Cours** : Python Avancé > REST API > Fast API \
**Objectifs pédagogiques** :
- Créer une application backend basée sur FastAPI.
- Structurer une application en couches (API/Routeurs, BLL/Services, DAL/Repository).
- Définir des schémas de données avec Pydantic pour la validation et la sérialisation.
- Interagir avec une base de données en asynchrone grâce à SQLAlchemy.
- Mettre en place un système d'injection de dépendances (pour la session BDD).
- Gérer les erreurs de manière centralisée avec des gestionnaires d'exceptions personnalisés, en particulier pour DAL et BLL
- Tests : pytest
- Outils modernes et performants (poetry, PyCharm, FastAPI, SQLAlchemy 2.x)
- Bonnes pratiques de l'entreprise
---
## Prérequis
### Connaissances préalables
* Connaissances de base en programmation.
* Application du modèle en couches (API, BLL, DAL).
### Architecture en couches
La pile applicative que nous allons construire suit une séparation claire des responsabilités :
```mermaid
graph TD
A[API / Routeurs] -- "Appelle le" --> B[Services / BLL];
B -- "Appelle le" --> C[Repository / DAL];
C -- "Utilise l'" --> D[ORM / SQLAlchemy];
D -- "Dialogue avec la" --> E[Base de donnees];
```
* **API / Routeurs** : la porte d'entrée HTTP.
* **Services / BLL** : la logique métier (Business Logic Layer).
* **Repository / DAL** : l'abstraction de la persistance (Data Access Layer).
* **ORM / SQLAlchemy** : les modèles et la session qui parlent à la BDD.
### Installation et configuration de lenvironnement
- Installation des dépendances : faire `poetry install` (fichier lock déja présent)
- À ce stade, si vos imports ne sont pas reconnus dans l'IDE PyCharm à l'ouverture d'un fichier (par exemple `app/main.py`), marquer le répertoire `src` comme `Sources Root` (Clic droit sur répertoire `src`, puis `Mark Directory as ... > Sources Root`) ou redémarrer l'IDE (File > Invalidate Caches... > Just restart à gauche).
- Par défaut, vous travaillerez avec une base de données locale `sqlite`. C'est fortement recommandé pour gagner du temps !
### Lancement du projet
Une fois le projet cloné, se positionner dans le répertoire racine du projet (là où se trouve le fichier `pyproject.toml`).
Faire clic droit puis `Settings` sur l'onglet du terminal local Pycharm, puis définir la variable d'environnement `BACKEND_CORS_ORIGINS`, en prévision de l'accès à ce micro service depuis un front Angular :
- `BACKEND_CORS_ORIGINS=["http://localhost:4200"]`)
Ouvrir un (nouvel onglet) terminal local Pycharm, puis lancer le projet via :
- `cd src`
- `uvicorn app.main:app --host 0.0.0.0 --port 8000`
Les URLs suivantes devront alors être accessibles, selon vos avancées sur le projet :
- http://127.0.0.1:8000/ -> retourne "welcome to this fantastic API"
- http://127.0.0.1:8000/api/v1/movies/ -> retourne une liste de films, éventuellement vide
Notez qu'un pré-remplissage de la base est possible via le fichier `app/db/seeding.py`.
Si vous souhaitez réinitialiser la base, vous pouvez supprimer le fichier `local_dev.db` à la racine de votre projet, en prenant soin de décocher l'option `safe delete` (pas de refactor nécessaire à cet effet).
---
## Énoncé
### Modèle logique : schéma UML
Vous trouverez ci-dessous pour information le schéma UML de la base de données.
![Modèle logique UML](./resources/tp_fastapi_uml.png)
### Structure du projet
La structure du projet `tp_fastapi` est la suivante :
```
tp_fastapi/
src/
├── app/
│ ├── api/
│ │ ├── __init__.py
│ │ ├── deps.py
│ │ ├── exception_handlers.py
│ │ └── routers/ # la porte d'entrée HTTP
│ │ ├── __init__.py
│ │ ├── genres.py
│ │ ├── movies.py
│ │ ├── opinions.py
│ │ └── participants.py
│ ├── core/
│ │ ├── __init__.py
│ │ ├── config.py
│ │ └── exceptions.py
│ ├── db/
│ │ ├── __init__.py
│ │ └── session.py
│ ├── models/ # les Business Objects (BO), qui sont également des entités pour l'ORM SQLAlchemy
│ │ ├── __init__.py
│ │ ├── base_class.py
│ │ ├── genre.py
│ │ ├── member.py
│ │ ├── movie.py
│ │ ├── movie_actors.py
│ │ ├── opinion.py
│ │ ├── participant.py
│ │ └── person.py
│ ├── repository/ # l'abstraction de la persistance (Data Access Layer)
│ │ ├── __init__.py
│ │ ├── genre.py
│ │ ├── movie.py
│ │ ├── opinion.py
│ │ └── participant.py
│ ├── schemas/ # objets manipulés lors des requêtes/réponses HTTP
│ │ ├── __init__.py
│ │ ├── genre.py
│ │ ├── movie.py
│ │ ├── opinion.py
│ │ ├── participant.py
│ │ └── person.py
│ ├── services/ # la logique métier (Business Logic Layer)
│ │ ├── __init__.py
│ │ ├── genre.py
│ │ ├── movie.py
│ │ ├── opinion.py
│ │ └── participant.py
│ ├── __init__.py
│ └── main.py
```
## Étape 1 : les fondations - modèles de données ORM
L'Object-Relational Mapping (ORM) est la couche qui traduit nos objets Python en tables de base de données.
C'est une partie importante, mais complexe.
Pour que vous puissiez vous concentrer sur l'architecture de l'API, **le code de ce répertoire `app/models/` vous est intégralement fourni**.
Votre mission est de **créer les fichiers et d'y copier le code ci-dessous**. Prenez le temps de lire et de comprendre les relations définies :
* `Person` est une classe de base utilisant l'**héritage** pour définir `Participant` (acteur/réalisateur) et `Member` (utilisateur).
* `Movie` a une relation **One-to-Many** avec `Opinion` (un film peut avoir plusieurs avis).
* `Movie` a une relation **Many-to-One** avec `Genre` et `Participant` (pour le réalisateur).
* `Movie` et `Participant` (pour les acteurs) ont une relation **Many-to-Many** via une table d'association.
#### Fichier : `app/models/base_class.py`
```python
from sqlalchemy.orm import declarative_base
Base = declarative_base()
```
#### Fichier : `app/models/person.py`
```python
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",
}
```
#### Fichier : `app/models/participant.py`
```python
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from .person import Person
class Participant(Person):
__tablename__ = "participants"
id: Mapped[int] = mapped_column(ForeignKey("persons.id"), primary_key=True)
__mapper_args__ = {"polymorphic_identity": "participant"}
```
#### Fichier : `app/models/genre.py`
```python
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))
```
#### Fichier : `app/models/movie_actors.py`
```python
from sqlalchemy import Column, ForeignKey, Table
from .base_class import Base
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),
)
```
#### Fichier : `app/models/movie.py`
```python
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")
```
#### Fichier : `app/models/opinion.py`
```python
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")
```
#### Fichier : `app/models/member.py`
```python
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}')"
```
#### Fichier : `app/models/__init__.py`
Pensez également à **importer toutes les classes dans `app/models/__init__.py`** pour faciliter les imports ailleurs dans le projet :
```python
# app/models/__init__.py
# 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
```
**Astuce :** lorsque vos fichiers sont prêts, et que vous aurez lancé l'application une première fois, vous pourrez désactiver la drop/création automatique des tables dans `main.py` (mettre en commentaire `await conn.run_sync(Base.metadata.drop_all)`).
---
## Étape 2 : les schémas de données avec Pydantic
Les schémas Pydantic définissent la "forme" des données que notre API attend en entrée et renvoie en sortie. Ils assurent une validation robuste et automatique.
Votre mission : **Créez les fichiers dans `app/schemas/`** et écrivez les classes Pydantic. Une bonne pratique est de créer :
* Une classe `Base` (champs communs).
* Une classe `Create` (champs requis pour la création).
* Une classe `Read` (pour la lecture, avec les `id` et les relations).
**Exemple pour `genre.py` :**
```python
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)
```
Inspirez-vous de cet exemple et des modèles ORM pour créer les schémas pour `Person`, `Participant`, `Opinion` et `Movie`.
**Astuce pour `Movie` :** Le schéma de création `MovieCreate` attendra des IDs (`director_id`, `actors_ids`), tandis que le schéma de lecture `MovieRead` renverra les objets imbriqués (`director: Person`, `actors: List[Person]`).
Pour valider votre travail, vous pouvez lancer le test suivant, à la racine du projet : `poetry run pytest tests/enonce/etape2_test_schemas.py`.
---
## Étape 3 : la couche d'accès aux données (DAL / Repository)
Dans la couche DAL (Data Access Layer), le Repository est responsable de toutes les requêtes à la base de données. C'est ici que vous écrirez vos requêtes SQLAlchemy. L'objectif est de cacher la complexité de l'ORM au reste de l'application.
Votre mission : **Complétez les fonctions dans les fichiers du répertoire `app/repositories/`**.
Toutes les fonctions recevront une session `db: AsyncSession` en paramètre.
**Exemple pour `repositories\movie.py` :**
* **`get_movie(db, movie_id)`** : Doit récupérer un film par son ID. Pensez à utiliser `selectinload` pour charger ses relations (genre, réalisateur, acteurs) de manière efficace et éviter le problème N+1.
* **`get_movies(db, skip, limit)`** : Récupère une liste de films paginée.
* **`create_movie(db, movie)`** : Crée une nouvelle instance `Movie`, l'ajoute à la session et la commit.
**Important** : entourez vos requêtes d'un bloc `try...except SQLAlchemyError` et levez une `DALException` personnalisée en cas d'erreur.
Pour valider votre travail, vous pouvez lancer le test suivant, à la racine du projet : `poetry run pytest tests/enonce/etape3_test_repositories.py`.
---
## Étape 4 : la logique métier (BLL / Services)
Le Service est le cœur de votre logique. Il orchestre les appels au Repository et applique les règles métier. C'est lui qui décide si une action est valide ou non.
Votre mission : **Implémentez la logique dans les fichiers du répertoire `app/services/`**.
**Exemple pour `services\movie.py` :**
* Dans `create_movie`, avant d'appeler le repository, **ajoutez des vérifications** :
1. Le titre du film ne doit pas être vide.
2. L'année de sortie doit être réaliste (ex: entre 1888 et aujourd'hui + 5 ans).
3. Si une de ces règles n'est pas respectée, levez une `ValidationBLLException`.
* Dans `get_movie_by_id`, si le repository ne retourne aucun film, levez une `NotFoundBLLException`.
**Principe clé** : Un service ne doit jamais manipuler directement des exceptions HTTP (`HTTPException`). Il utilise des exceptions métier personnalisées (`BLLException`, `NotFoundBLLException`, etc.) pour rester indépendant du framework web.
Pour valider votre travail, vous pouvez lancer le test suivant, à la racine du projet : `poetry run pytest tests/enonce/etape4_test_services.py`.
---
## Étape 5 : la couche API (Routers)
C'est ici que tout se connecte ! Les routeurs FastAPI définissent les endpoints de votre API, reçoivent les requêtes HTTP, appellent les services appropriés et retournent les réponses.
Votre mission : **Créez les endpoints dans les fichiers du répertoire `app/api/routers/`**.
* Utilisez les décorateurs de FastAPI (`@router.get`, `@router.post`, etc.).
* Utilisez `response_model` pour spécifier le schéma Pydantic de la réponse.
* Utilisez `status_code` pour définir le code de statut HTTP approprié (ex: `201 CREATED`).
* Injectez la session de base de données en utilisant `db: AsyncSession = Depends(get_db)`.
* Appelez la fonction de service correspondante et retournez son résultat.
Pour valider votre travail, vous pouvez lancer le test suivant, à la racine du projet : `poetry run pytest tests/enonce/etape5_test_api.py`.
---
## Étape 6 : l'assemblage final et la gestion des erreurs
La dernière étape consiste à configurer l'application FastAPI principale, à connecter les routeurs et à mettre en place la gestion centralisée des exceptions.
Votre mission : **Complétez le fichier `app/main.py` et observez `app/api/exception_handlers.py`**.
1. Dans `main.py` :
* Créez l'instance `FastAPI`, si ce n'est pas déja fait.
* Utilisez `app.include_router()` pour ajouter chaque routeur que vous avez créé.
* Utilisez `@app.add_exception_handler()` pour lier vos exceptions métier personnalisées (`NotFoundBLLException`, `ValidationBLLException`, `DALException`, `BLLException`) à des fonctions de gestion.
2. Dans `app/api/exception_handlers.py`, observez les points suivants :
* Les fonctions `async def` (ex: `not_found_bll_exception_handler`) qui prennent `Request` et votre exception en paramètres. À quoi servent-elles ?
* Ces fonctions doivent retourner une `JSONResponse` avec le code de statut HTTP adéquat (`404`, `400`, `500`) et un message d'erreur clair dans le contenu. Pourquoi c'est une bonne pratique avec REST de retourner un code status HTTP approprié ? Est-ce que le code en question sépare les erreurs techniques des erreurs fonctionnelles ?
Le schéma ci-dessous illustre comment les exceptions personnalisées sont levées par les couches internes (DAL, BLL) et interceptées par les gestionnaires centraux (handlers) définis dans `main.py` pour produire une réponse HTTP propre.
```mermaid
graph TD
subgraph "Flux de requete"
A[Requete HTTP] --> B[Endpoint API]
B -- Appelle --> C(Service BLL)
C -- Appelle --> D(Repository DAL)
end
subgraph "Flux d'exception"
D -- Leve --> E{DALException}
C -- Leve --> F{NotFoundBLLException}
C -- Leve --> G{ValidationBLLException}
H[main.py: @app.add_exception_handler] -- Enregistre --> I(handler_dal)
H -- Enregistre --> J(handler_not_found)
H -- Enregistre --> K(handler_validation)
E -.-> I
F -.-> J
G -.-> K
I -- Genere --> L[JSONResponse 500]
J -- Genere --> M[JSONResponse 404]
K -- Genere --> N[JSONResponse 400]
end
```
Une fois terminé, lancez votre application avec `uvicorn app.main:app --reload` (depuis le dossier `src/`) et explorez la documentation interactive sur `http://127.0.0.1:8000/docs`.
-----
## Aller plus loin (bonus)
* Vérifier que l'intégralité des tests passent. Se positionner à la racine du projet (avant le src), puis lancer les tests unitaires via :
- `poetry run pytest`
- `poetry run pytest -vv` (avec davantage d'éléments de debug)
* Implémentez-les endpoints `UPDATE` et `DELETE` pour les participants et les films.
* Ajoutez des filtres à la route `GET /movies/` (par année, par genre, etc.).
* Lancer les tests unitaires suivants : `poetry run pytest tests/api/test_movies_api.py` et `poetry run pytest tests/services/test_movie_service.py`
* Écrivez d'autres tests unitaires et d'intégration avec `pytest`.
* Mettre en œuvre une véritable base de données MySQL :
Si vous souhaitez établir une connexion avec une véritable base de données telle que MySQL, sachez que le package `asyncmy` sera requis (`poetry add asyncmy@^0.2.10`), ainsi qu'au préalable sous Windows, l'installation des redistributables Visual Studio 2022 (via l'outil vsBuildTools dont le binaire et la documentation sont transmis dans votre `00_Install_Pack.zip`).
```
La base doit être créée au préalable (exemple en root) :
CREATE DATABASE filmotheque CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
Un utilisateur filmotheque (mdp : filmotheque) doit être créé avec les droits sur cette base.
```
Faire clic droit puis Settings sur l'onglet du terminal local Pycharm, puis définir la variable d'environnement DATABASE_URL, contenant l'URL de connexion MySQL adéquate :
`DATABASE_URL=mysql+asyncmy://filmotheque:filmotheque@127.0.0.1:3306/filmotheque`
Fermez l'onglet du terminal, puis en ouvrir un autre, afin que les modifications soient effectives

1280
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

64
pyproject.toml Normal file
View File

@@ -0,0 +1,64 @@
[project]
name = "tp-fastapi"
version = "0.1.0"
description = "TP FastAPI"
authors = [
{name = "Your Name",email = "you@example.com"}
]
readme = "README.md"
classifiers = [
"Programming Language :: Python :: 3.13",
]
keywords = ["fastapi", "web"]
exclude = [
{ path = "tests", format = "wheel" }
]
requires-python = ">=3.13"
[tool.poetry]
package-mode = false
[tool.poetry.dependencies]
python = "^3.13"
fastapi = "^0.116.1"
uvicorn = { version = "^0.35.0", extras = [ "standard" ] }
gunicorn = "^23.0.0"
sqlalchemy = {extras = ["asyncio"], version = "^2.0.42"}
pydantic = {extras = ["email"], version = "^2.11.7"}
python-dotenv = "^1.0.1"
pydantic-settings = "^2.10.1"
aiosqlite = "^0.21.0"
[tool.poetry.group.dev.dependencies]
pytest = "^8.4.1"
pytest-cov = "^6.2.1"
pytest-asyncio = "^1.1.0"
pytest-mock = "^3.14.1"
httpx = "^0.28.1"
aiosqlite = "^0.21.0"
coverage = { version="*", extras=["toml"]}
[tool.pytest.ini_options]
asyncio_mode = "auto"
pythonpath = "src"
testpaths = "tests"
addopts = "-v -s"
[tool.black]
line-length = 120
[tool.pycln]
all = true
[tool.isort]
line_length = 120
multi_line_output = 3
include_trailing_comma = true
force_grid_wrap = 0
use_parentheses = true
ensure_newline_before_comments = true
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"

167
resources/tp_fastapi.drawio Normal file
View File

@@ -0,0 +1,167 @@
<mxfile host="app.diagrams.net" agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" version="28.0.7">
<diagram name="Page-1" id="yGmwfDGalRhBf6r4yIZM">
<mxGraphModel dx="1224" dy="1136" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-2" value="Person" style="swimlane;fontStyle=0;childLayout=stackLayout;horizontal=1;startSize=26;fillColor=none;horizontalStack=0;resizeParent=1;resizeParentMax=0;resizeLast=0;collapsible=1;marginBottom=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="370" y="100" width="140" height="104" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-3" value="+ id: int" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;whiteSpace=wrap;html=1;" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-2">
<mxGeometry y="26" width="140" height="26" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-4" value="+ last_name: str" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;whiteSpace=wrap;html=1;" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-2">
<mxGeometry y="52" width="140" height="26" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-5" value="+ first_name: str" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;whiteSpace=wrap;html=1;" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-2">
<mxGeometry y="78" width="140" height="26" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-6" value="Participant" style="swimlane;fontStyle=0;childLayout=stackLayout;horizontal=1;startSize=26;fillColor=none;horizontalStack=0;resizeParent=1;resizeParentMax=0;resizeLast=0;collapsible=1;marginBottom=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="180" y="270" width="140" height="52" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-10" value="Extends" style="endArrow=block;endSize=16;endFill=0;html=1;rounded=0;exitX=0.5;exitY=0;exitDx=0;exitDy=0;" edge="1" parent="1" source="Qx_BBq6HcmvMDlrlQHnB-6">
<mxGeometry width="160" relative="1" as="geometry">
<mxPoint x="330" y="510" as="sourcePoint" />
<mxPoint x="430" y="210" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-11" value="Member" style="swimlane;fontStyle=0;childLayout=stackLayout;horizontal=1;startSize=26;fillColor=none;horizontalStack=0;resizeParent=1;resizeParentMax=0;resizeLast=0;collapsible=1;marginBottom=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="640" y="260" width="140" height="104" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-12" value="+ login: str" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;whiteSpace=wrap;html=1;" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-11">
<mxGeometry y="26" width="140" height="26" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-13" value="+ password: str" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;whiteSpace=wrap;html=1;" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-11">
<mxGeometry y="52" width="140" height="26" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-14" value="+ is_admin: bool" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;whiteSpace=wrap;html=1;" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-11">
<mxGeometry y="78" width="140" height="26" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-15" value="Extends" style="endArrow=block;endSize=16;endFill=0;html=1;rounded=0;exitX=0.5;exitY=0;exitDx=0;exitDy=0;" edge="1" parent="1" source="Qx_BBq6HcmvMDlrlQHnB-11">
<mxGeometry width="160" relative="1" as="geometry">
<mxPoint x="330" y="510" as="sourcePoint" />
<mxPoint x="440" y="210" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-16" value="Movie" style="swimlane;fontStyle=0;childLayout=stackLayout;horizontal=1;startSize=26;fillColor=none;horizontalStack=0;resizeParent=1;resizeParentMax=0;resizeLast=0;collapsible=1;marginBottom=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="340" y="460" width="140" height="156" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-17" value="+ id: int" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;whiteSpace=wrap;html=1;" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-16">
<mxGeometry y="26" width="140" height="26" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-21" value="+ title: str" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;whiteSpace=wrap;html=1;" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-16">
<mxGeometry y="52" width="140" height="26" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-18" value="+ year: int" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;whiteSpace=wrap;html=1;" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-16">
<mxGeometry y="78" width="140" height="26" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-19" value="+ duration: int" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;whiteSpace=wrap;html=1;" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-16">
<mxGeometry y="104" width="140" height="26" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-20" value="+ synopsis: str" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;whiteSpace=wrap;html=1;" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-16">
<mxGeometry y="130" width="140" height="26" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-22" value="Opinion" style="swimlane;fontStyle=0;childLayout=stackLayout;horizontal=1;startSize=26;fillColor=none;horizontalStack=0;resizeParent=1;resizeParentMax=0;resizeLast=0;collapsible=1;marginBottom=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="640" y="460" width="140" height="104" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-23" value="+ id: int" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;whiteSpace=wrap;html=1;" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-22">
<mxGeometry y="26" width="140" height="26" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-24" value="+ note: int" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;whiteSpace=wrap;html=1;" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-22">
<mxGeometry y="52" width="140" height="26" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-25" value="+ comment: str" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;whiteSpace=wrap;html=1;" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-22">
<mxGeometry y="78" width="140" height="26" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-26" value="Genre" style="swimlane;fontStyle=0;childLayout=stackLayout;horizontal=1;startSize=26;fillColor=none;horizontalStack=0;resizeParent=1;resizeParentMax=0;resizeLast=0;collapsible=1;marginBottom=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="60" y="460" width="140" height="78" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-27" value="+ id: int" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;whiteSpace=wrap;html=1;" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-26">
<mxGeometry y="26" width="140" height="26" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-28" value="+ label: str" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;whiteSpace=wrap;html=1;" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-26">
<mxGeometry y="52" width="140" height="26" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-31" value="" style="endArrow=none;html=1;edgeStyle=orthogonalEdgeStyle;rounded=0;" edge="1" parent="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="200" y="520" as="sourcePoint" />
<mxPoint x="340" y="520" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-32" value="1" style="edgeLabel;resizable=0;html=1;align=left;verticalAlign=bottom;" connectable="0" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-31">
<mxGeometry x="-1" relative="1" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-33" value="*&amp;nbsp;" style="edgeLabel;resizable=0;html=1;align=right;verticalAlign=bottom;" connectable="0" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-31">
<mxGeometry x="1" relative="1" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-34" value="" style="endArrow=none;html=1;edgeStyle=orthogonalEdgeStyle;rounded=0;" edge="1" parent="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="480" y="520" as="sourcePoint" />
<mxPoint x="640" y="520" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-35" value="1" style="edgeLabel;resizable=0;html=1;align=left;verticalAlign=bottom;" connectable="0" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-34">
<mxGeometry x="-1" relative="1" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-36" value="*&amp;nbsp;" style="edgeLabel;resizable=0;html=1;align=right;verticalAlign=bottom;" connectable="0" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-34">
<mxGeometry x="1" relative="1" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-37" value="" style="endArrow=none;html=1;edgeStyle=orthogonalEdgeStyle;rounded=0;entryX=0.479;entryY=1.115;entryDx=0;entryDy=0;entryPerimeter=0;exitX=0.5;exitY=0;exitDx=0;exitDy=0;" edge="1" parent="1" source="Qx_BBq6HcmvMDlrlQHnB-22" target="Qx_BBq6HcmvMDlrlQHnB-14">
<mxGeometry relative="1" as="geometry">
<mxPoint x="570" y="420" as="sourcePoint" />
<mxPoint x="730" y="420" as="targetPoint" />
<Array as="points">
<mxPoint x="710" y="367" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-38" value="&amp;nbsp;*" style="edgeLabel;resizable=0;html=1;align=left;verticalAlign=bottom;" connectable="0" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-37">
<mxGeometry x="-1" relative="1" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-39" value="1" style="edgeLabel;resizable=0;html=1;align=right;verticalAlign=bottom;" connectable="0" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-37">
<mxGeometry x="1" relative="1" as="geometry">
<mxPoint y="13" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-40" value="" style="endArrow=none;html=1;edgeStyle=orthogonalEdgeStyle;rounded=0;entryX=0.7;entryY=-0.026;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" target="Qx_BBq6HcmvMDlrlQHnB-16">
<mxGeometry relative="1" as="geometry">
<mxPoint x="320" y="310" as="sourcePoint" />
<mxPoint x="480" y="310" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-41" value="*" style="edgeLabel;resizable=0;html=1;align=left;verticalAlign=bottom;" connectable="0" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-40">
<mxGeometry x="-1" relative="1" as="geometry">
<mxPoint x="10" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-42" value="*" style="edgeLabel;resizable=0;html=1;align=right;verticalAlign=bottom;" connectable="0" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-40">
<mxGeometry x="1" relative="1" as="geometry">
<mxPoint x="-8" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-43" value="&lt;span style=&quot;font-weight: normal;&quot;&gt;actors&lt;/span&gt;" style="text;align=center;fontStyle=1;verticalAlign=middle;spacingLeft=3;spacingRight=3;strokeColor=none;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;html=1;" vertex="1" parent="1">
<mxGeometry x="430" y="420" width="80" height="26" as="geometry" />
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-44" value="" style="endArrow=none;html=1;edgeStyle=orthogonalEdgeStyle;rounded=0;exitX=0.25;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" edge="1" parent="1" source="Qx_BBq6HcmvMDlrlQHnB-16" target="Qx_BBq6HcmvMDlrlQHnB-6">
<mxGeometry relative="1" as="geometry">
<mxPoint x="330" y="510" as="sourcePoint" />
<mxPoint x="490" y="510" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-45" value="&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;*" style="edgeLabel;resizable=0;html=1;align=left;verticalAlign=bottom;" connectable="0" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-44">
<mxGeometry x="-1" relative="1" as="geometry">
<mxPoint x="-45" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-46" value="1&amp;nbsp;" style="edgeLabel;resizable=0;html=1;align=right;verticalAlign=bottom;" connectable="0" vertex="1" parent="Qx_BBq6HcmvMDlrlQHnB-44">
<mxGeometry x="1" relative="1" as="geometry">
<mxPoint y="18" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="Qx_BBq6HcmvMDlrlQHnB-47" value="&lt;span style=&quot;font-weight: normal;&quot;&gt;director&lt;/span&gt;" style="text;align=center;fontStyle=1;verticalAlign=middle;spacingLeft=3;spacingRight=3;strokeColor=none;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;html=1;" vertex="1" parent="1">
<mxGeometry x="310" y="420" width="80" height="26" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

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.

0
tests/__init__.py Normal file
View File

View File

@@ -0,0 +1,69 @@
import pytest
from httpx import AsyncClient
# Marqueur pour indiquer à pytest que ce sont des tests asynchrones
pytestmark = pytest.mark.asyncio
# Données de test réutilisables
@pytest.fixture
async def test_data(db_session):
"""Fixture pour insérer des données de test initiales."""
from app.models import Genre, Participant
# 1. Créer les objets
genre = Genre(label="Science-Fiction")
director = Participant(first_name="Denis", last_name="Villeneuve")
db_session.add_all([genre, director])
await db_session.commit()
# 2. Rafraîchir les objets pour obtenir les ID générés par la BDD
await db_session.refresh(genre)
await db_session.refresh(director)
# 3. Renvoyer uniquement les ID, pas les objets entiers
return {"genre_id": genre.id, "director_id": director.id}
async def test_create_movie_success(test_client: AsyncClient, test_data):
"""Vérifie la création réussie d'un film via l'API."""
response = await test_client.post(
"/api/v1/movies/",
json={
"title": "Dune",
"year": 2021,
"duration": 155,
"synopsis": "A mythic and emotionally charged hero's journey.",
"genre_id": test_data["genre_id"],
"director_id": test_data["director_id"],
"actors_ids": []
},
)
assert response.status_code == 201
data = response.json()
assert data["title"] == "Dune"
assert "id" in data
assert data["genre"]["label"] == "Science-Fiction"
async def test_create_movie_validation_error(test_client: AsyncClient, test_data):
"""Vérifie que l'API retourne une erreur 400 pour des données invalides."""
response = await test_client.post(
"/api/v1/movies/",
json={
"title": "Future Movie",
"year": 1800, # Année invalide
"genre_id": test_data["genre_id"],
"director_id": test_data["director_id"],
},
)
assert response.status_code == 400
assert "L'année du film doit être comprise entre" in response.json()["detail"]
async def test_read_movie_not_found(test_client: AsyncClient):
"""Vérifie que l'API retourne une erreur 404 pour un film inexistant."""
response = await test_client.get("/api/v1/movies/999")
assert response.status_code == 404
assert "Film avec l'ID '999' non trouvé." in response.json()["detail"]

109
tests/conftest.py Normal file
View File

@@ -0,0 +1,109 @@
import pytest
import pytest_asyncio
from typing import AsyncGenerator
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from httpx import AsyncClient, ASGITransport
from app.main import app
from app.api.deps import get_db
from app.models import Base, Genre, Participant, Member
# URL pour une base de données SQLite en mémoire
TEST_DATABASE_URL = "sqlite+aiosqlite:///file:memdb_tp_filmotheque?mode=memory&cache=shared"
# Créer un moteur de BDD de test
engine = create_async_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, class_=AsyncSession)
@pytest.fixture(scope="session")
def event_loop():
"""Crée une instance de la boucle d'événements pour toute la session de test."""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture(scope="function")
async def db_session() -> AsyncGenerator[AsyncSession, None]:
"""
Fixture pour fournir une session de BDD de test isolée pour chaque test.
Recrée les tables à chaque fois pour garantir un état propre.
"""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
async with TestingSessionLocal() as session:
try:
yield session
finally:
await session.rollback()
await session.close()
@pytest.fixture(scope="function")
def override_get_db(db_session: AsyncSession):
"""Fixture pour surcharger la dépendance get_db de l'application."""
async def _override_get_db():
yield db_session
return _override_get_db
@pytest_asyncio.fixture(scope="function")
async def test_client(override_get_db) -> AsyncGenerator[AsyncClient, None]:
"""Fixture pour le client HTTP de FastAPI."""
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
@pytest_asyncio.fixture(scope="function")
async def test_data(db_session: AsyncSession):
"""
Fixture pour insérer des données de test initiales.
"""
# 1. Créer les objets
genre_sf = Genre(label="Science-Fiction")
genre_action = Genre(label="Action")
director_nolan = Participant(first_name="Christopher", last_name="Nolan")
actor_leo = Participant(first_name="Leonardo", last_name="DiCaprio")
member_user = Member(
first_name="Test",
last_name="User",
login="testuser",
password="password",
is_admin=False
)
db_session.add_all([genre_sf, genre_action, director_nolan, actor_leo, member_user])
await db_session.commit()
# Rafraîchir les objets après le commit pour charger leurs ID
await db_session.refresh(genre_sf)
await db_session.refresh(genre_action)
await db_session.refresh(director_nolan)
await db_session.refresh(actor_leo)
await db_session.refresh(member_user)
# --- CORRECTION FINALE ---
# Ne pas retourner les objets SQLAlchemy eux-mêmes, mais seulement leurs ID
# et les valeurs simples.
return {
"genre_sf_id": genre_sf.id,
"genre_action_id": genre_action.id,
"director_nolan_id": director_nolan.id,
"director_nolan_lastname": director_nolan.last_name,
"actor_leo_id": actor_leo.id,
"actor_leo_lastname": actor_leo.last_name,
"member_user_id": member_user.id,
"member_user_login": member_user.login
}

View File

@@ -0,0 +1,60 @@
import pytest
from pydantic import ValidationError
try:
from app.schemas.person import PersonBase, PersonRead
from app.schemas.participant import ParticipantCreate, ParticipantRead, ParticipantUpdate
from app.schemas.opinion import OpinionBase, OpinionCreate, OpinionRead
from app.schemas.member import MemberRead
SCHEMAS_LOADED = True
except ImportError as e:
print(f"Échec de l'import des schémas : {e}")
SCHEMAS_LOADED = False
@pytest.mark.skipif(not SCHEMAS_LOADED, reason="Schémas (Person, Participant, Opinion) non trouvés ou import échoué")
def test_person_schemas():
"""Teste les schémas Person (Base et Read) - TODO Étape 2."""
person_data = {"first_name": "John", "last_name": "Doe"}
base = PersonBase(**person_data)
assert base.last_name == "Doe"
read = PersonRead(id=1, **person_data)
assert read.id == 1
@pytest.mark.skipif(not SCHEMAS_LOADED, reason="Schémas (Person, Participant, Opinion) non trouvés ou import échoué")
def test_participant_schemas():
"""Teste les schémas Participant (Create, Update, Read) - TODO Étape 2."""
participant_data = {"first_name": "Jane", "last_name": "Smith"}
create = ParticipantCreate(**participant_data)
assert create.last_name == "Smith"
update_data = {"first_name": "Janet"}
update = ParticipantUpdate(**update_data)
assert update.first_name == "Janet"
assert update.last_name is None
# Teste que des champs inconnus lèvent une erreur (extra="forbid")
with pytest.raises(ValidationError):
ParticipantUpdate(first_name="Test", unknown_field="error")
@pytest.mark.skipif(not SCHEMAS_LOADED, reason="Schémas (Person, Participant, Opinion) non trouvés ou import échoué")
def test_opinion_schemas():
"""Teste les schémas Opinion (Base, Create, Read) - TODO Étape 2."""
opinion_data = {"note": 5, "comment": "Excellent!"}
base = OpinionBase(**opinion_data)
assert base.note == 5
create_data = {"member_id": 1, **opinion_data}
create = OpinionCreate(**create_data)
assert create.member_id == 1
# Mock d'un membre pour le schéma de lecture
mock_member = MemberRead(id=1, login="testuser")
read_data = {"id": 10, "movie_id": 20, "member": mock_member, **opinion_data}
read = OpinionRead(**read_data)
assert read.id == 10
assert read.member.login == "testuser"

View File

@@ -0,0 +1,123 @@
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.repositories import movie as movie_repository
from app.repositories import opinion as opinion_repository
from app.repositories import genre as genre_repository
from app.schemas.movie import MovieCreate
from app.schemas.opinion import OpinionCreate
from app.models import Genre, Participant, Member, Movie, Opinion
# Marqueur pour indiquer à pytest que ce sont des tests asynchrones
pytestmark = pytest.mark.asyncio
@pytest.fixture
async def repo_test_data(db_session: AsyncSession):
"""Fixture pour insérer des données de test pour les tests de repository."""
# 1. Créer tous les objets ORM manuellement
genre = Genre(label="Science-Fiction")
director = Participant(first_name="Denis", last_name="Villeneuve")
member = Member(
first_name="Repo",
last_name="Tester",
login="repo_user",
password="pwd"
)
db_session.add_all([genre, director, member])
await db_session.flush()
# 2. Créer les objets dépendants (Movie, Opinion) manuellement
# Ajouter des valeurs pour les champs NOT NULL (duration et synopsis)
db_movie = Movie(
title="Dune",
year=2021,
duration=155,
synopsis="Un film sur le sable et les vers.",
genre_id=genre.id,
director_id=director.id
)
db_session.add(db_movie)
await db_session.flush() # Flusher pour obtenir l'ID du film
db_opinion = Opinion(
note=5,
comment="Génial",
member_id=member.id,
movie_id=db_movie.id
)
db_session.add(db_opinion)
# 3. Faire un SEUL commit à la fin pour tout sauvegarder
await db_session.commit()
# 4. Rafraîchir les objets pour être sûr qu'ils sont chargés pour les tests
await db_session.refresh(genre)
await db_session.refresh(director)
await db_session.refresh(member)
await db_session.refresh(db_movie)
await db_session.refresh(db_opinion)
# Rafraîchir aussi les relations du film
await db_session.refresh(db_movie, attribute_names=["genre", "director", "opinions"])
return {
"movie": db_movie,
"opinion": db_opinion,
"genre": genre,
"director": director,
"member": member
}
async def test_get_movies_repository(db_session: AsyncSession, repo_test_data):
"""Teste le TODO 'get_movies' dans movie_repository - Étape 3."""
# 1. Appeler la fonction à tester
movies = await movie_repository.get_movies(db_session, skip=0, limit=10)
# 2. Vérifier les résultats
assert isinstance(movies, list)
assert len(movies) == 1
assert movies[0].title == "Dune"
# Vérifier que les relations sont chargées (problème N+1)
assert movies[0].genre is not None
assert movies[0].genre.label == "Science-Fiction"
assert movies[0].director is not None
assert movies[0].director.last_name == "Villeneuve"
assert movies[0].opinions is not None
assert len(movies[0].opinions) == 1
assert movies[0].opinions[0].comment == "Génial"
async def test_get_delete_opinion_repository(db_session: AsyncSession, repo_test_data):
"""Teste les TODO 'get_opinion' et 'delete_opinion_by_id' - Étape 3."""
opinion_id = repo_test_data["opinion"].id
# 1. Tester get_opinion (TODO)
fetched_opinion = await opinion_repository.get_opinion(db_session, opinion_id)
assert fetched_opinion is not None
assert fetched_opinion.id == opinion_id
assert fetched_opinion.comment == "Génial"
# 2. Tester delete_opinion_by_id (TODO)
deleted_opinion = await opinion_repository.delete_opinion_by_id(db_session, opinion_id)
assert deleted_opinion is not None
assert deleted_opinion.id == opinion_id
# 3. Vérifier que l'avis a bien été supprimé
fetched_again = await opinion_repository.get_opinion(db_session, opinion_id)
assert fetched_again is None
async def test_get_genres_repository(db_session: AsyncSession, repo_test_data):
"""Teste 'get_genres' (déjà implémenté, mais bon à avoir)."""
genres = await genre_repository.get_genres(db_session)
assert isinstance(genres, list)
assert len(genres) >= 1
assert genres[0].label == "Science-Fiction"

View File

@@ -0,0 +1,98 @@
import pytest
from unittest.mock import AsyncMock, patch
from app.services import genre as genre_service
from app.services import participant as participant_service
from app.services import opinion as opinion_service
from app.schemas.participant import ParticipantUpdate
from app.schemas.opinion import OpinionCreate
from app.core.exceptions import NotFoundBLLException, ValidationBLLException
# Marqueur pour indiquer à pytest que ce sont des tests asynchrones
pytestmark = pytest.mark.asyncio
async def test_get_genres_service(mocker):
"""Teste le TODO 'get_genres' (service) - Étape 4."""
# 1. Arrange
# Simuler le repository pour qu'il retourne une liste
mock_repo = mocker.patch("app.repositories.genre.get_genres", new_callable=AsyncMock)
mock_repo.return_value = [{"id": 1, "label": "Action"}]
# 2. Act
result = await genre_service.get_genres(db=AsyncMock())
# 3. Assert
mock_repo.assert_called_once()
assert len(result) == 1
assert result[0]["label"] == "Action"
async def test_update_participant_not_found(mocker):
"""Teste que update_participant (service) lève NotFoundBLLException - Étape 4."""
# 1. Arrange
# Simuler le repository pour qu'il retourne None
mock_repo = mocker.patch("app.repositories.participant.update_participant", new_callable=AsyncMock)
mock_repo.return_value = None
update_data = ParticipantUpdate(first_name="Test")
# 2. Act & 3. Assert
with pytest.raises(NotFoundBLLException, match="Participant avec l'ID '999' non trouvé"):
await participant_service.update_participant(
db=AsyncMock(),
participant_id=999,
participant_data=update_data
)
async def test_create_opinion_service_validation(mocker):
"""Teste la validation (note) dans create_opinion (service) - Étape 4."""
# 1. Arrange
# Simuler le service de film (nécessaire pour la validation)
mocker.patch("app.services.movie.get_movie_by_id", new_callable=AsyncMock)
# Simuler le service de membre (maintenant aussi nécessaire)
mocker.patch("app.services.member.get_member_by_id", new_callable=AsyncMock)
# Données d'opinion avec une note invalide
opinion_data = OpinionCreate(note=10, comment="Trop haut!", member_id=1)
# 2. Act & 3. Assert
with pytest.raises(ValidationBLLException, match="La note doit être comprise entre 0 et 5"):
await opinion_service.create_opinion(
db=AsyncMock(),
movie_id=1,
opinion=opinion_data
)
async def test_create_opinion_service_movie_not_found(mocker):
"""Teste que create_opinion lève NotFound si le film n'existe pas - Étape 4."""
# 1. Arrange
# Simuler le service de film pour qu'il lève l'exception
mocker.patch(
"app.services.movie.get_movie_by_id",
new_callable=AsyncMock,
side_effect=NotFoundBLLException(resource_name="Film", resource_id=999)
)
opinion_data = OpinionCreate(note=5, comment="Valide", member_id=1)
# 2. Act & 3. Assert
with pytest.raises(NotFoundBLLException, match="Film avec l'ID '999' non trouvé"):
await opinion_service.create_opinion(
db=AsyncMock(),
movie_id=999,
opinion=opinion_data
)
async def test_delete_opinion_service_not_found(mocker):
"""Teste que delete_opinion (service) lève NotFound - Étape 4."""
# 1. Arrange
# Simuler le repository d'opinion pour qu'il retourne None
mock_repo = mocker.patch("app.repositories.opinion.get_opinion", new_callable=AsyncMock)
mock_repo.return_value = None
# 2. Act & 3. Assert
with pytest.raises(NotFoundBLLException, match="Avis avec l'ID '999' non trouvé"):
await opinion_service.delete_opinion(db=AsyncMock(), opinion_id=999)

View File

@@ -0,0 +1,112 @@
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
# CORRECTION : Importer le modèle Movie
from app.models import Movie
# Marqueur pour indiquer à pytest que ce sont des tests asynchrones
pytestmark = pytest.mark.asyncio
async def test_read_genres_api(test_client: AsyncClient, test_data):
"""Teste le TODO 'GET /genres/' (API) - Étape 5."""
response = await test_client.get("/api/v1/genres/")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) >= 2 # "Science-Fiction" et "Action" de la fixture
assert data[0]["label"] == "Science-Fiction"
assert data[1]["label"] == "Action"
async def test_participants_api_workflow(test_client: AsyncClient, test_data):
"""
Teste le workflow complet pour les participants :
- POST /participants/ (TODO)
- GET /participants/ (TODO)
- PATCH /participants/{id} (TODO)
"""
# 1. Tester GET /participants/ (TODO) avec les données de la fixture
response_get_all = await test_client.get("/api/v1/participants/")
assert response_get_all.status_code == 200
list_data = response_get_all.json()
assert len(list_data) == 2 # Nolan et DiCaprio
# Utiliser les valeurs simples de la fixture test_data
assert list_data[0]["last_name"] == test_data["actor_leo_lastname"] # Trié par nom de famille
assert list_data[1]["last_name"] == test_data["director_nolan_lastname"]
# 2. Tester POST /participants/ (TODO)
participant_data = {"first_name": "Greta", "last_name": "Gerwig"}
response_post = await test_client.post("/api/v1/participants/", json=participant_data)
assert response_post.status_code == 201
created_data = response_post.json()
assert created_data["first_name"] == "Greta"
participant_id = created_data["id"]
# 3. Tester PATCH /participants/{id} (TODO)
patch_data = {"first_name": "G.", "last_name": "Gerwig-Baumbach"}
response_patch = await test_client.patch(
f"/api/v1/participants/{participant_id}",
json=patch_data
)
assert response_patch.status_code == 200
updated_data = response_patch.json()
assert updated_data["first_name"] == "G."
assert updated_data["last_name"] == "Gerwig-Baumbach"
async def test_opinions_api_workflow(test_client: AsyncClient, db_session: AsyncSession, test_data):
"""
Teste le workflow des avis :
- POST /movies/{id}/opinions/ (TODO)
- DELETE /opinions/{id} (déjà fourni, mais on teste)
"""
# 1. Créer un film de test manuellement pour avoir un movie_id
# Ajouter les champs NOT NULL (duration, synopsis)
movie = Movie(
title="Inception",
year=2010,
duration=148,
synopsis="Un film sur les rêves.",
# Utiliser les ID simples de la fixture test_data
genre_id=test_data["genre_action_id"],
director_id=test_data["director_nolan_id"]
)
db_session.add(movie)
await db_session.commit()
await db_session.refresh(movie)
movie_id = movie.id
# 2. Tester POST /movies/{id}/opinions/ (TODO)
opinion_data = {
"note": 5,
"comment": "Mind-blowing!",
"member_id": test_data["member_user_id"]
}
response_post = await test_client.post(
f"/api/v1/movies/{movie_id}/opinions/",
json=opinion_data
)
assert response_post.status_code == 201
created_opinion = response_post.json()
assert created_opinion["comment"] == "Mind-blowing!"
assert created_opinion["member"]["login"] == test_data["member_user_login"]
opinion_id = created_opinion["id"]
# 3. Tester DELETE /opinions/{id}
response_delete = await test_client.delete(f"/api/v1/opinions/{opinion_id}")
assert response_delete.status_code == 204 # No Content
# 4. Vérifier que la suppression lève un 404 si on réessaye
response_delete_again = await test_client.delete(f"/api/v1/opinions/{opinion_id}")
assert response_delete_again.status_code == 404

View File

@@ -0,0 +1,72 @@
import pytest
from unittest.mock import AsyncMock
from app.services import movie as movie_service
from app.schemas.movie import MovieCreate
from app.core.exceptions import NotFoundBLLException, ValidationBLLException
# Marqueur pour indiquer à pytest que ce sont des tests asynchrones
pytestmark = pytest.mark.asyncio
async def test_get_movie_by_id_success(mocker):
"""
Vérifie que le service retourne un film si le repositories le trouve.
"""
# 1. Préparation (Arrange)
# On simule le repositories movie
mock_repo = mocker.patch("app.repositories.movie.get_movie", new_callable=AsyncMock)
# On configure le mock pour qu'il retourne une fausse donnée
fake_movie_id = 1
mock_repo.return_value = {"id": fake_movie_id, "title": "Fake Movie"}
# 2. Action (Act)
# On appelle la fonction du service à tester
result = await movie_service.get_movie_by_id(db=AsyncMock(), movie_id=fake_movie_id)
# 3. Assertion (Assert)
# On vérifie que le service a bien appelé le repositories
mock_repo.assert_called_once_with(mocker.ANY, movie_id=fake_movie_id)
# On vérifie que le résultat est correct
assert result["id"] == fake_movie_id
async def test_get_movie_by_id_not_found(mocker):
"""
Vérifie que le service lève une exception NotFoundError si le repositories ne trouve rien.
"""
# 1. Arrange
# On simule le repositories pour qu'il retourne None
mock_repo = AsyncMock(return_value=None)
mocker.patch("app.repositories.movie.get_movie", new=mock_repo)
# 2. Act & 3. Assert
# On s'attend à ce qu'une exception soit levée et on vérifie son type
with pytest.raises(NotFoundBLLException):
await movie_service.get_movie_by_id(db=AsyncMock(), movie_id=999)
async def test_create_movie_invalid_year():
"""
Vérifie que le service lève une ValidationError pour une année invalide.
"""
# 1. Arrange
movie_data = MovieCreate(title="The Future Movie", year=3000, genre_id=1, director_id=1)
# 2. Act & 3. Assert
with pytest.raises(ValidationBLLException, match="L'année du film doit être comprise entre"):
await movie_service.create_movie(db=AsyncMock(), movie=movie_data)
async def test_create_movie_empty_title():
"""
Vérifie que le service lève une ValidationError pour un titre vide.
"""
# 1. Arrange
movie_data = MovieCreate(title=" ", year=2020, genre_id=1, director_id=1)
# 2. Act & 3. Assert
with pytest.raises(ValidationBLLException, match="Le titre du film ne peut pas être vide."):
await movie_service.create_movie(db=AsyncMock(), movie=movie_data)