claude code

This commit is contained in:
2026-01-28 19:22:30 +01:00
parent f9b1d43c81
commit bdbfa4e25a
104 changed files with 9591 additions and 261 deletions

View File

@@ -0,0 +1,11 @@
"""Package des routers API."""
from app.routers.categories import router as categories_router
from app.routers.items import router as items_router
from app.routers.locations import router as locations_router
__all__ = [
"categories_router",
"locations_router",
"items_router",
]

View File

@@ -0,0 +1,168 @@
"""Router API pour les catégories."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.repositories.category import CategoryRepository
from app.schemas.category import (
CategoryCreate,
CategoryResponse,
CategoryUpdate,
CategoryWithItemCount,
)
from app.schemas.common import PaginatedResponse, SuccessResponse
router = APIRouter(prefix="/categories", tags=["Categories"])
@router.get("", response_model=PaginatedResponse[CategoryWithItemCount])
async def list_categories(
page: int = 1,
page_size: int = 20,
db: AsyncSession = Depends(get_db),
) -> PaginatedResponse[CategoryWithItemCount]:
"""Liste toutes les catégories avec le nombre d'objets."""
repo = CategoryRepository(db)
skip = (page - 1) * page_size
categories_with_count = await repo.get_all_with_item_count(skip=skip, limit=page_size)
total = await repo.count()
items = [
CategoryWithItemCount(
id=cat.id,
name=cat.name,
description=cat.description,
color=cat.color,
icon=cat.icon,
created_at=cat.created_at,
updated_at=cat.updated_at,
item_count=count,
)
for cat, count in categories_with_count
]
return PaginatedResponse.create(items=items, total=total, page=page, page_size=page_size)
@router.get("/{category_id}", response_model=CategoryWithItemCount)
async def get_category(
category_id: int,
db: AsyncSession = Depends(get_db),
) -> CategoryWithItemCount:
"""Récupère une catégorie par son ID."""
repo = CategoryRepository(db)
result = await repo.get_with_item_count(category_id)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Catégorie {category_id} non trouvée",
)
category, item_count = result
return CategoryWithItemCount(
id=category.id,
name=category.name,
description=category.description,
color=category.color,
icon=category.icon,
created_at=category.created_at,
updated_at=category.updated_at,
item_count=item_count,
)
@router.post("", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
async def create_category(
data: CategoryCreate,
db: AsyncSession = Depends(get_db),
) -> CategoryResponse:
"""Crée une nouvelle catégorie."""
repo = CategoryRepository(db)
# Vérifier si le nom existe déjà
if await repo.name_exists(data.name):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Une catégorie avec le nom '{data.name}' existe déjà",
)
category = await repo.create(
name=data.name,
description=data.description,
color=data.color,
icon=data.icon,
)
await db.commit()
return CategoryResponse.model_validate(category)
@router.put("/{category_id}", response_model=CategoryResponse)
async def update_category(
category_id: int,
data: CategoryUpdate,
db: AsyncSession = Depends(get_db),
) -> CategoryResponse:
"""Met à jour une catégorie."""
repo = CategoryRepository(db)
# Vérifier si la catégorie existe
existing = await repo.get(category_id)
if existing is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Catégorie {category_id} non trouvée",
)
# Vérifier si le nouveau nom existe déjà (si changement de nom)
if data.name and data.name != existing.name:
if await repo.name_exists(data.name, exclude_id=category_id):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Une catégorie avec le nom '{data.name}' existe déjà",
)
category = await repo.update(
category_id,
name=data.name,
description=data.description,
color=data.color,
icon=data.icon,
)
await db.commit()
return CategoryResponse.model_validate(category)
@router.delete("/{category_id}", response_model=SuccessResponse)
async def delete_category(
category_id: int,
db: AsyncSession = Depends(get_db),
) -> SuccessResponse:
"""Supprime une catégorie."""
repo = CategoryRepository(db)
# Vérifier si la catégorie existe
result = await repo.get_with_item_count(category_id)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Catégorie {category_id} non trouvée",
)
category, item_count = result
# Empêcher la suppression si des objets utilisent cette catégorie
if item_count > 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Impossible de supprimer : {item_count} objet(s) utilisent cette catégorie",
)
await repo.delete(category_id)
await db.commit()
return SuccessResponse(message="Catégorie supprimée avec succès", id=category_id)

