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,15 @@
"""Package des repositories pour l'accès aux données."""
from app.repositories.base import BaseRepository
from app.repositories.category import CategoryRepository
from app.repositories.document import DocumentRepository
from app.repositories.item import ItemRepository
from app.repositories.location import LocationRepository
__all__ = [
"BaseRepository",
"CategoryRepository",
"LocationRepository",
"ItemRepository",
"DocumentRepository",
]

View File

@@ -0,0 +1,154 @@
"""Repository de base générique.
Fournit les opérations CRUD de base pour tous les modèles.
"""
from typing import Any, Generic, TypeVar
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import Base
ModelType = TypeVar("ModelType", bound=Base)
class BaseRepository(Generic[ModelType]):
"""Repository générique avec opérations CRUD de base.
Attributes:
model: Classe du modèle SQLAlchemy
db: Session de base de données
"""
def __init__(self, model: type[ModelType], db: AsyncSession) -> None:
"""Initialise le repository.
Args:
model: Classe du modèle SQLAlchemy
db: Session de base de données async
"""
self.model = model
self.db = db
async def get(self, id: int) -> ModelType | None:
"""Récupère un élément par son ID.
Args:
id: Identifiant de l'élément
Returns:
L'élément trouvé ou None
"""
result = await self.db.execute(select(self.model).where(self.model.id == id))
return result.scalar_one_or_none()
async def get_all(
self, skip: int = 0, limit: int = 100, **filters: Any
) -> list[ModelType]:
"""Récupère tous les éléments avec pagination et filtres optionnels.
Args:
skip: Nombre d'éléments à sauter
limit: Nombre max d'éléments à retourner
**filters: Filtres additionnels (ex: status="active")
Returns:
Liste des éléments
"""
query = select(self.model)
# Appliquer les filtres
for field, value in filters.items():
if value is not None and hasattr(self.model, field):
query = query.where(getattr(self.model, field) == value)
query = query.offset(skip).limit(limit)
result = await self.db.execute(query)
return list(result.scalars().all())
async def count(self, **filters: Any) -> int:
"""Compte le nombre d'éléments avec filtres optionnels.
Args:
**filters: Filtres additionnels
Returns:
Nombre total d'éléments
"""
query = select(func.count(self.model.id))
for field, value in filters.items():
if value is not None and hasattr(self.model, field):
query = query.where(getattr(self.model, field) == value)
result = await self.db.execute(query)
return result.scalar_one()
async def create(self, **data: Any) -> ModelType:
"""Crée un nouvel élément.
Args:
**data: Données de l'élément
Returns:
L'élément créé
"""
instance = self.model(**data)
self.db.add(instance)
await self.db.flush()
await self.db.refresh(instance)
return instance
async def update(self, id: int, **data: Any) -> ModelType | None:
"""Met à jour un élément existant.
Args:
id: Identifiant de l'élément
**data: Données à mettre à jour (seules les valeurs non-None)
Returns:
L'élément mis à jour ou None si non trouvé
"""
instance = await self.get(id)
if instance is None:
return None
for field, value in data.items():
if value is not None and hasattr(instance, field):
setattr(instance, field, value)
await self.db.flush()
await self.db.refresh(instance)
return instance
async def delete(self, id: int) -> bool:
"""Supprime un élément.
Args:
id: Identifiant de l'élément
Returns:
True si supprimé, False si non trouvé
"""
instance = await self.get(id)
if instance is None:
return False
await self.db.delete(instance)
await self.db.flush()
return True
async def exists(self, id: int) -> bool:
"""Vérifie si un élément existe.
Args:
id: Identifiant de l'élément
Returns:
True si existe, False sinon
"""
result = await self.db.execute(
select(func.count(self.model.id)).where(self.model.id == id)
)
return result.scalar_one() > 0

View File

