From 2a6929fbb59c00b6a0eaf35b6f72cf8633f84787 Mon Sep 17 00:00:00 2001 From: Baptiste-Ferrand Date: Mon, 1 Sep 2025 17:57:23 +0200 Subject: [PATCH 01/12] create daily checkup repo --- .../sqlalchemy/repositories/daily_checkup.py | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 src/adapters/sqlalchemy/repositories/daily_checkup.py diff --git a/src/adapters/sqlalchemy/repositories/daily_checkup.py b/src/adapters/sqlalchemy/repositories/daily_checkup.py new file mode 100644 index 0000000..f2f18a0 --- /dev/null +++ b/src/adapters/sqlalchemy/repositories/daily_checkup.py @@ -0,0 +1,107 @@ +from typing import Optional, List +from uuid import UUID +import json +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_ +from sqlalchemy.orm import selectinload +from src.domain.model.daily_checkup import DailyCheckup +from src.domain.ports.daily_checkup_repository import DailyCheckupRepository +from src.infrastructure.adapters.sql.models.daily_checkup_model import DailyCheckupModel + +class SqlDailyCheckupRepository(DailyCheckupRepository): + def __init__(self, session: AsyncSession): + self._session = session + + async def add(self, daily_checkup: DailyCheckup) -> DailyCheckup: + """Ajoute un nouveau daily checkup en base""" + db_daily_checkup = DailyCheckupModel( + id=daily_checkup.id, + profile_id=daily_checkup.profile_id, + date=daily_checkup.date, + sleepduration=daily_checkup.sleepduration, + sleepquality=daily_checkup.sleepquality, + weight=daily_checkup.weight, + shape=daily_checkup.shape, + soreness=daily_checkup.soreness, + steps=daily_checkup.steps, + digestion=daily_checkup.digestion, + dayon=daily_checkup.dayon, + picture=json.dumps(daily_checkup.picture) if daily_checkup.picture else None, + created_at=daily_checkup.created_at, + updated_at=daily_checkup.updated_at + ) + self._session.add(db_daily_checkup) + await self._session.commit() + await self._session.refresh(db_daily_checkup) + return self._to_domain(db_daily_checkup) + + async def find_by_id(self, id: UUID) -> Optional[DailyCheckup]: + """Trouve un daily checkup par son ID""" + result = await self._session.execute( + select(DailyCheckupModel).where(DailyCheckupModel.id == id) + ) + db_daily_checkup = result.scalar_one_or_none() + if db_daily_checkup is None: + return None + return self._to_domain(db_daily_checkup) + + async def find_by_profile_id(self, profile_id: UUID) -> List[DailyCheckup]: + """Trouve tous les daily checkups d'un profil""" + result = await self._session.execute( + select(DailyCheckupModel) + .where(DailyCheckupModel.profile_id == profile_id) + .order_by(DailyCheckupModel.date.desc()) + ) + db_daily_checkups = result.scalars().all() + return [self._to_domain(db_daily_checkup) for db_daily_checkup in db_daily_checkups] + + async def find_by_profile_id_and_date(self, profile_id: UUID, date: str) -> Optional[DailyCheckup]: + """Trouve un daily checkup par profil et date""" + result = await self._session.execute( + select(DailyCheckupModel).where( + and_( + DailyCheckupModel.profile_id == profile_id, + DailyCheckupModel.date == date + ) + ) + ) + db_daily_checkup = result.scalar_one_or_none() + if db_daily_checkup is None: + return None + return self._to_domain(db_daily_checkup) + + async def delete(self, id: UUID) -> None: + """Supprime un daily checkup""" + result = await self._session.execute( + select(DailyCheckupModel).where(DailyCheckupModel.id == id) + ) + db_daily_checkup = result.scalar_one_or_none() + if db_daily_checkup is None: + raise ValueError(f"Daily checkup with id {id} not found") + await self._session.delete(db_daily_checkup) + await self._session.commit() + + def _to_domain(self, db_daily_checkup: DailyCheckupModel) -> DailyCheckup: + """Convertit un modèle DB en objet domain""" + picture_list = [] + if db_daily_checkup.picture: + try: + picture_list = json.loads(db_daily_checkup.picture) + except json.JSONDecodeError: + picture_list = [] + return DailyCheckup( + id=db_daily_checkup.id, + profile_id=db_daily_checkup.profile_id, + date=db_daily_checkup.date, + sleepduration=db_daily_checkup.sleepduration, + sleepquality=db_daily_checkup.sleepquality, + weight=db_daily_checkup.weight, + shape=db_daily_checkup.shape, + soreness=db_daily_checkup.soreness, + steps=db_daily_checkup.steps, + digestion=db_daily_checkup.digestion, + dayon=db_daily_checkup.dayon, + picture=picture_list, + created_at=db_daily_checkup.created_at, + updated_at=db_daily_checkup.updated_at + ) \ No newline at end of file From 9fb93712f19a5053a6c108b84ef44aa62f432760 Mon Sep 17 00:00:00 2001 From: Baptiste-Ferrand Date: Mon, 1 Sep 2025 17:57:44 +0200 Subject: [PATCH 02/12] create daily checkup mock --- .../inmemory/repositories/daily_checkup.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/adapters/inmemory/repositories/daily_checkup.py diff --git a/src/adapters/inmemory/repositories/daily_checkup.py b/src/adapters/inmemory/repositories/daily_checkup.py new file mode 100644 index 0000000..be278fd --- /dev/null +++ b/src/adapters/inmemory/repositories/daily_checkup.py @@ -0,0 +1,52 @@ +from uuid import UUID, uuid4 +from typing import Optional, List +from datetime import datetime + +from src.domain.model.daily_checkup import DailyCheckup as DomainDailyCheckup +from src.domain.ports.daily_checkup_repository import DailyCheckupRepository +from src.domain.exceptions import NotFoundError + +class InMemoryDailyCheckupRepository(DailyCheckupRepository): + def __init__(self): + self._daily_checkups: dict[UUID, DomainDailyCheckup] = {} + + async def add(self, daily_checkup: DomainDailyCheckup) -> DomainDailyCheckup: + """Ajoute un nouveau daily checkup""" + new_id = uuid4() + daily_checkup.id = new_id + if not getattr(daily_checkup, 'created_at', None): + daily_checkup.created_at = datetime.utcnow() + + existing = await self.find_by_profile_id_and_date(daily_checkup.profile_id, daily_checkup.date) + if existing: + raise ValueError(f"Daily checkup already exists for profile {daily_checkup.profile_id} on {daily_checkup.date}") + + self._daily_checkups[new_id] = daily_checkup + return daily_checkup + + async def find_by_id(self, id: UUID) -> Optional[DomainDailyCheckup]: + """Trouve un daily checkup par son ID""" + return self._daily_checkups.get(id) + + async def find_by_profile_id(self, profile_id: UUID) -> List[DomainDailyCheckup]: + """Trouve tous les daily checkups d'un profil""" + checkups = [dc for dc in self._daily_checkups.values() if dc.profile_id == profile_id] + return sorted(checkups, key=lambda x: x.date, reverse=True) + + async def find_by_profile_id_and_date(self, profile_id: UUID, date: str) -> Optional[DomainDailyCheckup]: + """Trouve un daily checkup par profil et date""" + for checkup in self._daily_checkups.values(): + if checkup.profile_id == profile_id and checkup.date == date: + return checkup + return None + + async def update(self, daily_checkup: DomainDailyCheckup) -> DomainDailyCheckup: + """Met à jour un daily checkup existant""" + if daily_checkup.id not in self._daily_checkups: + raise NotFoundError(f"Daily checkup {daily_checkup.id} not found") + self._daily_checkups[daily_checkup.id] = daily_checkup + return daily_checkup + + async def delete(self, id: UUID) -> None: + """Supprime un daily checkup""" + self._daily_checkups.pop(id, None) \ No newline at end of file From 733183d85c8ddbff12c142cf019423b2ee79c5f0 Mon Sep 17 00:00:00 2001 From: Baptiste-Ferrand Date: Mon, 1 Sep 2025 17:58:07 +0200 Subject: [PATCH 03/12] create daily checkup model and service --- src/domain/model/daily_checkup.py | 159 +++++++++++++++++++ src/domain/ports/daily_checkup_repository.py | 29 ++++ src/domain/services/daily_ckeckup.py | 104 ++++++++++++ 3 files changed, 292 insertions(+) create mode 100644 src/domain/model/daily_checkup.py create mode 100644 src/domain/ports/daily_checkup_repository.py create mode 100644 src/domain/services/daily_ckeckup.py diff --git a/src/domain/model/daily_checkup.py b/src/domain/model/daily_checkup.py new file mode 100644 index 0000000..0c24de5 --- /dev/null +++ b/src/domain/model/daily_checkup.py @@ -0,0 +1,159 @@ +from typing import List, Optional +from uuid import UUID, uuid4 +from datetime import datetime, date +import os +from src.domain.model.daily_checkup import DailyCheckup +from src.domain.ports.daily_checkup_repository import DailyCheckupRepository +from src.domain.ports.image_storage import ImageStorage +from src.domain.exceptions import NotFoundError + +class DailyCheckupService: + def __init__(self, repository: DailyCheckupRepository, image_storage: ImageStorage): + self._repo = repository + self._image_storage = image_storage + + def _generate_image_filename(self, original_filename: str, profile_id: UUID, index: int) -> str: + """Generate unique filename for daily checkup images""" + _, ext = os.path.splitext(original_filename) + if not ext: + ext = '.jpg' + + timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + unique_id = str(uuid4())[:8] + + return f"daily_checkups/{profile_id}/{timestamp}_{index}_{unique_id}{ext}" + + def _validate_image_file(self, filename: str) -> bool: + """Validate if file is an allowed image type""" + allowed_extensions = {'.jpg', '.jpeg', '.png', '.webp'} + _, ext = os.path.splitext(filename.lower()) + return ext in allowed_extensions + + async def _upload_images(self, picture_files: List[tuple], profile_id: UUID) -> List[str]: + """Upload images one by one and return array of URLs""" + picture_urls = [] + + if picture_files: + for i, (file_data, filename) in enumerate(picture_files): + if not self._validate_image_file(filename): + raise ValueError(f"Invalid file type for {filename}. Only JPG, JPEG, PNG, and WebP are allowed.") + + new_filename = self._generate_image_filename(filename, profile_id, i) + + url = await self._image_storage.upload(file_data, new_filename) + picture_urls.append(url) + + return picture_urls + + async def create( + self, + profile_id: UUID, + sleepduration: Optional[str] = None, + sleepquality: Optional[int] = None, + weight: Optional[float] = None, + shape: Optional[int] = None, + soreness: Optional[int] = None, + steps: Optional[int] = None, + digestion: Optional[int] = None, + dayon: Optional[bool] = None, + picture_files: Optional[List[tuple]] = None + ) -> DailyCheckup: + """Crée un nouveau daily checkup""" + + today = date.today().strftime('%Y-%m-%d') + existing = await self._repo.find_by_profile_id_and_date(profile_id, today) + if existing: + raise ValueError(f"Daily checkup already exists for {today}") + + picture_urls = await self._upload_images(picture_files, profile_id) + + daily_checkup = DailyCheckup( + profile_id=profile_id, + date=today, + sleepduration=sleepduration, + sleepquality=sleepquality, + weight=weight, + shape=shape, + soreness=soreness, + steps=steps, + digestion=digestion, + dayon=dayon, + picture=picture_urls + ) + + return await self._repo.add(daily_checkup) + + async def get_by_id(self, checkup_id: UUID) -> DailyCheckup: + """Récupère un daily checkup par son ID""" + checkup = await self._repo.find_by_id(checkup_id) + if not checkup: + raise NotFoundError(f"Daily checkup {checkup_id} not found") + return checkup + + async def get_by_profile_id(self, profile_id: UUID) -> List[DailyCheckup]: + """Récupère tous les daily checkups d'un profil""" + return await self._repo.find_by_profile_id(profile_id) + + async def get_by_profile_and_date(self, profile_id: UUID, date_str: str) -> Optional[DailyCheckup]: + """Récupère un daily checkup par profil et date""" + return await self._repo.find_by_profile_id_and_date(profile_id, date_str) + + async def update( + self, + checkup_id: UUID, + sleepduration: Optional[str] = None, + sleepquality: Optional[int] = None, + weight: Optional[float] = None, + shape: Optional[int] = None, + soreness: Optional[int] = None, + steps: Optional[int] = None, + digestion: Optional[int] = None, + dayon: Optional[bool] = None, + picture_files: Optional[List[tuple]] = None + ) -> DailyCheckup: + """Met à jour un daily checkup existant""" + + existing = await self.get_by_id(checkup_id) + + picture_urls = existing.picture.copy() if existing.picture else [] + + if picture_files: + new_urls = await self._upload_images(picture_files, existing.profile_id) + picture_urls.extend(new_urls) + + if sleepduration is not None: + existing.sleepduration = sleepduration + if sleepquality is not None: + existing.sleepquality = sleepquality + if weight is not None: + existing.weight = weight + if shape is not None: + existing.shape = shape + if soreness is not None: + existing.soreness = soreness + if steps is not None: + existing.steps = steps + if digestion is not None: + existing.digestion = digestion + if dayon is not None: + existing.dayon = dayon + if picture_files: + existing.picture = picture_urls + + existing._validate_score_fields() + + return await self._repo.update(existing) + + async def delete(self, checkup_id: UUID) -> None: + """Supprime un daily checkup""" + existing = await self.get_by_id(checkup_id) + + if existing.picture: + for picture_url in existing.picture: + try: + old_key = self._image_storage.extract_key_from_url(picture_url) + await self._image_storage.delete(old_key) + except Exception: + pass + + await self._repo.delete(checkup_id) \ No newline at end of file diff --git a/src/domain/ports/daily_checkup_repository.py b/src/domain/ports/daily_checkup_repository.py new file mode 100644 index 0000000..a186c34 --- /dev/null +++ b/src/domain/ports/daily_checkup_repository.py @@ -0,0 +1,29 @@ +from abc import ABC, abstractmethod +from typing import Optional, List +from uuid import UUID +from src.domain.model.daily_checkup import DailyCheckup + +class DailyCheckupRepository(ABC): + @abstractmethod + async def add(self, daily_checkup: DailyCheckup) -> DailyCheckup: + pass + + @abstractmethod + async def find_by_id(self, id: UUID) -> Optional[DailyCheckup]: + pass + + @abstractmethod + async def find_by_profile_id(self, profile_id: UUID) -> List[DailyCheckup]: + pass + + @abstractmethod + async def find_by_profile_id_and_date(self, profile_id: UUID, date: str) -> Optional[DailyCheckup]: + pass + + @abstractmethod + async def update(self, daily_checkup: DailyCheckup) -> DailyCheckup: + pass + + @abstractmethod + async def delete(self, id: UUID) -> None: + pass \ No newline at end of file diff --git a/src/domain/services/daily_ckeckup.py b/src/domain/services/daily_ckeckup.py new file mode 100644 index 0000000..e9b07e5 --- /dev/null +++ b/src/domain/services/daily_ckeckup.py @@ -0,0 +1,104 @@ +from typing import List, Optional +from uuid import UUID, uuid4 +from datetime import datetime, date +import os +from src.domain.model.daily_checkup import DailyCheckup +from src.domain.ports.daily_checkup_repository import DailyCheckupRepository +from src.domain.ports.image_storage import ImageStorage +from src.domain.exceptions import NotFoundError + +class DailyCheckupService: + def __init__(self, repository: DailyCheckupRepository, image_storage: ImageStorage): + self._repo = repository + self._image_storage = image_storage + + def _generate_image_filename(self, original_filename: str, profile_id: UUID, index: int) -> str: + """Generate unique filename for daily checkup images""" + _, ext = os.path.splitext(original_filename) + if not ext: + ext = '.jpg' + + timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + unique_id = str(uuid4())[:8] + + return f"daily_checkups/{profile_id}/{timestamp}_{index}_{unique_id}{ext}" + + def _validate_image_file(self, filename: str) -> bool: + """Validate if file is an allowed image type""" + allowed_extensions = {'.jpg', '.jpeg', '.png', '.webp'} + _, ext = os.path.splitext(filename.lower()) + return ext in allowed_extensions + + async def create( + self, + profile_id: UUID, + sleepduration: Optional[str] = None, + sleepquality: Optional[int] = None, + weight: Optional[float] = None, + shape: Optional[int] = None, + soreness: Optional[int] = None, + steps: Optional[int] = None, + digestion: Optional[int] = None, + dayon: Optional[bool] = None, + picture_files: Optional[List[tuple]] = None + ) -> DailyCheckup: + """Crée un nouveau daily checkup""" + + today = date.today().strftime('%Y-%m-%d') + existing = await self._repo.find_by_profile_id_and_date(profile_id, today) + if existing: + raise ValueError(f"Daily checkup already exists for {today}") + + picture_urls = [] + if picture_files: + for i, (file_data, filename) in enumerate(picture_files): + if not self._validate_image_file(filename): + raise ValueError(f"Invalid file type for {filename}. Only JPG, JPEG, PNG, and WebP are allowed.") + + new_filename = self._generate_image_filename(filename, profile_id, i) + url = await self._image_storage.upload(file_data, new_filename) + picture_urls.append(url) + + daily_checkup = DailyCheckup( + profile_id=profile_id, + sleepduration=sleepduration, + sleepquality=sleepquality, + weight=weight, + shape=shape, + soreness=soreness, + steps=steps, + digestion=digestion, + dayon=dayon, + picture=picture_urls + ) + + return await self._repo.add(daily_checkup) + + async def get_by_id(self, checkup_id: UUID) -> DailyCheckup: + """Récupère un daily checkup par son ID""" + checkup = await self._repo.find_by_id(checkup_id) + if not checkup: + raise NotFoundError(f"Daily checkup {checkup_id} not found") + return checkup + + async def get_by_profile_id(self, profile_id: UUID) -> List[DailyCheckup]: + """Récupère tous les daily checkups d'un profil""" + return await self._repo.find_by_profile_id(profile_id) + + async def get_by_profile_and_date(self, profile_id: UUID, date_str: str) -> Optional[DailyCheckup]: + """Récupère un daily checkup par profil et date""" + return await self._repo.find_by_profile_id_and_date(profile_id, date_str) + + async def delete(self, checkup_id: UUID) -> None: + """Supprime un daily checkup""" + existing = await self.get_by_id(checkup_id) + + if existing.picture: + for picture_url in existing.picture: + try: + old_key = self._image_storage.extract_key_from_url(picture_url) + await self._image_storage.delete(old_key) + except Exception: + pass + + await self._repo.delete(checkup_id) \ No newline at end of file From 98218956fec9694603f92f59e4d12bf8ea31bbff Mon Sep 17 00:00:00 2001 From: Baptiste-Ferrand Date: Mon, 1 Sep 2025 17:58:19 +0200 Subject: [PATCH 04/12] create daily checkup endpoiint and schema --- src/entrypoints/api/routers/daily_checkup.py | 175 +++++++++++++++++++ src/entrypoints/api/schemas/daily_checkup.py | 39 +++++ 2 files changed, 214 insertions(+) create mode 100644 src/entrypoints/api/routers/daily_checkup.py create mode 100644 src/entrypoints/api/schemas/daily_checkup.py diff --git a/src/entrypoints/api/routers/daily_checkup.py b/src/entrypoints/api/routers/daily_checkup.py new file mode 100644 index 0000000..3a42a12 --- /dev/null +++ b/src/entrypoints/api/routers/daily_checkup.py @@ -0,0 +1,175 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status +from typing import List, Optional +from uuid import UUID + +from src.entrypoints.api.schemas.daily_checkup import ( + DailyCheckupCreate, + DailyCheckupResponse, + DailyCheckupListResponse, + MessageResponse +) +from src.entrypoints.api.deps.auth import get_current_user +from src.domain.exceptions import NotFoundError +from src.container import container + +router = APIRouter(prefix="/daily-checkups", tags=["Daily Checkups"]) + +@router.post("/", response_model=DailyCheckupResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(get_current_user)]) +async def create_daily_checkup( + sleepduration: Optional[str] = Form(None), + sleepquality: Optional[int] = Form(None), + weight: Optional[float] = Form(None), + shape: Optional[int] = Form(None), + soreness: Optional[int] = Form(None), + steps: Optional[int] = Form(None), + digestion: Optional[int] = Form(None), + dayon: Optional[bool] = Form(None), + pictures: List[UploadFile] = File(default=[]), + user=Depends(get_current_user) +): + """Créer un nouveau daily checkup pour l'utilisateur connecté""" + service = container.get_daily_checkup_service() + + try: + # Validation des scores (1-10) + score_fields = { + "sleepquality": sleepquality, + "shape": shape, + "soreness": soreness, + "digestion": digestion + } + + for field_name, value in score_fields.items(): + if value is not None and (value < 1 or value > 10): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"{field_name} must be between 1 and 10" + ) + + if weight is not None and weight <= 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Weight must be greater than 0" + ) + + if steps is not None and steps < 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Steps must be greater than or equal to 0" + ) + + picture_files = [] + if pictures: + for picture in pictures: + if picture.filename: + content = await picture.read() + picture_files.append((content, picture.filename)) + + daily_checkup = await service.create( + profile_id=UUID(user["sub"]), + sleepduration=sleepduration, + sleepquality=sleepquality, + weight=weight, + shape=shape, + soreness=soreness, + steps=steps, + digestion=digestion, + dayon=dayon, + picture_files=picture_files if picture_files else None + ) + + return DailyCheckupResponse.model_validate(daily_checkup) + + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Internal server error: {str(e)}" + ) + +@router.get("/{checkup_id}", response_model=DailyCheckupResponse, dependencies=[Depends(get_current_user)]) +async def get_daily_checkup( + checkup_id: UUID, + user=Depends(get_current_user) +): + """Récupérer un daily checkup spécifique par son ID""" + service = container.get_daily_checkup_service() + + try: + daily_checkup = await service.get_by_id(checkup_id) + + if daily_checkup.profile_id != UUID(user["sub"]): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to access this daily checkup" + ) + + return DailyCheckupResponse.model_validate(daily_checkup) + + except NotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Daily checkup not found" + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Internal server error: {str(e)}" + ) + +@router.get("/", response_model=DailyCheckupListResponse, dependencies=[Depends(get_current_user)]) +async def get_user_daily_checkups( + user=Depends(get_current_user) +): + """Récupérer tous les daily checkups de l'utilisateur connecté""" + service = container.get_daily_checkup_service() + + try: + daily_checkups = await service.get_by_profile_id(UUID(user["sub"])) + + return DailyCheckupListResponse( + daily_checkups=[DailyCheckupResponse.model_validate(dc) for dc in daily_checkups], + total=len(daily_checkups) + ) + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Internal server error: {str(e)}" + ) + +@router.delete("/{checkup_id}", response_model=MessageResponse, dependencies=[Depends(get_current_user)]) +async def delete_daily_checkup( + checkup_id: UUID, + user=Depends(get_current_user) +): + """Supprimer un daily checkup spécifique""" + service = container.get_daily_checkup_service() + + try: + daily_checkup = await service.get_by_id(checkup_id) + + if daily_checkup.profile_id != UUID(user["sub"]): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to delete this daily checkup" + ) + + await service.delete(checkup_id) + + return MessageResponse(message="Daily checkup deleted successfully") + + except NotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Daily checkup not found" + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Internal server error: {str(e)}" + ) \ No newline at end of file diff --git a/src/entrypoints/api/schemas/daily_checkup.py b/src/entrypoints/api/schemas/daily_checkup.py new file mode 100644 index 0000000..8eea0b7 --- /dev/null +++ b/src/entrypoints/api/schemas/daily_checkup.py @@ -0,0 +1,39 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from uuid import UUID +from datetime import datetime + +class DailyCheckupCreate(BaseModel): + sleepduration: Optional[str] = Field(None, description="Durée de sommeil") + sleepquality: Optional[int] = Field(None, ge=1, le=10, description="Qualité du sommeil (1-10)") + weight: Optional[float] = Field(None, gt=0, description="Poids en kg") + shape: Optional[int] = Field(None, ge=1, le=10, description="Forme physique (1-10)") + soreness: Optional[int] = Field(None, ge=1, le=10, description="Courbatures (1-10)") + steps: Optional[int] = Field(None, ge=0, description="Nombre de pas") + digestion: Optional[int] = Field(None, ge=1, le=10, description="Digestion (1-10)") + dayon: Optional[bool] = Field(None, description="Jour on/off") + +class DailyCheckupResponse(BaseModel): + id: UUID + profile_id: UUID + date: str + sleepduration: Optional[str] + sleepquality: Optional[int] + weight: Optional[float] + shape: Optional[int] + soreness: Optional[int] + steps: Optional[int] + digestion: Optional[int] + dayon: Optional[bool] + picture: List[str] = Field(default_factory=list, description="URLs des images") + created_at: datetime + + class Config: + from_attributes = True + +class DailyCheckupListResponse(BaseModel): + daily_checkups: List[DailyCheckupResponse] + total: int + +class MessageResponse(BaseModel): + message: str \ No newline at end of file From 2e8e44f8241251308f87013f26a02cf9b0b1919f Mon Sep 17 00:00:00 2001 From: Baptiste-Ferrand Date: Mon, 1 Sep 2025 17:58:37 +0200 Subject: [PATCH 05/12] update container for daily checkup and main for include new router --- src/container.py | 30 ++++++++++++++++++++++++++++-- src/main.py | 2 ++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/container.py b/src/container.py index bf72f38..3c5283d 100644 --- a/src/container.py +++ b/src/container.py @@ -8,7 +8,7 @@ from src.domain.services.training import TrainingService from src.domain.services.exercise import ExerciseService from src.domain.services.diet import DietService - +from src.domain.services.daily_ckeckup import DailyCheckupService class Container: def __init__(self, env: str | None = None): self.env = env if env is not None else os.getenv("ENV", "dev") @@ -40,12 +40,14 @@ def __init__(self, env: str | None = None): from src.adapters.inmemory.repositories.exercise import InMemoryExerciseRepository from src.adapters.inmemory.repositories.diet import InMemoryDietRepository from src.adapters.inmemory.repositories.image_storage import InMemoryImageStorage + from src.adapters.inmemory.repositories.daily_checkup import InMemoryDailyCheckupRepository self.profile_repo = InMemoryProfileRepository(initial=[admin]) self.group_repo = InMemoryGroupRepository(self.profile_repo) self.training_repo = InMemoryTrainingRepository() self.exercise_repo = InMemoryExerciseRepository() self.diet_repo = InMemoryDietRepository() self.image_repo = InMemoryImageStorage() + self.daily_checkup_repo = InMemoryDailyCheckupRepository() else: from src.adapters.sqlalchemy.db import SessionLocal from src.adapters.minio.image_storage import MinioImageStorage @@ -164,5 +166,29 @@ async def method(*args, **kwargs): repo = SessionManagedRepository(SqlAlchemyDietRepository, self.SessionFactory) return DietService(repo) - + + def get_daily_checkup_service(self): + if self.env in ("dev", "test"): + repo = self.daily_checkup_repo + return DailyCheckupService(repo, self.image_repo) + else: + from src.adapters.sqlalchemy.repositories.daily_checkup import SqlDailyCheckupRepository + + class SessionManagedRepository: + def __init__(self, repo_class, session_factory): + self.repo_class = repo_class + self.session_factory = session_factory + + def __getattr__(self, name): + async def method(*args, **kwargs): + async with self.session_factory() as session: + repo = self.repo_class(session) + repo_method = getattr(repo, name) + return await repo_method(*args, **kwargs) + return method + + repo = SessionManagedRepository(SqlDailyCheckupRepository, self.SessionFactory) + return DailyCheckupService(repo, self.image_storage) + + container = Container() \ No newline at end of file diff --git a/src/main.py b/src/main.py index 4d88aaf..50d5b1d 100644 --- a/src/main.py +++ b/src/main.py @@ -16,6 +16,7 @@ from src.entrypoints.api.routers.exercise import router as exercise_router from src.entrypoints.api.routers.training import router as training_router from src.entrypoints.api.routers.diet import router as diet_router +from src.entrypoints.api.routers.daily_checkup import router as daily_checkup_router bearer_scheme = HTTPBearer() @@ -72,5 +73,6 @@ def custom_openapi(): app.include_router(exercise_router) app.include_router(training_router) app.include_router(diet_router) +app.include_router(daily_checkup_router) From deb78bc3374af9328fc7084c120ddcd3b8096e04 Mon Sep 17 00:00:00 2001 From: Baptiste-Ferrand Date: Mon, 1 Sep 2025 17:59:13 +0200 Subject: [PATCH 06/12] udpate image storage --- .../inmemory/repositories/image_storage.py | 20 ++++++++++++------- src/domain/ports/image_storage.py | 4 ++-- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/adapters/inmemory/repositories/image_storage.py b/src/adapters/inmemory/repositories/image_storage.py index 5cde65f..4973c84 100644 --- a/src/adapters/inmemory/repositories/image_storage.py +++ b/src/adapters/inmemory/repositories/image_storage.py @@ -1,19 +1,22 @@ -from typing import BinaryIO +from typing import BinaryIO, Union from src.domain.ports.image_storage import ImageStorage, ProfileImageType - class InMemoryImageStorage(ImageStorage): - """In-memory implementation for profile images testing""" + """In-memory implementation for images testing""" def __init__(self): self._files: dict[str, bytes] = {} self._upload_urls: dict[str, str] = {} - async def upload(self, file: BinaryIO, filename: str, image_type: ProfileImageType) -> str: + async def upload(self, file: BinaryIO, filename: str, image_type: Union[ProfileImageType, None] = None) -> str: """Store file content in memory and return mock URL""" content = file.read() self._files[filename] = content - return f"http://localhost/mock/profile-pictures/{filename}" + + if image_type and isinstance(image_type, ProfileImageType): + return f"http://localhost/mock/profile-pictures/{filename}" + else: + return f"http://localhost/mock/daily-checkups/{filename}" async def delete(self, object_key: str) -> None: """Remove file from memory storage""" @@ -23,9 +26,12 @@ def extract_key_from_url(self, url: str) -> str: """Extract filename from mock URL""" return url.split("/")[-1] - async def get_upload_url(self, filename: str, image_type: ProfileImageType) -> str: + async def get_upload_url(self, filename: str, image_type: Union[ProfileImageType, None] = None) -> str: """Generate mock presigned upload URL""" - url = f"http://localhost/mock/upload/profile-pictures/{filename}" + if image_type and isinstance(image_type, ProfileImageType): + url = f"http://localhost/mock/upload/profile-pictures/{filename}" + else: + url = f"http://localhost/mock/upload/daily-checkups/{filename}" self._upload_urls[filename] = url return url diff --git a/src/domain/ports/image_storage.py b/src/domain/ports/image_storage.py index 73d0150..2472f3e 100644 --- a/src/domain/ports/image_storage.py +++ b/src/domain/ports/image_storage.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import BinaryIO +from typing import BinaryIO, Union from enum import Enum @@ -12,7 +12,7 @@ class ImageStorage(ABC): """Interface for image storage operations for profile images""" @abstractmethod - async def upload(self, file: BinaryIO, filename: str, image_type: ProfileImageType) -> str: + async def upload(self, file: BinaryIO, filename: str, image_type: Union[ProfileImageType, None] = None) -> str: """Upload an image file to the profile pictures bucket and return the public URL""" pass From c51bf1b5a7ff7215384ee6d8c425ff0a158ff950 Mon Sep 17 00:00:00 2001 From: Baptiste-Ferrand Date: Tue, 2 Sep 2025 12:47:58 +0200 Subject: [PATCH 07/12] update minio for incline new bucket --- .../inmemory/repositories/image_storage.py | 66 +++++++---- src/adapters/minio/image_storage.py | 38 ++++++- src/container.py | 15 +-- src/domain/ports/image_storage.py | 8 +- src/domain/services/daily_ckeckup.py | 104 ------------------ 5 files changed, 91 insertions(+), 140 deletions(-) delete mode 100644 src/domain/services/daily_ckeckup.py diff --git a/src/adapters/inmemory/repositories/image_storage.py b/src/adapters/inmemory/repositories/image_storage.py index 4973c84..b1a0135 100644 --- a/src/adapters/inmemory/repositories/image_storage.py +++ b/src/adapters/inmemory/repositories/image_storage.py @@ -1,44 +1,72 @@ from typing import BinaryIO, Union -from src.domain.ports.image_storage import ImageStorage, ProfileImageType +from src.domain.ports.image_storage import ImageStorage class InMemoryImageStorage(ImageStorage): """In-memory implementation for images testing""" - + def __init__(self): self._files: dict[str, bytes] = {} self._upload_urls: dict[str, str] = {} - - async def upload(self, file: BinaryIO, filename: str, image_type: Union[ProfileImageType, None] = None) -> str: + + async def upload(self, file: BinaryIO, filename: str, image_type: Union[object, None] = None) -> str: """Store file content in memory and return mock URL""" content = file.read() self._files[filename] = content - - if image_type and isinstance(image_type, ProfileImageType): - return f"http://localhost/mock/profile-pictures/{filename}" - else: - return f"http://localhost/mock/daily-checkups/{filename}" - + + bucket = "mock" + if image_type is not None: + bucket_val = getattr(image_type, "value", None) + if bucket_val is not None: + if bucket_val == "profile_picture": + bucket = "profile-pictures" + elif bucket_val == "daily_checkup": + bucket = "users" + else: + bucket = bucket_val + elif isinstance(image_type, str): + if image_type == "profile_picture": + bucket = "profile-pictures" + elif image_type == "daily_checkup": + bucket = "users" + else: + bucket = image_type + return f"http://localhost/mock/{bucket}/{filename}" + async def delete(self, object_key: str) -> None: """Remove file from memory storage""" self._files.pop(object_key, None) - + def extract_key_from_url(self, url: str) -> str: """Extract filename from mock URL""" return url.split("/")[-1] - - async def get_upload_url(self, filename: str, image_type: Union[ProfileImageType, None] = None) -> str: + + async def get_upload_url(self, filename: str, image_type: Union[object, None] = None) -> str: """Generate mock presigned upload URL""" - if image_type and isinstance(image_type, ProfileImageType): - url = f"http://localhost/mock/upload/profile-pictures/{filename}" - else: - url = f"http://localhost/mock/upload/daily-checkups/{filename}" + bucket = "mock" + if image_type is not None: + bucket_val = getattr(image_type, "value", None) + if bucket_val is not None: + if bucket_val == "profile_picture": + bucket = "profile-pictures" + elif bucket_val == "daily_checkup": + bucket = "users" + else: + bucket = bucket_val + elif isinstance(image_type, str): + if image_type == "profile_picture": + bucket = "profile-pictures" + elif image_type == "daily_checkup": + bucket = "users" + else: + bucket = image_type + url = f"http://localhost/mock/upload/{bucket}/{filename}" self._upload_urls[filename] = url return url - + def get_stored_files(self) -> dict[str, bytes]: """Get all stored files (for testing)""" return self._files.copy() - + def file_exists(self, filename: str) -> bool: """Check if file exists in storage (for testing)""" return filename in self._files \ No newline at end of file diff --git a/src/adapters/minio/image_storage.py b/src/adapters/minio/image_storage.py index f99f0e3..9069255 100644 --- a/src/adapters/minio/image_storage.py +++ b/src/adapters/minio/image_storage.py @@ -2,17 +2,17 @@ import boto3 import mimetypes import os -from typing import BinaryIO +from typing import BinaryIO, Optional from botocore.exceptions import ClientError from src.domain.ports.image_storage import ImageStorage, ProfileImageType class MinioImageStorage(ImageStorage): - """Minio implementation for profile images storage""" + """Minio implementation for images storage with dynamic bucket support""" - def __init__(self): - self.bucket_name = os.getenv("MINIO_BUCKET_PP", "profile-pictures") + def __init__(self, bucket_name: Optional[str] = None): + self.bucket_name = bucket_name or os.getenv("MINIO_BUCKET_PP", "profile-pictures") self.region = os.getenv("MINIO_REGION", "us-east-1") self.public_url = os.getenv("MINIO_PUBLIC_URL", "http://localhost:9000") @@ -24,6 +24,36 @@ def __init__(self): aws_secret_access_key=os.getenv("MINIO_SECRET_KEY"), region_name=self.region, ) + + @classmethod + def for_profile_pictures(cls) -> 'MinioImageStorage': + """ + Factory method pour créer une instance dédiée aux images de profil + Utilise le bucket MINIO_BUCKET_PP + """ + bucket_name = os.getenv("MINIO_BUCKET_PP", "profile-pictures") + return cls(bucket_name=bucket_name) + + @classmethod + def for_daily_checkup(cls) -> 'MinioImageStorage': + """ + Factory method pour créer une instance dédiée aux daily checkups + Utilise le bucket MINIO_BUCKET_USERS + """ + bucket_name = os.getenv("MINIO_BUCKET_USERS", "users") + return cls(bucket_name=bucket_name) + + @classmethod + def for_custom_bucket(cls, bucket_env_var: str, default_bucket: str) -> 'MinioImageStorage': + """ + Factory method générique pour créer une instance avec un bucket personnalisé + + Args: + bucket_env_var: Nom de la variable d'environnement contenant le bucket + default_bucket: Nom du bucket par défaut si la variable d'env n'existe pas + """ + bucket_name = os.getenv(bucket_env_var, default_bucket) + return cls(bucket_name=bucket_name) def _guess_mime_type(self, filename: str) -> str: """Guess MIME type from filename""" diff --git a/src/container.py b/src/container.py index 3c5283d..688372d 100644 --- a/src/container.py +++ b/src/container.py @@ -8,7 +8,7 @@ from src.domain.services.training import TrainingService from src.domain.services.exercise import ExerciseService from src.domain.services.diet import DietService -from src.domain.services.daily_ckeckup import DailyCheckupService +from src.domain.services.daily_checkup import DailyCheckupService class Container: def __init__(self, env: str | None = None): self.env = env if env is not None else os.getenv("ENV", "dev") @@ -52,7 +52,8 @@ def __init__(self, env: str | None = None): from src.adapters.sqlalchemy.db import SessionLocal from src.adapters.minio.image_storage import MinioImageStorage self.SessionFactory = SessionLocal - self.image_storage = MinioImageStorage() + self.profile_image_storage = MinioImageStorage.for_profile_pictures() + self.daily_checkup_image_storage = MinioImageStorage.for_daily_checkup() def get_profile_service(self): if self.env in ("dev", "test"): @@ -72,8 +73,8 @@ async def method(*args, **kwargs): repo_method = getattr(repo, name) return await repo_method(*args, **kwargs) return method - repo = SessionManagedRepository(SqlAlchemyProfileRepository, self.SessionFactory) - return ProfileService(repo, self.hasher, self.image_storage) + repo = SessionManagedRepository(SqlAlchemyProfileRepository, self.SessionFactory) + return ProfileService(repo, self.hasher, self.profile_image_storage) def get_group_service(self): if self.env in ("dev", "test"): @@ -172,7 +173,7 @@ def get_daily_checkup_service(self): repo = self.daily_checkup_repo return DailyCheckupService(repo, self.image_repo) else: - from src.adapters.sqlalchemy.repositories.daily_checkup import SqlDailyCheckupRepository + from src.adapters.sqlalchemy.repositories.daily_checkup import SqlAlchemyDailyCheckupRepository class SessionManagedRepository: def __init__(self, repo_class, session_factory): @@ -187,8 +188,8 @@ async def method(*args, **kwargs): return await repo_method(*args, **kwargs) return method - repo = SessionManagedRepository(SqlDailyCheckupRepository, self.SessionFactory) - return DailyCheckupService(repo, self.image_storage) + repo = SessionManagedRepository(SqlAlchemyDailyCheckupRepository, self.SessionFactory) + return DailyCheckupService(repo, self.daily_checkup_image_storage) container = Container() \ No newline at end of file diff --git a/src/domain/ports/image_storage.py b/src/domain/ports/image_storage.py index 2472f3e..88afccc 100644 --- a/src/domain/ports/image_storage.py +++ b/src/domain/ports/image_storage.py @@ -6,27 +6,23 @@ class ProfileImageType(Enum): PROFILE_PICTURE = "profile_picture" BACKGROUND_PICTURE = "background_picture" - +class DailyCheckupImageType(Enum): + DAILY_CHECKUP = "daily_checkup" class ImageStorage(ABC): - """Interface for image storage operations for profile images""" @abstractmethod async def upload(self, file: BinaryIO, filename: str, image_type: Union[ProfileImageType, None] = None) -> str: - """Upload an image file to the profile pictures bucket and return the public URL""" pass @abstractmethod async def delete(self, object_key: str) -> None: - """Delete an image by its object key from the profile pictures bucket""" pass @abstractmethod def extract_key_from_url(self, url: str) -> str: - """Extract object key from a full URL""" pass @abstractmethod async def get_upload_url(self, filename: str, image_type: ProfileImageType) -> str: - """Generate a presigned upload URL for direct client upload""" pass \ No newline at end of file diff --git a/src/domain/services/daily_ckeckup.py b/src/domain/services/daily_ckeckup.py deleted file mode 100644 index e9b07e5..0000000 --- a/src/domain/services/daily_ckeckup.py +++ /dev/null @@ -1,104 +0,0 @@ -from typing import List, Optional -from uuid import UUID, uuid4 -from datetime import datetime, date -import os -from src.domain.model.daily_checkup import DailyCheckup -from src.domain.ports.daily_checkup_repository import DailyCheckupRepository -from src.domain.ports.image_storage import ImageStorage -from src.domain.exceptions import NotFoundError - -class DailyCheckupService: - def __init__(self, repository: DailyCheckupRepository, image_storage: ImageStorage): - self._repo = repository - self._image_storage = image_storage - - def _generate_image_filename(self, original_filename: str, profile_id: UUID, index: int) -> str: - """Generate unique filename for daily checkup images""" - _, ext = os.path.splitext(original_filename) - if not ext: - ext = '.jpg' - - timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") - unique_id = str(uuid4())[:8] - - return f"daily_checkups/{profile_id}/{timestamp}_{index}_{unique_id}{ext}" - - def _validate_image_file(self, filename: str) -> bool: - """Validate if file is an allowed image type""" - allowed_extensions = {'.jpg', '.jpeg', '.png', '.webp'} - _, ext = os.path.splitext(filename.lower()) - return ext in allowed_extensions - - async def create( - self, - profile_id: UUID, - sleepduration: Optional[str] = None, - sleepquality: Optional[int] = None, - weight: Optional[float] = None, - shape: Optional[int] = None, - soreness: Optional[int] = None, - steps: Optional[int] = None, - digestion: Optional[int] = None, - dayon: Optional[bool] = None, - picture_files: Optional[List[tuple]] = None - ) -> DailyCheckup: - """Crée un nouveau daily checkup""" - - today = date.today().strftime('%Y-%m-%d') - existing = await self._repo.find_by_profile_id_and_date(profile_id, today) - if existing: - raise ValueError(f"Daily checkup already exists for {today}") - - picture_urls = [] - if picture_files: - for i, (file_data, filename) in enumerate(picture_files): - if not self._validate_image_file(filename): - raise ValueError(f"Invalid file type for {filename}. Only JPG, JPEG, PNG, and WebP are allowed.") - - new_filename = self._generate_image_filename(filename, profile_id, i) - url = await self._image_storage.upload(file_data, new_filename) - picture_urls.append(url) - - daily_checkup = DailyCheckup( - profile_id=profile_id, - sleepduration=sleepduration, - sleepquality=sleepquality, - weight=weight, - shape=shape, - soreness=soreness, - steps=steps, - digestion=digestion, - dayon=dayon, - picture=picture_urls - ) - - return await self._repo.add(daily_checkup) - - async def get_by_id(self, checkup_id: UUID) -> DailyCheckup: - """Récupère un daily checkup par son ID""" - checkup = await self._repo.find_by_id(checkup_id) - if not checkup: - raise NotFoundError(f"Daily checkup {checkup_id} not found") - return checkup - - async def get_by_profile_id(self, profile_id: UUID) -> List[DailyCheckup]: - """Récupère tous les daily checkups d'un profil""" - return await self._repo.find_by_profile_id(profile_id) - - async def get_by_profile_and_date(self, profile_id: UUID, date_str: str) -> Optional[DailyCheckup]: - """Récupère un daily checkup par profil et date""" - return await self._repo.find_by_profile_id_and_date(profile_id, date_str) - - async def delete(self, checkup_id: UUID) -> None: - """Supprime un daily checkup""" - existing = await self.get_by_id(checkup_id) - - if existing.picture: - for picture_url in existing.picture: - try: - old_key = self._image_storage.extract_key_from_url(picture_url) - await self._image_storage.delete(old_key) - except Exception: - pass - - await self._repo.delete(checkup_id) \ No newline at end of file From 696c1ce4ba9165ea21580b06b10d3392ff052589 Mon Sep 17 00:00:00 2001 From: Baptiste-Ferrand Date: Tue, 2 Sep 2025 12:48:10 +0200 Subject: [PATCH 08/12] created new exception --- src/domain/exceptions.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/domain/exceptions.py b/src/domain/exceptions.py index 5cc0030..2d9e25c 100644 --- a/src/domain/exceptions.py +++ b/src/domain/exceptions.py @@ -36,3 +36,7 @@ class TokenMissingError(DomainError): class AuthenticationError(DomainError): """Authentification failed.""" + +class ValidationError(DomainError): + """Validation error.""" + pass \ No newline at end of file From 523039e14941121aa3a7f934aacf2c64f67ffacb Mon Sep 17 00:00:00 2001 From: Baptiste-Ferrand Date: Tue, 2 Sep 2025 12:48:41 +0200 Subject: [PATCH 09/12] update and finsh inmemory and sqlalchemy repo --- .../inmemory/repositories/daily_checkup.py | 37 ++--- .../sqlalchemy/repositories/daily_checkup.py | 156 +++++++++--------- 2 files changed, 98 insertions(+), 95 deletions(-) diff --git a/src/adapters/inmemory/repositories/daily_checkup.py b/src/adapters/inmemory/repositories/daily_checkup.py index be278fd..9d6e346 100644 --- a/src/adapters/inmemory/repositories/daily_checkup.py +++ b/src/adapters/inmemory/repositories/daily_checkup.py @@ -1,6 +1,6 @@ from uuid import UUID, uuid4 from typing import Optional, List -from datetime import datetime +from datetime import datetime, date from src.domain.model.daily_checkup import DailyCheckup as DomainDailyCheckup from src.domain.ports.daily_checkup_repository import DailyCheckupRepository @@ -12,16 +12,15 @@ def __init__(self): async def add(self, daily_checkup: DomainDailyCheckup) -> DomainDailyCheckup: """Ajoute un nouveau daily checkup""" - new_id = uuid4() - daily_checkup.id = new_id - if not getattr(daily_checkup, 'created_at', None): + if not getattr(daily_checkup, "id", None): + daily_checkup.id = uuid4() + if not getattr(daily_checkup, "created_at", None): daily_checkup.created_at = datetime.utcnow() - - existing = await self.find_by_profile_id_and_date(daily_checkup.profile_id, daily_checkup.date) + today = daily_checkup.created_at.date() + existing = await self.find_by_profile_id_and_date(daily_checkup.profile_id, today) if existing: - raise ValueError(f"Daily checkup already exists for profile {daily_checkup.profile_id} on {daily_checkup.date}") - - self._daily_checkups[new_id] = daily_checkup + raise ValueError(f"Daily checkup already exists for profile {daily_checkup.profile_id} on {today}") + self._daily_checkups[daily_checkup.id] = daily_checkup return daily_checkup async def find_by_id(self, id: UUID) -> Optional[DomainDailyCheckup]: @@ -29,24 +28,20 @@ async def find_by_id(self, id: UUID) -> Optional[DomainDailyCheckup]: return self._daily_checkups.get(id) async def find_by_profile_id(self, profile_id: UUID) -> List[DomainDailyCheckup]: - """Trouve tous les daily checkups d'un profil""" + """Trouve tous les daily checkups d'un profil, triés du plus récent au plus ancien""" checkups = [dc for dc in self._daily_checkups.values() if dc.profile_id == profile_id] - return sorted(checkups, key=lambda x: x.date, reverse=True) + return sorted(checkups, key=lambda x: x.created_at, reverse=True) - async def find_by_profile_id_and_date(self, profile_id: UUID, date: str) -> Optional[DomainDailyCheckup]: - """Trouve un daily checkup par profil et date""" + async def find_by_profile_id_and_date(self, profile_id: UUID, target_date: date) -> Optional[DomainDailyCheckup]: + """Trouve un daily checkup par profil et date (même jour)""" for checkup in self._daily_checkups.values(): - if checkup.profile_id == profile_id and checkup.date == date: + if ( + checkup.profile_id == profile_id + and checkup.created_at.date() == target_date + ): return checkup return None - async def update(self, daily_checkup: DomainDailyCheckup) -> DomainDailyCheckup: - """Met à jour un daily checkup existant""" - if daily_checkup.id not in self._daily_checkups: - raise NotFoundError(f"Daily checkup {daily_checkup.id} not found") - self._daily_checkups[daily_checkup.id] = daily_checkup - return daily_checkup - async def delete(self, id: UUID) -> None: """Supprime un daily checkup""" self._daily_checkups.pop(id, None) \ No newline at end of file diff --git a/src/adapters/sqlalchemy/repositories/daily_checkup.py b/src/adapters/sqlalchemy/repositories/daily_checkup.py index f2f18a0..389df22 100644 --- a/src/adapters/sqlalchemy/repositories/daily_checkup.py +++ b/src/adapters/sqlalchemy/repositories/daily_checkup.py @@ -1,23 +1,23 @@ -from typing import Optional, List +from typing import List, Optional from uuid import UUID -import json +from datetime import datetime, date from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, and_ -from sqlalchemy.orm import selectinload -from src.domain.model.daily_checkup import DailyCheckup +from sqlalchemy import select, and_, delete +from src.domain.exceptions import NotFoundError +from src.adapters.sqlalchemy.models import DailyCheckup as ORMDailyCheckup +from src.domain.model.daily_checkup import DailyCheckup as DomainDailyCheckup from src.domain.ports.daily_checkup_repository import DailyCheckupRepository -from src.infrastructure.adapters.sql.models.daily_checkup_model import DailyCheckupModel -class SqlDailyCheckupRepository(DailyCheckupRepository): + +class SqlAlchemyDailyCheckupRepository(DailyCheckupRepository): def __init__(self, session: AsyncSession): self._session = session - async def add(self, daily_checkup: DailyCheckup) -> DailyCheckup: - """Ajoute un nouveau daily checkup en base""" - db_daily_checkup = DailyCheckupModel( + async def add(self, daily_checkup: DomainDailyCheckup) -> DomainDailyCheckup: + """Ajoute un nouveau daily checkup""" + orm_checkup = ORMDailyCheckup( id=daily_checkup.id, profile_id=daily_checkup.profile_id, - date=daily_checkup.date, sleepduration=daily_checkup.sleepduration, sleepquality=daily_checkup.sleepquality, weight=daily_checkup.weight, @@ -26,82 +26,90 @@ async def add(self, daily_checkup: DailyCheckup) -> DailyCheckup: steps=daily_checkup.steps, digestion=daily_checkup.digestion, dayon=daily_checkup.dayon, - picture=json.dumps(daily_checkup.picture) if daily_checkup.picture else None, + picture=daily_checkup.picture, created_at=daily_checkup.created_at, - updated_at=daily_checkup.updated_at ) - self._session.add(db_daily_checkup) + self._session.add(orm_checkup) await self._session.commit() - await self._session.refresh(db_daily_checkup) - return self._to_domain(db_daily_checkup) + await self._session.refresh(orm_checkup) + return self._to_domain(orm_checkup) - async def find_by_id(self, id: UUID) -> Optional[DailyCheckup]: + async def find_by_id(self, id: UUID) -> Optional[DomainDailyCheckup]: """Trouve un daily checkup par son ID""" - result = await self._session.execute( - select(DailyCheckupModel).where(DailyCheckupModel.id == id) - ) - db_daily_checkup = result.scalar_one_or_none() - if db_daily_checkup is None: - return None - return self._to_domain(db_daily_checkup) + stmt = select(ORMDailyCheckup).where(ORMDailyCheckup.id == id) + result = await self._session.execute(stmt) + orm_checkup = result.scalar_one_or_none() + if orm_checkup: + return self._to_domain(orm_checkup) + return None - async def find_by_profile_id(self, profile_id: UUID) -> List[DailyCheckup]: + async def find_by_profile_id(self, profile_id: UUID) -> List[DomainDailyCheckup]: """Trouve tous les daily checkups d'un profil""" - result = await self._session.execute( - select(DailyCheckupModel) - .where(DailyCheckupModel.profile_id == profile_id) - .order_by(DailyCheckupModel.date.desc()) - ) - db_daily_checkups = result.scalars().all() - return [self._to_domain(db_daily_checkup) for db_daily_checkup in db_daily_checkups] + stmt = select(ORMDailyCheckup).where( + ORMDailyCheckup.profile_id == profile_id + ).order_by(ORMDailyCheckup.created_at.desc()) + result = await self._session.execute(stmt) + orm_checkups = result.scalars().all() + return [self._to_domain(checkup) for checkup in orm_checkups] - async def find_by_profile_id_and_date(self, profile_id: UUID, date: str) -> Optional[DailyCheckup]: + async def find_by_profile_id_and_date(self, profile_id: UUID, target_date: date) -> Optional[DomainDailyCheckup]: """Trouve un daily checkup par profil et date""" - result = await self._session.execute( - select(DailyCheckupModel).where( - and_( - DailyCheckupModel.profile_id == profile_id, - DailyCheckupModel.date == date - ) + start_of_day = datetime.combine(target_date, datetime.min.time()) + end_of_day = datetime.combine(target_date, datetime.max.time()) + + stmt = select(ORMDailyCheckup).where( + and_( + ORMDailyCheckup.profile_id == profile_id, + ORMDailyCheckup.created_at >= start_of_day, + ORMDailyCheckup.created_at <= end_of_day ) ) - db_daily_checkup = result.scalar_one_or_none() - if db_daily_checkup is None: - return None - return self._to_domain(db_daily_checkup) + result = await self._session.execute(stmt) + orm_checkup = result.scalar_one_or_none() + if orm_checkup: + return self._to_domain(orm_checkup) + return None + + async def update(self, daily_checkup: DomainDailyCheckup) -> DomainDailyCheckup: + """Met à jour un daily checkup existant""" + orm_checkup = await self._session.get(ORMDailyCheckup, daily_checkup.id) + if not orm_checkup: + raise NotFoundError(f"Daily checkup {daily_checkup.id} not found") + + orm_checkup.sleepduration = daily_checkup.sleepduration + orm_checkup.sleepquality = daily_checkup.sleepquality + orm_checkup.weight = daily_checkup.weight + orm_checkup.shape = daily_checkup.shape + orm_checkup.soreness = daily_checkup.soreness + orm_checkup.steps = daily_checkup.steps + orm_checkup.digestion = daily_checkup.digestion + orm_checkup.dayon = daily_checkup.dayon + orm_checkup.picture = daily_checkup.picture + + await self._session.commit() + await self._session.refresh(orm_checkup) + return self._to_domain(orm_checkup) async def delete(self, id: UUID) -> None: """Supprime un daily checkup""" - result = await self._session.execute( - select(DailyCheckupModel).where(DailyCheckupModel.id == id) - ) - db_daily_checkup = result.scalar_one_or_none() - if db_daily_checkup is None: - raise ValueError(f"Daily checkup with id {id} not found") - await self._session.delete(db_daily_checkup) - await self._session.commit() + orm_checkup = await self._session.get(ORMDailyCheckup, id) + if orm_checkup: + await self._session.delete(orm_checkup) + await self._session.commit() - def _to_domain(self, db_daily_checkup: DailyCheckupModel) -> DailyCheckup: - """Convertit un modèle DB en objet domain""" - picture_list = [] - if db_daily_checkup.picture: - try: - picture_list = json.loads(db_daily_checkup.picture) - except json.JSONDecodeError: - picture_list = [] - return DailyCheckup( - id=db_daily_checkup.id, - profile_id=db_daily_checkup.profile_id, - date=db_daily_checkup.date, - sleepduration=db_daily_checkup.sleepduration, - sleepquality=db_daily_checkup.sleepquality, - weight=db_daily_checkup.weight, - shape=db_daily_checkup.shape, - soreness=db_daily_checkup.soreness, - steps=db_daily_checkup.steps, - digestion=db_daily_checkup.digestion, - dayon=db_daily_checkup.dayon, - picture=picture_list, - created_at=db_daily_checkup.created_at, - updated_at=db_daily_checkup.updated_at + def _to_domain(self, orm_checkup: ORMDailyCheckup) -> DomainDailyCheckup: + """Convertit un modèle ORM en modèle de domaine""" + return DomainDailyCheckup( + id=orm_checkup.id, + profile_id=orm_checkup.profile_id, + sleepduration=orm_checkup.sleepduration, + sleepquality=orm_checkup.sleepquality, + weight=orm_checkup.weight, + shape=orm_checkup.shape, + soreness=orm_checkup.soreness, + steps=orm_checkup.steps, + digestion=orm_checkup.digestion, + dayon=orm_checkup.dayon, + picture=orm_checkup.picture or [], + created_at=orm_checkup.created_at, ) \ No newline at end of file From 85fdc867d200ac5ae514cb2ef3b37a6aa5de19c6 Mon Sep 17 00:00:00 2001 From: Baptiste-Ferrand Date: Tue, 2 Sep 2025 12:49:04 +0200 Subject: [PATCH 10/12] finish service for daily checkup port and models --- src/domain/model/daily_checkup.py | 202 +++++-------------- src/domain/ports/daily_checkup_repository.py | 9 +- src/domain/services/daily_checkup.py | 149 ++++++++++++++ 3 files changed, 197 insertions(+), 163 deletions(-) create mode 100644 src/domain/services/daily_checkup.py diff --git a/src/domain/model/daily_checkup.py b/src/domain/model/daily_checkup.py index 0c24de5..9828e2f 100644 --- a/src/domain/model/daily_checkup.py +++ b/src/domain/model/daily_checkup.py @@ -1,159 +1,47 @@ +from dataclasses import dataclass, field +from uuid import UUID +from datetime import datetime from typing import List, Optional -from uuid import UUID, uuid4 -from datetime import datetime, date -import os -from src.domain.model.daily_checkup import DailyCheckup -from src.domain.ports.daily_checkup_repository import DailyCheckupRepository -from src.domain.ports.image_storage import ImageStorage -from src.domain.exceptions import NotFoundError -class DailyCheckupService: - def __init__(self, repository: DailyCheckupRepository, image_storage: ImageStorage): - self._repo = repository - self._image_storage = image_storage - - def _generate_image_filename(self, original_filename: str, profile_id: UUID, index: int) -> str: - """Generate unique filename for daily checkup images""" - _, ext = os.path.splitext(original_filename) - if not ext: - ext = '.jpg' - - timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") - unique_id = str(uuid4())[:8] - - return f"daily_checkups/{profile_id}/{timestamp}_{index}_{unique_id}{ext}" - - def _validate_image_file(self, filename: str) -> bool: - """Validate if file is an allowed image type""" - allowed_extensions = {'.jpg', '.jpeg', '.png', '.webp'} - _, ext = os.path.splitext(filename.lower()) - return ext in allowed_extensions - - async def _upload_images(self, picture_files: List[tuple], profile_id: UUID) -> List[str]: - """Upload images one by one and return array of URLs""" - picture_urls = [] - - if picture_files: - for i, (file_data, filename) in enumerate(picture_files): - if not self._validate_image_file(filename): - raise ValueError(f"Invalid file type for {filename}. Only JPG, JPEG, PNG, and WebP are allowed.") - - new_filename = self._generate_image_filename(filename, profile_id, i) - - url = await self._image_storage.upload(file_data, new_filename) - picture_urls.append(url) - - return picture_urls - - async def create( - self, - profile_id: UUID, - sleepduration: Optional[str] = None, - sleepquality: Optional[int] = None, - weight: Optional[float] = None, - shape: Optional[int] = None, - soreness: Optional[int] = None, - steps: Optional[int] = None, - digestion: Optional[int] = None, - dayon: Optional[bool] = None, - picture_files: Optional[List[tuple]] = None - ) -> DailyCheckup: - """Crée un nouveau daily checkup""" - - today = date.today().strftime('%Y-%m-%d') - existing = await self._repo.find_by_profile_id_and_date(profile_id, today) - if existing: - raise ValueError(f"Daily checkup already exists for {today}") - - picture_urls = await self._upload_images(picture_files, profile_id) - - daily_checkup = DailyCheckup( - profile_id=profile_id, - date=today, - sleepduration=sleepduration, - sleepquality=sleepquality, - weight=weight, - shape=shape, - soreness=soreness, - steps=steps, - digestion=digestion, - dayon=dayon, - picture=picture_urls - ) - - return await self._repo.add(daily_checkup) - - async def get_by_id(self, checkup_id: UUID) -> DailyCheckup: - """Récupère un daily checkup par son ID""" - checkup = await self._repo.find_by_id(checkup_id) - if not checkup: - raise NotFoundError(f"Daily checkup {checkup_id} not found") - return checkup - - async def get_by_profile_id(self, profile_id: UUID) -> List[DailyCheckup]: - """Récupère tous les daily checkups d'un profil""" - return await self._repo.find_by_profile_id(profile_id) - - async def get_by_profile_and_date(self, profile_id: UUID, date_str: str) -> Optional[DailyCheckup]: - """Récupère un daily checkup par profil et date""" - return await self._repo.find_by_profile_id_and_date(profile_id, date_str) - - async def update( - self, - checkup_id: UUID, - sleepduration: Optional[str] = None, - sleepquality: Optional[int] = None, - weight: Optional[float] = None, - shape: Optional[int] = None, - soreness: Optional[int] = None, - steps: Optional[int] = None, - digestion: Optional[int] = None, - dayon: Optional[bool] = None, - picture_files: Optional[List[tuple]] = None - ) -> DailyCheckup: - """Met à jour un daily checkup existant""" - - existing = await self.get_by_id(checkup_id) - - picture_urls = existing.picture.copy() if existing.picture else [] - - if picture_files: - new_urls = await self._upload_images(picture_files, existing.profile_id) - picture_urls.extend(new_urls) - - if sleepduration is not None: - existing.sleepduration = sleepduration - if sleepquality is not None: - existing.sleepquality = sleepquality - if weight is not None: - existing.weight = weight - if shape is not None: - existing.shape = shape - if soreness is not None: - existing.soreness = soreness - if steps is not None: - existing.steps = steps - if digestion is not None: - existing.digestion = digestion - if dayon is not None: - existing.dayon = dayon - if picture_files: - existing.picture = picture_urls - - existing._validate_score_fields() - - return await self._repo.update(existing) - - async def delete(self, checkup_id: UUID) -> None: - """Supprime un daily checkup""" - existing = await self.get_by_id(checkup_id) - - if existing.picture: - for picture_url in existing.picture: - try: - old_key = self._image_storage.extract_key_from_url(picture_url) - await self._image_storage.delete(old_key) - except Exception: - pass - - await self._repo.delete(checkup_id) \ No newline at end of file +@dataclass +class DailyCheckup: + profile_id: UUID + id: Optional[UUID] = None + sleepduration: Optional[str] = None + sleepquality: Optional[int] = None + weight: Optional[float] = None + shape: Optional[int] = None + soreness: Optional[int] = None + steps: Optional[int] = None + digestion: Optional[int] = None + dayon: Optional[bool] = None + picture: List[str] = field(default_factory=list) + created_at: datetime = field(default_factory=datetime.utcnow) + + def _validate_score_fields(self): + score_fields = { + 'sleepquality': self.sleepquality, + 'shape': self.shape, + 'soreness': self.soreness, + 'digestion': self.digestion + } + + for field_name, value in score_fields.items(): + if value is not None and not (1 <= value <= 10): + raise ValueError(f"{field_name} must be between 1 and 10") + + def to_orm_dict(self) -> dict: + return { + 'id': self.id, + 'profile_id': self.profile_id, + 'sleepduration': self.sleepduration, + 'sleepquality': self.sleepquality, + 'weight': self.weight, + 'shape': self.shape, + 'soreness': self.soreness, + 'steps': self.steps, + 'digestion': self.digestion, + 'dayon': self.dayon, + 'picture': self.picture, + 'created_at': self.created_at, + } \ No newline at end of file diff --git a/src/domain/ports/daily_checkup_repository.py b/src/domain/ports/daily_checkup_repository.py index a186c34..db6d81f 100644 --- a/src/domain/ports/daily_checkup_repository.py +++ b/src/domain/ports/daily_checkup_repository.py @@ -1,6 +1,7 @@ from abc import ABC, abstractmethod -from typing import Optional, List +from typing import List, Optional from uuid import UUID +from datetime import date from src.domain.model.daily_checkup import DailyCheckup class DailyCheckupRepository(ABC): @@ -17,11 +18,7 @@ async def find_by_profile_id(self, profile_id: UUID) -> List[DailyCheckup]: pass @abstractmethod - async def find_by_profile_id_and_date(self, profile_id: UUID, date: str) -> Optional[DailyCheckup]: - pass - - @abstractmethod - async def update(self, daily_checkup: DailyCheckup) -> DailyCheckup: + async def find_by_profile_id_and_date(self, profile_id: UUID, target_date: date) -> Optional[DailyCheckup]: pass @abstractmethod diff --git a/src/domain/services/daily_checkup.py b/src/domain/services/daily_checkup.py new file mode 100644 index 0000000..7d71978 --- /dev/null +++ b/src/domain/services/daily_checkup.py @@ -0,0 +1,149 @@ +from typing import List, Optional +from uuid import UUID, uuid4 +from datetime import datetime, date +import os +from src.domain.model.daily_checkup import DailyCheckup +from src.domain.ports.daily_checkup_repository import DailyCheckupRepository +from src.domain.ports.image_storage import DailyCheckupImageType, ImageStorage +from src.domain.exceptions import NotFoundError, ValidationError + + +class DailyCheckupService: + def __init__(self, repository: DailyCheckupRepository, image_storage: ImageStorage): + self._repo = repository + self._image_storage = image_storage + + def _generate_image_filename(self, original_filename: str, profile_id: UUID, index: int) -> str: + """Generate unique filename for daily checkup images""" + _, ext = os.path.splitext(original_filename) + if not ext: + ext = '.jpg' + + timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + unique_id = str(uuid4())[:8] + + return f"daily_checkups/{profile_id}/{timestamp}_{index}_{unique_id}{ext}" + + def _validate_image_file(self, filename: str) -> bool: + """Validate if file is an allowed image type""" + allowed_extensions = {'.jpg', '.jpeg', '.png', '.webp'} + _, ext = os.path.splitext(filename.lower()) + return ext in allowed_extensions + + async def create( + self, + profile_id: UUID, + sleepduration: Optional[str] = None, + sleepquality: Optional[int] = None, + weight: Optional[float] = None, + shape: Optional[int] = None, + soreness: Optional[int] = None, + steps: Optional[int] = None, + digestion: Optional[int] = None, + dayon: Optional[bool] = None, + picture_files: Optional[List[tuple]] = None + ) -> DailyCheckup: + + today = date.today() + existing = await self._repo.find_by_profile_id_and_date(profile_id, today) + if existing: + raise ValidationError(f"Daily checkup already exists for today") + + picture_urls = [] + if picture_files: + for i, (file_data, filename) in enumerate(picture_files): + if not self._validate_image_file(filename): + raise ValueError(f"Invalid file type for {filename}. Only JPG, JPEG, PNG, and WebP are allowed.") + + new_filename = self._generate_image_filename(filename, profile_id, DailyCheckupImageType.DAILY_CHECKUP) + url = await self._image_storage.upload(file_data, new_filename, DailyCheckupImageType.DAILY_CHECKUP) + picture_urls.append(url) + + daily_checkup = DailyCheckup( + id=uuid4(), + profile_id=profile_id, + sleepduration=sleepduration, + sleepquality=sleepquality, + weight=weight, + shape=shape, + soreness=soreness, + steps=steps, + digestion=digestion, + dayon=dayon, + picture=picture_urls, + created_at=datetime.utcnow() + ) + + + daily_checkup._validate_score_fields() + + return await self._repo.add(daily_checkup) + + async def get_by_id(self, checkup_id: UUID) -> DailyCheckup: + """Récupère un daily checkup par son ID""" + checkup = await self._repo.find_by_id(checkup_id) + if not checkup: + raise NotFoundError(f"Daily checkup {checkup_id} not found") + return checkup + + async def get_by_profile_id(self, profile_id: UUID) -> List[DailyCheckup]: + """Récupère tous les daily checkups d'un profil""" + return await self._repo.find_by_profile_id(profile_id) + + async def get_by_profile_and_date(self, profile_id: UUID, target_date: date) -> Optional[DailyCheckup]: + """Récupère un daily checkup par profil et date (via created_at)""" + return await self._repo.find_by_profile_id_and_date(profile_id, target_date) + + async def get_today_checkup(self, profile_id: UUID) -> Optional[DailyCheckup]: + """Récupère le checkup d'aujourd'hui pour un utilisateur""" + today = date.today() + return await self._repo.find_by_profile_id_and_date(profile_id, today) + + async def delete(self, checkup_id: UUID) -> None: + """Supprime un daily checkup""" + existing = await self.get_by_id(checkup_id) + + if existing.picture: + for picture_url in existing.picture: + try: + old_key = self._image_storage.extract_key_from_url(picture_url) + await self._image_storage.delete(old_key) + except Exception: + pass + + await self._repo.delete(checkup_id) + + async def add_pictures(self, checkup_id: UUID, picture_files: List[tuple]) -> DailyCheckup: + """Ajoute des images à un daily checkup existant""" + existing = await self.get_by_id(checkup_id) + + picture_urls = existing.picture.copy() + + for i, (file_data, filename) in enumerate(picture_files): + if not self._validate_image_file(filename): + raise ValueError(f"Invalid file type for {filename}. Only JPG, JPEG, PNG, and WebP are allowed.") + + new_filename = self._generate_image_filename(filename, existing.profile_id, len(picture_urls) + i) + url = await self._image_storage.upload(file_data, new_filename) + picture_urls.append(url) + + existing.picture = picture_urls + + return await self._repo.update(existing) + + async def remove_picture(self, checkup_id: UUID, picture_url: str) -> DailyCheckup: + """Supprime une image spécifique d'un daily checkup""" + existing = await self.get_by_id(checkup_id) + + if picture_url not in existing.picture: + raise ValueError(f"Picture {picture_url} not found in checkup") + + try: + old_key = self._image_storage.extract_key_from_url(picture_url) + await self._image_storage.delete(old_key) + except Exception: + pass + + existing.picture = [url for url in existing.picture if url != picture_url] + + return await self._repo.update(existing) \ No newline at end of file From 67d598f549a7f0c50a35e0d55f526f71215e8a47 Mon Sep 17 00:00:00 2001 From: Baptiste-Ferrand Date: Tue, 2 Sep 2025 12:49:26 +0200 Subject: [PATCH 11/12] finish schema and router for daily checkup --- src/entrypoints/api/routers/daily_checkup.py | 185 ++++++------------- src/entrypoints/api/schemas/daily_checkup.py | 34 ++-- uv.lock | 2 +- 3 files changed, 74 insertions(+), 147 deletions(-) diff --git a/src/entrypoints/api/routers/daily_checkup.py b/src/entrypoints/api/routers/daily_checkup.py index 3a42a12..a464a7e 100644 --- a/src/entrypoints/api/routers/daily_checkup.py +++ b/src/entrypoints/api/routers/daily_checkup.py @@ -1,20 +1,17 @@ -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status +import json +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form from typing import List, Optional from uuid import UUID +from datetime import date -from src.entrypoints.api.schemas.daily_checkup import ( - DailyCheckupCreate, - DailyCheckupResponse, - DailyCheckupListResponse, - MessageResponse -) -from src.entrypoints.api.deps.auth import get_current_user -from src.domain.exceptions import NotFoundError from src.container import container +from src.entrypoints.api.deps.auth import get_current_user, require_owner_or_admin +from src.entrypoints.api.schemas.daily_checkup import DailyCheckupCreate, DailyCheckupRead +from src.domain.exceptions import NotFoundError, ValidationError -router = APIRouter(prefix="/daily-checkups", tags=["Daily Checkups"]) +router = APIRouter(prefix="/daily-checkups", tags=["daily-checkups"]) -@router.post("/", response_model=DailyCheckupResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(get_current_user)]) +@router.post("", response_model=DailyCheckupRead, status_code=status.HTTP_201_CREATED) async def create_daily_checkup( sleepduration: Optional[str] = Form(None), sleepquality: Optional[int] = Form(None), @@ -27,45 +24,30 @@ async def create_daily_checkup( pictures: List[UploadFile] = File(default=[]), user=Depends(get_current_user) ): - """Créer un nouveau daily checkup pour l'utilisateur connecté""" service = container.get_daily_checkup_service() - + try: - # Validation des scores (1-10) - score_fields = { - "sleepquality": sleepquality, - "shape": shape, - "soreness": soreness, - "digestion": digestion - } - - for field_name, value in score_fields.items(): - if value is not None and (value < 1 or value > 10): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"{field_name} must be between 1 and 10" - ) - - if weight is not None and weight <= 0: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Weight must be greater than 0" - ) - - if steps is not None and steps < 0: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Steps must be greater than or equal to 0" - ) - + dto = DailyCheckupCreate( + sleepduration=sleepduration, + sleepquality=sleepquality, + weight=weight, + shape=shape, + soreness=soreness, + steps=steps, + digestion=digestion, + dayon=dayon + ) + + for file in pictures: + if file.size and file.size > 2 * 1024 * 1024: + raise HTTPException(400, f"File {file.filename} too large. Maximum size is 2MB.") + picture_files = [] - if pictures: - for picture in pictures: - if picture.filename: - content = await picture.read() - picture_files.append((content, picture.filename)) + for file in pictures: + if file.filename: + picture_files.append((file.file, file.filename)) - daily_checkup = await service.create( + checkup = await service.create( profile_id=UUID(user["sub"]), sleepduration=sleepduration, sleepquality=sleepquality, @@ -75,101 +57,56 @@ async def create_daily_checkup( steps=steps, digestion=digestion, dayon=dayon, - picture_files=picture_files if picture_files else None + picture_files=picture_files ) - - return DailyCheckupResponse.model_validate(daily_checkup) - + return DailyCheckupRead.model_validate(checkup) + + except ValidationError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) except ValueError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e) - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Internal server error: {str(e)}" - ) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Upload failed: {str(e)}") + + +@router.get("/mine", response_model=List[DailyCheckupRead]) +async def get_my_daily_checkups(user=Depends(get_current_user)): + service = container.get_daily_checkup_service() + checkups = await service.get_by_profile_id(UUID(user["sub"])) + return [DailyCheckupRead.model_validate(checkup) for checkup in checkups] + +@router.get("/today", response_model=Optional[DailyCheckupRead]) +async def get_today_checkup(user=Depends(get_current_user)): + service = container.get_daily_checkup_service() + checkup = await service.get_today_checkup(UUID(user["sub"])) + if checkup: + return DailyCheckupRead.model_validate(checkup) + return None -@router.get("/{checkup_id}", response_model=DailyCheckupResponse, dependencies=[Depends(get_current_user)]) +@router.get("/{checkup_id}", response_model=DailyCheckupRead) async def get_daily_checkup( checkup_id: UUID, user=Depends(get_current_user) ): - """Récupérer un daily checkup spécifique par son ID""" service = container.get_daily_checkup_service() - try: - daily_checkup = await service.get_by_id(checkup_id) - - if daily_checkup.profile_id != UUID(user["sub"]): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="You don't have permission to access this daily checkup" - ) - - return DailyCheckupResponse.model_validate(daily_checkup) - + checkup = await service.get_by_id(checkup_id) + if str(checkup.profile_id) != user["sub"] and "admin" not in user.get("roles", []): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") + return DailyCheckupRead.model_validate(checkup) except NotFoundError: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Daily checkup not found" - ) - except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Internal server error: {str(e)}" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Daily checkup not found") -@router.get("/", response_model=DailyCheckupListResponse, dependencies=[Depends(get_current_user)]) -async def get_user_daily_checkups( - user=Depends(get_current_user) -): - """Récupérer tous les daily checkups de l'utilisateur connecté""" - service = container.get_daily_checkup_service() - - try: - daily_checkups = await service.get_by_profile_id(UUID(user["sub"])) - - return DailyCheckupListResponse( - daily_checkups=[DailyCheckupResponse.model_validate(dc) for dc in daily_checkups], - total=len(daily_checkups) - ) - - except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Internal server error: {str(e)}" - ) - -@router.delete("/{checkup_id}", response_model=MessageResponse, dependencies=[Depends(get_current_user)]) +@router.delete("/{checkup_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_daily_checkup( checkup_id: UUID, user=Depends(get_current_user) ): - """Supprimer un daily checkup spécifique""" service = container.get_daily_checkup_service() - try: - daily_checkup = await service.get_by_id(checkup_id) - - if daily_checkup.profile_id != UUID(user["sub"]): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="You don't have permission to delete this daily checkup" - ) - + checkup = await service.get_by_id(checkup_id) + if str(checkup.profile_id) != user["sub"] and "admin" not in user.get("roles", []): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") await service.delete(checkup_id) - - return MessageResponse(message="Daily checkup deleted successfully") - except NotFoundError: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Daily checkup not found" - ) - except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Internal server error: {str(e)}" - ) \ No newline at end of file + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Daily checkup not found") \ No newline at end of file diff --git a/src/entrypoints/api/schemas/daily_checkup.py b/src/entrypoints/api/schemas/daily_checkup.py index 8eea0b7..c9de37d 100644 --- a/src/entrypoints/api/schemas/daily_checkup.py +++ b/src/entrypoints/api/schemas/daily_checkup.py @@ -1,22 +1,20 @@ from pydantic import BaseModel, Field -from typing import Optional, List +from typing import List, Optional from uuid import UUID from datetime import datetime class DailyCheckupCreate(BaseModel): - sleepduration: Optional[str] = Field(None, description="Durée de sommeil") - sleepquality: Optional[int] = Field(None, ge=1, le=10, description="Qualité du sommeil (1-10)") - weight: Optional[float] = Field(None, gt=0, description="Poids en kg") - shape: Optional[int] = Field(None, ge=1, le=10, description="Forme physique (1-10)") - soreness: Optional[int] = Field(None, ge=1, le=10, description="Courbatures (1-10)") - steps: Optional[int] = Field(None, ge=0, description="Nombre de pas") - digestion: Optional[int] = Field(None, ge=1, le=10, description="Digestion (1-10)") - dayon: Optional[bool] = Field(None, description="Jour on/off") - -class DailyCheckupResponse(BaseModel): + sleepduration: Optional[str] = None + sleepquality: Optional[int] = Field(None, ge=1, le=10) + weight: Optional[float] = Field(None, gt=0) + shape: Optional[int] = Field(None, ge=1, le=10) + soreness: Optional[int] = Field(None, ge=1, le=10) + steps: Optional[int] = Field(None, ge=0) + digestion: Optional[int] = Field(None, ge=1, le=10) + dayon: Optional[bool] = None +class DailyCheckupRead(BaseModel): id: UUID profile_id: UUID - date: str sleepduration: Optional[str] sleepquality: Optional[int] weight: Optional[float] @@ -25,15 +23,7 @@ class DailyCheckupResponse(BaseModel): steps: Optional[int] digestion: Optional[int] dayon: Optional[bool] - picture: List[str] = Field(default_factory=list, description="URLs des images") + picture: List[str] created_at: datetime - class Config: - from_attributes = True - -class DailyCheckupListResponse(BaseModel): - daily_checkups: List[DailyCheckupResponse] - total: int - -class MessageResponse(BaseModel): - message: str \ No newline at end of file + model_config = {"from_attributes": True} \ No newline at end of file diff --git a/uv.lock b/uv.lock index 7cd7e4f..9aa585b 100644 --- a/uv.lock +++ b/uv.lock @@ -751,7 +751,7 @@ wheels = [ [[package]] name = "tracknatrainapi" -version = "0.6.0" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "annotated-types" }, From ec6daa6541b90877544befef55e1d34583e83d35 Mon Sep 17 00:00:00 2001 From: Baptiste-Ferrand Date: Tue, 2 Sep 2025 12:50:22 +0200 Subject: [PATCH 12/12] update main --- src/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.py b/src/main.py index 50d5b1d..34b1501 100644 --- a/src/main.py +++ b/src/main.py @@ -27,7 +27,7 @@ def custom_openapi(): openapi_schema = get_openapi( title="TracknTrain", - version="1.0.0", + version="0.7.0", description="API For TracknTrain", routes=app.routes, )