View File

@@ -0,0 +1,264 @@
"""Router API pour les objets d'inventaire."""
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.models.item import ItemStatus
from app.repositories.category import CategoryRepository
from app.repositories.item import ItemRepository
from app.repositories.location import LocationRepository
from app.schemas.common import PaginatedResponse, SuccessResponse
from app.schemas.item import (
ItemCreate,
ItemResponse,
ItemUpdate,
ItemWithRelations,
)
router = APIRouter(prefix="/items", tags=["Items"])
@router.get("", response_model=PaginatedResponse[ItemWithRelations])
async def list_items(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
search: str | None = Query(default=None, min_length=2),
category_id: int | None = None,
location_id: int | None = None,
status: ItemStatus | None = None,
min_price: Decimal | None = None,
max_price: Decimal | None = None,
db: AsyncSession = Depends(get_db),
) -> PaginatedResponse[ItemWithRelations]:
"""Liste les objets avec filtres et pagination."""
repo = ItemRepository(db)
skip = (page - 1) * page_size
items = await repo.search(
query=search or "",
category_id=category_id,
location_id=location_id,
status=status,
min_price=min_price,
max_price=max_price,
skip=skip,
limit=page_size,
)
total = await repo.count_filtered(
query=search,
category_id=category_id,
location_id=location_id,
status=status,
min_price=min_price,
max_price=max_price,
)
result_items = [ItemWithRelations.model_validate(item) for item in items]
return PaginatedResponse.create(items=result_items, total=total, page=page, page_size=page_size)
@router.get("/{item_id}", response_model=ItemWithRelations)
async def get_item(
item_id: int,
db: AsyncSession = Depends(get_db),
) -> ItemWithRelations:
"""Récupère un objet par son ID avec ses relations."""
repo = ItemRepository(db)
item = await repo.get_with_relations(item_id)
if item is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Objet {item_id} non trouvé",
)
return ItemWithRelations.model_validate(item)
@router.post("", response_model=ItemResponse, status_code=status.HTTP_201_CREATED)
async def create_item(
data: ItemCreate,
db: AsyncSession = Depends(get_db),
) -> ItemResponse:
"""Crée un nouvel objet."""
item_repo = ItemRepository(db)
category_repo = CategoryRepository(db)
location_repo = LocationRepository(db)
# Vérifier que la catégorie existe
if not await category_repo.exists(data.category_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Catégorie {data.category_id} non trouvée",
)
# Vérifier que l'emplacement existe
if not await location_repo.exists(data.location_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Emplacement {data.location_id} non trouvé",
)
# Vérifier l'unicité du numéro de série si fourni
if data.serial_number:
existing = await item_repo.get_by_serial_number(data.serial_number)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Un objet avec le numéro de série '{data.serial_number}' existe déjà",
)
item = await item_repo.create(
name=data.name,
description=data.description,
quantity=data.quantity,
status=data.status,
brand=data.brand,
model=data.model,
serial_number=data.serial_number,
price=data.price,
purchase_date=data.purchase_date,
notes=data.notes,
category_id=data.category_id,
location_id=data.location_id,
)
await db.commit()
return ItemResponse.model_validate(item)
@router.put("/{item_id}", response_model=ItemResponse)
async def update_item(
item_id: int,
data: ItemUpdate,
db: AsyncSession = Depends(get_db),
) -> ItemResponse:
"""Met à jour un objet."""
item_repo = ItemRepository(db)
category_repo = CategoryRepository(db)
location_repo = LocationRepository(db)
# Vérifier que l'objet existe
existing = await item_repo.get(item_id)
if existing is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Objet {item_id} non trouvé",
)
# Vérifier la catégorie si changée
if data.category_id is not None and data.category_id != existing.category_id:
if not await category_repo.exists(data.category_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Catégorie {data.category_id} non trouvée",
)
# Vérifier l'emplacement si changé
if data.location_id is not None and data.location_id != existing.location_id:
if not await location_repo.exists(data.location_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Emplacement {data.location_id} non trouvé",
)
# Vérifier l'unicité du numéro de série si changé
if data.serial_number and data.serial_number != existing.serial_number:
existing_with_serial = await item_repo.get_by_serial_number(data.serial_number)
if existing_with_serial and existing_with_serial.id != item_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Un objet avec le numéro de série '{data.serial_number}' existe déjà",
)
item = await item_repo.update(
item_id,
name=data.name,
description=data.description,
quantity=data.quantity,
status=data.status,
brand=data.brand,
model=data.model,
serial_number=data.serial_number,
price=data.price,
purchase_date=data.purchase_date,
notes=data.notes,
category_id=data.category_id,
location_id=data.location_id,
)
await db.commit()
return ItemResponse.model_validate(item)
@router.delete("/{item_id}", response_model=SuccessResponse)
async def delete_item(
item_id: int,
db: AsyncSession = Depends(get_db),
) -> SuccessResponse:
"""Supprime un objet et ses documents associés."""
repo = ItemRepository(db)
# Vérifier que l'objet existe
if not await repo.exists(item_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Objet {item_id} non trouvé",
)
await repo.delete(item_id)
await db.commit()
return SuccessResponse(message="Objet supprimé avec succès", id=item_id)
@router.patch("/{item_id}/status", response_model=ItemResponse)
async def update_item_status(
item_id: int,
new_status: ItemStatus,
db: AsyncSession = Depends(get_db),
) -> ItemResponse:
"""Met à jour le statut d'un objet."""
repo = ItemRepository(db)
item = await repo.update(item_id, status=new_status)
if item is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Objet {item_id} non trouvé",
)
await db.commit()
return ItemResponse.model_validate(item)
@router.patch("/{item_id}/location", response_model=ItemResponse)
async def move_item(
item_id: int,
new_location_id: int,
db: AsyncSession = Depends(get_db),
) -> ItemResponse:
"""Déplace un objet vers un nouvel emplacement."""
item_repo = ItemRepository(db)
location_repo = LocationRepository(db)
# Vérifier que le nouvel emplacement existe
if not await location_repo.exists(new_location_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Emplacement {new_location_id} non trouvé",
)
item = await item_repo.update(item_id, location_id=new_location_id)
if item is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Objet {item_id} non trouvé",
)
await db.commit()
return ItemResponse.model_validate(item)