@@ -0,0 +1,85 @@
"""Repository pour les catégories."""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.category import Category
from app.repositories.base import BaseRepository
class CategoryRepository(BaseRepository[Category]):
"""Repository pour les opérations sur les catégories."""
def __init__(self, db: AsyncSession) -> None:
"""Initialise le repository."""
super().__init__(Category, db)
async def get_by_name(self, name: str) -> Category | None:
"""Récupère une catégorie par son nom.
Args:
name: Nom de la catégorie
Returns:
La catégorie trouvée ou None
"""
result = await self.db.execute(
select(Category).where(Category.name == name)
)
return result.scalar_one_or_none()
async def get_with_item_count(self, id: int) -> tuple[Category, int] | None:
"""Récupère une catégorie avec le nombre d'objets.
Args:
id: ID de la catégorie
Returns:
Tuple (catégorie, nombre d'objets) ou None
"""
result = await self.db.execute(
select(Category).options(selectinload(Category.items)).where(Category.id == id)
)
category = result.scalar_one_or_none()
if category is None:
return None
return category, len(category.items)
async def get_all_with_item_count(
self, skip: int = 0, limit: int = 100
) -> list[tuple[Category, int]]:
"""Récupère toutes les catégories avec le nombre d'objets.
Args:
skip: Offset
limit: Limite
Returns:
Liste de tuples (catégorie, nombre d'objets)
"""
result = await self.db.execute(
select(Category)
.options(selectinload(Category.items))
.offset(skip)
.limit(limit)
.order_by(Category.name)
)
categories = result.scalars().all()
return [(cat, len(cat.items)) for cat in categories]
async def name_exists(self, name: str, exclude_id: int | None = None) -> bool:
"""Vérifie si un nom de catégorie existe déjà.
Args:
name: Nom à vérifier
exclude_id: ID à exclure (pour les mises à jour)
Returns:
True si le nom existe déjà
"""
query = select(func.count(Category.id)).where(Category.name == name)
if exclude_id is not None:
query = query.where(Category.id != exclude_id)
result = await self.db.execute(query)
return result.scalar_one() > 0

View File

@@ -0,0 +1,113 @@
"""Repository pour les documents attachés."""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.document import Document, DocumentType
from app.repositories.base import BaseRepository
class DocumentRepository(BaseRepository[Document]):
"""Repository pour les opérations sur les documents."""
def __init__(self, db: AsyncSession) -> None:
"""Initialise le repository."""
super().__init__(Document, db)
async def get_by_item(self, item_id: int) -> list[Document]:
"""Récupère tous les documents d'un objet.
Args:
item_id: ID de l'objet
Returns:
Liste des documents
"""
result = await self.db.execute(
select(Document)
.where(Document.item_id == item_id)
.order_by(Document.type, Document.created_at)
)
return list(result.scalars().all())
async def get_by_item_and_type(
self, item_id: int, type: DocumentType
) -> list[Document]:
"""Récupère les documents d'un objet par type.
Args:
item_id: ID de l'objet
type: Type de document
Returns:
Liste des documents
"""
result = await self.db.execute(
select(Document)
.where(Document.item_id == item_id, Document.type == type)
.order_by(Document.created_at)
)
return list(result.scalars().all())
async def get_by_filename(self, filename: str) -> Document | None:
"""Récupère un document par son nom de fichier.
Args:
filename: Nom du fichier (UUID)
Returns:
Le document trouvé ou None
"""
result = await self.db.execute(
select(Document).where(Document.filename == filename)
)
return result.scalar_one_or_none()
async def count_by_item(self, item_id: int) -> int:
"""Compte le nombre de documents d'un objet.
Args:
item_id: ID de l'objet
Returns:
Nombre de documents
"""
from sqlalchemy import func
result = await self.db.execute(
select(func.count(Document.id)).where(Document.item_id == item_id)
)
return result.scalar_one()
async def get_photos(self, item_id: int) -> list[Document]:
"""Récupère les photos d'un objet.
Args:
item_id: ID de l'objet
Returns:
Liste des photos
"""
return await self.get_by_item_and_type(item_id, DocumentType.PHOTO)
async def get_invoices(self, item_id: int) -> list[Document]:
"""Récupère les factures d'un objet.
Args:
item_id: ID de l'objet
Returns:
Liste des factures
"""
return await self.get_by_item_and_type(item_id, DocumentType.INVOICE)
async def get_manuals(self, item_id: int) -> list[Document]:
"""Récupère les notices d'un objet.
Args:
item_id: ID de l'objet
Returns:
Liste des notices
"""
return await self.get_by_item_and_type(item_id, DocumentType.MANUAL)

View File

