tp done
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/.idea
|
||||
484
README.md
Normal file
484
README.md
Normal 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 l’environnement
|
||||
|
||||
- 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.
|
||||
|
||||

|
||||
|
||||
### 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
1280
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
64
pyproject.toml
Normal file
64
pyproject.toml
Normal 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
167
resources/tp_fastapi.drawio
Normal 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="*&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="*&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="&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="<span style="font-weight: normal;">actors</span>" 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="&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &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&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="<span style="font-weight: normal;">director</span>" 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>
|
||||
BIN
resources/tp_fastapi_uml.png
Normal file
BIN
resources/tp_fastapi_uml.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
0
src/app/__init__.py
Normal file
0
src/app/__init__.py
Normal file
0
src/app/api/__init__.py
Normal file
0
src/app/api/__init__.py
Normal file
10
src/app/api/deps.py
Normal file
10
src/app/api/deps.py
Normal 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
|
||||
43
src/app/api/exception_handlers.py
Normal file
43
src/app/api/exception_handlers.py
Normal 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.")
|
||||
0
src/app/api/routers/__init__.py
Normal file
0
src/app/api/routers/__init__.py
Normal file
12
src/app/api/routers/genres.py
Normal file
12
src/app/api/routers/genres.py
Normal 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)
|
||||
24
src/app/api/routers/movies.py
Normal file
24
src/app/api/routers/movies.py
Normal 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)
|
||||
21
src/app/api/routers/opinions.py
Normal file
21
src/app/api/routers/opinions.py
Normal 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)
|
||||
28
src/app/api/routers/participants.py
Normal file
28
src/app/api/routers/participants.py
Normal 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
0
src/app/core/__init__.py
Normal file
38
src/app/core/config.py
Normal file
38
src/app/core/config.py
Normal 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()
|
||||
25
src/app/core/exceptions.py
Normal file
25
src/app/core/exceptions.py
Normal 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
0
src/app/db/__init__.py
Normal file
71
src/app/db/seeding.py
Normal file
71
src/app/db/seeding.py
Normal 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
21
src/app/db/session.py
Normal 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
65
src/app/main.py
Normal 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!"}
|
||||
|
||||
13
src/app/models/__init__.py
Normal file
13
src/app/models/__init__.py
Normal 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
|
||||
|
||||
3
src/app/models/base_class.py
Normal file
3
src/app/models/base_class.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from sqlalchemy.orm import declarative_base
|
||||
Base = declarative_base()
|
||||
|
||||
13
src/app/models/genre.py
Normal file
13
src/app/models/genre.py
Normal 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
33
src/app/models/member.py
Normal 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
36
src/app/models/movie.py
Normal 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"
|
||||
)
|
||||
|
||||
10
src/app/models/movie_actors.py
Normal file
10
src/app/models/movie_actors.py
Normal 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
23
src/app/models/opinion.py
Normal 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")
|
||||
21
src/app/models/participant.py
Normal file
21
src/app/models/participant.py
Normal 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
21
src/app/models/person.py
Normal 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",
|
||||
}
|
||||
0
src/app/repositories/__init__.py
Normal file
0
src/app/repositories/__init__.py
Normal file
14
src/app/repositories/genre.py
Normal file
14
src/app/repositories/genre.py
Normal 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)
|
||||
13
src/app/repositories/member.py
Normal file
13
src/app/repositories/member.py
Normal 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)
|
||||
78
src/app/repositories/movie.py
Normal file
78
src/app/repositories/movie.py
Normal 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)
|
||||
60
src/app/repositories/opinion.py
Normal file
60
src/app/repositories/opinion.py
Normal 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)
|
||||
60
src/app/repositories/participant.py
Normal file
60
src/app/repositories/participant.py
Normal 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)
|
||||
|
||||
0
src/app/schemas/__init__.py
Normal file
0
src/app/schemas/__init__.py
Normal file
13
src/app/schemas/genre.py
Normal file
13
src/app/schemas/genre.py
Normal 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)
|
||||
9
src/app/schemas/member.py
Normal file
9
src/app/schemas/member.py
Normal 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
29
src/app/schemas/movie.py
Normal 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)
|
||||
22
src/app/schemas/opinion.py
Normal file
22
src/app/schemas/opinion.py
Normal 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)
|
||||
|
||||
23
src/app/schemas/participant.py
Normal file
23
src/app/schemas/participant.py
Normal 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
15
src/app/schemas/person.py
Normal 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)
|
||||
0
src/app/services/__init__.py
Normal file
0
src/app/services/__init__.py
Normal file
9
src/app/services/genre.py
Normal file
9
src/app/services/genre.py
Normal 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)
|
||||
11
src/app/services/member.py
Normal file
11
src/app/services/member.py
Normal 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
43
src/app/services/movie.py
Normal 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)
|
||||
|
||||
40
src/app/services/opinion.py
Normal file
40
src/app/services/opinion.py
Normal 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
|
||||
23
src/app/services/participant.py
Normal file
23
src/app/services/participant.py
Normal 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
BIN
src/local_dev.db
Normal file
Binary file not shown.
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
69
tests/api/test_movies_api.py
Normal file
69
tests/api/test_movies_api.py
Normal 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
109
tests/conftest.py
Normal 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
|
||||
}
|
||||
60
tests/enonce/etape2_test_schemas.py
Normal file
60
tests/enonce/etape2_test_schemas.py
Normal 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"
|
||||
123
tests/enonce/etape3_test_repositories.py
Normal file
123
tests/enonce/etape3_test_repositories.py
Normal 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"
|
||||
98
tests/enonce/etape4_test_services.py
Normal file
98
tests/enonce/etape4_test_services.py
Normal 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)
|
||||
112
tests/enonce/etape5_test_api.py
Normal file
112
tests/enonce/etape5_test_api.py
Normal 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
|
||||
72
tests/services/test_movie_service.py
Normal file
72
tests/services/test_movie_service.py
Normal 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)
|
||||
Reference in New Issue
Block a user