View File

@@ -0,0 +1,249 @@
"""Router API pour les emplacements."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.models.location import LocationType
from app.repositories.location import LocationRepository
from app.schemas.common import PaginatedResponse, SuccessResponse
from app.schemas.location import (
LocationCreate,
LocationResponse,
LocationTree,
LocationUpdate,
LocationWithItemCount,
)
router = APIRouter(prefix="/locations", tags=["Locations"])
@router.get("", response_model=PaginatedResponse[LocationResponse])
async def list_locations(
page: int = 1,
page_size: int = 50,
parent_id: int | None = None,
type: LocationType | None = None,
db: AsyncSession = Depends(get_db),
) -> PaginatedResponse[LocationResponse]:
"""Liste les emplacements avec filtres optionnels."""
repo = LocationRepository(db)
skip = (page - 1) * page_size
filters = {}
if parent_id is not None:
filters["parent_id"] = parent_id
if type is not None:
filters["type"] = type
locations = await repo.get_all(skip=skip, limit=page_size, **filters)
total = await repo.count(**filters)
items = [LocationResponse.model_validate(loc) for loc in locations]
return PaginatedResponse.create(items=items, total=total, page=page, page_size=page_size)
@router.get("/tree", response_model=list[LocationTree])
async def get_location_tree(
db: AsyncSession = Depends(get_db),
) -> list[LocationTree]:
"""Récupère l'arborescence complète des emplacements."""
repo = LocationRepository(db)
# Récupérer tous les emplacements
all_locations = await repo.get_all(skip=0, limit=1000)
# Construire un dictionnaire pour un accès rapide
loc_dict: dict[int, LocationTree] = {}
for loc in all_locations:
loc_dict[loc.id] = LocationTree(
id=loc.id,
name=loc.name,
type=loc.type,
path=loc.path,
children=[],
item_count=0,
)
# Construire l'arborescence
roots: list[LocationTree] = []
for loc in all_locations:
tree_node = loc_dict[loc.id]
if loc.parent_id is None:
roots.append(tree_node)
elif loc.parent_id in loc_dict:
loc_dict[loc.parent_id].children.append(tree_node)
return roots
@router.get("/roots", response_model=list[LocationResponse])
async def get_root_locations(
db: AsyncSession = Depends(get_db),
) -> list[LocationResponse]:
"""Récupère les emplacements racine (pièces)."""
repo = LocationRepository(db)
locations = await repo.get_root_locations()
return [LocationResponse.model_validate(loc) for loc in locations]
@router.get("/{location_id}", response_model=LocationWithItemCount)
async def get_location(
location_id: int,
db: AsyncSession = Depends(get_db),
) -> LocationWithItemCount:
"""Récupère un emplacement par son ID."""
repo = LocationRepository(db)
result = await repo.get_with_item_count(location_id)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Emplacement {location_id} non trouvé",
)
location, item_count = result
return LocationWithItemCount(
id=location.id,
name=location.name,
type=location.type,
parent_id=location.parent_id,
path=location.path,
description=location.description,
created_at=location.created_at,
updated_at=location.updated_at,
item_count=item_count,
)
@router.get("/{location_id}/children", response_model=list[LocationResponse])
async def get_location_children(
location_id: int,
db: AsyncSession = Depends(get_db),
) -> list[LocationResponse]:
"""Récupère les enfants directs d'un emplacement."""
repo = LocationRepository(db)
# Vérifier que le parent existe
if not await repo.exists(location_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Emplacement {location_id} non trouvé",
)
children = await repo.get_children(location_id)
return [LocationResponse.model_validate(child) for child in children]
@router.post("", response_model=LocationResponse, status_code=status.HTTP_201_CREATED)
async def create_location(
data: LocationCreate,
db: AsyncSession = Depends(get_db),
) -> LocationResponse:
"""Crée un nouvel emplacement."""
repo = LocationRepository(db)
# Vérifier que le parent existe si spécifié
if data.parent_id is not None:
if not await repo.exists(data.parent_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Emplacement parent {data.parent_id} non trouvé",
)
location = await repo.create_with_path(
name=data.name,
type=data.type,
parent_id=data.parent_id,
description=data.description,
)
await db.commit()
return LocationResponse.model_validate(location)
@router.put("/{location_id}", response_model=LocationResponse)
async def update_location(
location_id: int,
data: LocationUpdate,
db: AsyncSession = Depends(get_db),
) -> LocationResponse:
"""Met à jour un emplacement."""
repo = LocationRepository(db)
# Vérifier que l'emplacement existe
existing = await repo.get(location_id)
if existing is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Emplacement {location_id} non trouvé",
)
# Vérifier que le nouveau parent existe si spécifié
if data.parent_id is not None and data.parent_id != existing.parent_id:
if data.parent_id == location_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Un emplacement ne peut pas être son propre parent",
)
if not await repo.exists(data.parent_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Emplacement parent {data.parent_id} non trouvé",
)
# Mettre à jour
location = await repo.update(
location_id,
name=data.name,
type=data.type,
parent_id=data.parent_id,
description=data.description,
)
# Recalculer les chemins si le nom ou le parent a changé
if data.name or data.parent_id is not None:
await repo.update_paths_recursive(location)
await db.commit()
return LocationResponse.model_validate(location)
@router.delete("/{location_id}", response_model=SuccessResponse)
async def delete_location(
location_id: int,
db: AsyncSession = Depends(get_db),
) -> SuccessResponse:
"""Supprime un emplacement."""
repo = LocationRepository(db)
# Vérifier que l'emplacement existe
result = await repo.get_with_item_count(location_id)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Emplacement {location_id} non trouvé",
)
location, item_count = result
# Empêcher la suppression si des objets utilisent cet emplacement
if item_count > 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Impossible de supprimer : {item_count} objet(s) utilisent cet emplacement",
)
# Vérifier s'il y a des enfants
children = await repo.get_children(location_id)
if children:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Impossible de supprimer : cet emplacement a {len(children)} sous-emplacement(s)",
)
await repo.delete(location_id)
await db.commit()
return SuccessResponse(message="Emplacement supprimé avec succès", id=location_id)