@@ -0,0 +1,247 @@
"""Repository pour les objets d'inventaire."""
from decimal import Decimal
from typing import Any
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.item import Item, ItemStatus
from app.repositories.base import BaseRepository
class ItemRepository(BaseRepository[Item]):
"""Repository pour les opérations sur les objets."""
def __init__(self, db: AsyncSession) -> None:
"""Initialise le repository."""
super().__init__(Item, db)
async def get_with_relations(self, id: int) -> Item | None:
"""Récupère un objet avec ses relations (catégorie, emplacement, documents).
Args:
id: ID de l'objet
Returns:
L'objet avec ses relations ou None
"""
result = await self.db.execute(
select(Item)
.options(
selectinload(Item.category),
selectinload(Item.location),
selectinload(Item.documents),
)
.where(Item.id == id)
)
return result.scalar_one_or_none()
async def get_all_with_relations(
self, skip: int = 0, limit: int = 100
) -> list[Item]:
"""Récupère tous les objets avec leurs relations.
Args:
skip: Offset
limit: Limite
Returns:
Liste des objets avec relations
"""
result = await self.db.execute(
select(Item)
.options(
selectinload(Item.category),
selectinload(Item.location),
)
.offset(skip)
.limit(limit)
.order_by(Item.name)
)
return list(result.scalars().all())
async def search(
self,
query: str,
category_id: int | None = None,
location_id: int | None = None,
status: ItemStatus | None = None,
min_price: Decimal | None = None,
max_price: Decimal | None = None,
skip: int = 0,
limit: int = 100,
) -> list[Item]:
"""Recherche des objets avec filtres.
Args:
query: Texte de recherche (nom, description, marque, modèle)
category_id: Filtre par catégorie
location_id: Filtre par emplacement
status: Filtre par statut
min_price: Prix minimum
max_price: Prix maximum
skip: Offset
limit: Limite
Returns:
Liste des objets correspondants
"""
stmt = select(Item).options(
selectinload(Item.category),
selectinload(Item.location),
)
# Recherche textuelle
if query:
search_term = f"%{query}%"
stmt = stmt.where(
or_(
Item.name.ilike(search_term),
Item.description.ilike(search_term),
Item.brand.ilike(search_term),
Item.model.ilike(search_term),
Item.notes.ilike(search_term),
)
)
# Filtres
if category_id is not None:
stmt = stmt.where(Item.category_id == category_id)
if location_id is not None:
stmt = stmt.where(Item.location_id == location_id)
if status is not None:
stmt = stmt.where(Item.status == status)
if min_price is not None:
stmt = stmt.where(Item.price >= min_price)
if max_price is not None:
stmt = stmt.where(Item.price <= max_price)
stmt = stmt.offset(skip).limit(limit).order_by(Item.name)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def count_filtered(
self,
query: str | None = None,
category_id: int | None = None,
location_id: int | None = None,
status: ItemStatus | None = None,
min_price: Decimal | None = None,
max_price: Decimal | None = None,
) -> int:
"""Compte les objets avec filtres.
Returns:
Nombre d'objets correspondants
"""
from sqlalchemy import func
stmt = select(func.count(Item.id))
if query:
search_term = f"%{query}%"
stmt = stmt.where(
or_(
Item.name.ilike(search_term),
Item.description.ilike(search_term),
Item.brand.ilike(search_term),
Item.model.ilike(search_term),
Item.notes.ilike(search_term),
)
)
if category_id is not None:
stmt = stmt.where(Item.category_id == category_id)
if location_id is not None:
stmt = stmt.where(Item.location_id == location_id)
if status is not None:
stmt = stmt.where(Item.status == status)
if min_price is not None:
stmt = stmt.where(Item.price >= min_price)
if max_price is not None:
stmt = stmt.where(Item.price <= max_price)
result = await self.db.execute(stmt)
return result.scalar_one()
async def get_by_category(
self, category_id: int, skip: int = 0, limit: int = 100
) -> list[Item]:
"""Récupère les objets d'une catégorie.
Args:
category_id: ID de la catégorie
skip: Offset
limit: Limite
Returns:
Liste des objets
"""
result = await self.db.execute(
select(Item)
.where(Item.category_id == category_id)
.offset(skip)
.limit(limit)
.order_by(Item.name)
)
return list(result.scalars().all())
async def get_by_location(
self, location_id: int, skip: int = 0, limit: int = 100
) -> list[Item]:
"""Récupère les objets d'un emplacement.
Args:
location_id: ID de l'emplacement
skip: Offset
limit: Limite
Returns:
Liste des objets
"""
result = await self.db.execute(
select(Item)
.where(Item.location_id == location_id)
.offset(skip)
.limit(limit)
.order_by(Item.name)
)
return list(result.scalars().all())
async def get_by_status(
self, status: ItemStatus, skip: int = 0, limit: int = 100
) -> list[Item]:
"""Récupère les objets par statut.
Args:
status: Statut recherché
skip: Offset
limit: Limite
Returns:
Liste des objets
"""
result = await self.db.execute(
select(Item)
.where(Item.status == status)
.offset(skip)
.limit(limit)
.order_by(Item.name)
)
return list(result.scalars().all())
async def get_by_serial_number(self, serial_number: str) -> Item | None:
"""Récupère un objet par son numéro de série.
Args:
serial_number: Numéro de série
Returns:
L'objet trouvé ou None
"""
result = await self.db.execute(
select(Item).where(Item.serial_number == serial_number)
)
return result.scalar_one_or_none()

View File

@@ -0,0 +1,171 @@
"""Repository pour les emplacements."""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.location import Location, LocationType
from app.repositories.base import BaseRepository
class LocationRepository(BaseRepository[Location]):
"""Repository pour les opérations sur les emplacements."""
def __init__(self, db: AsyncSession) -> None:
"""Initialise le repository."""
super().__init__(Location, db)
async def get_with_children(self, id: int) -> Location | None:
"""Récupère un emplacement avec ses enfants.
Args:
id: ID de l'emplacement
Returns:
L'emplacement avec ses enfants ou None
"""
result = await self.db.execute(
select(Location)
.options(selectinload(Location.children))
.where(Location.id == id)
)
return result.scalar_one_or_none()
async def get_root_locations(self) -> list[Location]:
"""Récupère tous les emplacements racine (sans parent).
Returns:
Liste des emplacements racine
"""
result = await self.db.execute(
select(Location)
.where(Location.parent_id.is_(None))
.order_by(Location.name)
)
return list(result.scalars().all())
async def get_children(self, parent_id: int) -> list[Location]:
"""Récupère les enfants directs d'un emplacement.
Args:
parent_id: ID du parent
Returns:
Liste des enfants
"""
result = await self.db.execute(
select(Location)
.where(Location.parent_id == parent_id)
.order_by(Location.name)
)
return list(result.scalars().all())
async def get_by_type(self, type: LocationType) -> list[Location]:
"""Récupère tous les emplacements d'un type donné.
Args:
type: Type d'emplacement
Returns:
Liste des emplacements
"""
result = await self.db.execute(
select(Location)
.where(Location.type == type)
.order_by(Location.path)
)
return list(result.scalars().all())
async def get_full_tree(self) -> list[Location]:
"""Récupère l'arborescence complète des emplacements.
Returns:
Liste des emplacements racine avec enfants chargés récursivement
"""
# Charger tous les emplacements avec leurs enfants
result = await self.db.execute(
select(Location)
.options(selectinload(Location.children))
.order_by(Location.path)
)
all_locations = list(result.scalars().all())
# Retourner seulement les racines (les enfants sont déjà chargés)
return [loc for loc in all_locations if loc.parent_id is None]
async def get_with_item_count(self, id: int) -> tuple[Location, int] | None:
"""Récupère un emplacement avec le nombre d'objets.
Args:
id: ID de l'emplacement
Returns:
Tuple (emplacement, nombre d'objets) ou None
"""
result = await self.db.execute(
select(Location)
.options(selectinload(Location.items))
.where(Location.id == id)
)
location = result.scalar_one_or_none()
if location is None:
return None
return location, len(location.items)
async def create_with_path(
self,
name: str,
type: LocationType,
parent_id: int | None = None,
description: str | None = None,
) -> Location:
"""Crée un emplacement avec calcul automatique du chemin.
Args:
name: Nom de l'emplacement
type: Type d'emplacement
parent_id: ID du parent (None si racine)
description: Description optionnelle
Returns:
L'emplacement créé
"""
# Calculer le chemin
if parent_id is None:
path = name
else:
parent = await self.get(parent_id)
if parent is None:
path = name
else:
path = f"{parent.path} > {name}"
return await self.create(
name=name,
type=type,
parent_id=parent_id,
path=path,
description=description,
)
async def update_paths_recursive(self, location: Location) -> None:
"""Met à jour récursivement les chemins après modification.
Args:
location: Emplacement modifié
"""
# Mettre à jour le chemin de cet emplacement
if location.parent_id is None:
location.path = location.name
else:
parent = await self.get(location.parent_id)
if parent:
location.path = f"{parent.path} > {location.name}"
else:
location.path = location.name
# Mettre à jour les enfants
children = await self.get_children(location.id)
for child in children:
child.path = f"{location.path} > {child.name}"
await self.update_paths_recursive(child)