diff --git a/src/adapters/inmemory/repositories/daily_checkup.py b/src/adapters/inmemory/repositories/daily_checkup.py new file mode 100644 index 0000000..9d6e346 --- /dev/null +++ b/src/adapters/inmemory/repositories/daily_checkup.py @@ -0,0 +1,47 @@ +from uuid import UUID, uuid4 +from typing import Optional, List +from datetime import datetime, date + +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""" + 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() + 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 {today}") + self._daily_checkups[daily_checkup.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, 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.created_at, reverse=True) + + 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.created_at.date() == target_date + ): + return checkup + return None + + 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/inmemory/repositories/image_storage.py b/src/adapters/inmemory/repositories/image_storage.py index 5cde65f..b1a0135 100644 --- a/src/adapters/inmemory/repositories/image_storage.py +++ b/src/adapters/inmemory/repositories/image_storage.py @@ -1,38 +1,72 @@ -from typing import BinaryIO -from src.domain.ports.image_storage import ImageStorage, ProfileImageType - +from typing import BinaryIO, Union +from src.domain.ports.image_storage import ImageStorage 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[object, 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}" - + + 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: ProfileImageType) -> str: + + async def get_upload_url(self, filename: str, image_type: Union[object, None] = None) -> str: """Generate mock presigned upload URL""" - url = f"http://localhost/mock/upload/profile-pictures/{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/adapters/sqlalchemy/repositories/daily_checkup.py b/src/adapters/sqlalchemy/repositories/daily_checkup.py new file mode 100644 index 0000000..389df22 --- /dev/null +++ b/src/adapters/sqlalchemy/repositories/daily_checkup.py @@ -0,0 +1,115 @@ +from typing import List, Optional +from uuid import UUID +from datetime import datetime, date +from sqlalchemy.ext.asyncio import AsyncSession +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 + + +class SqlAlchemyDailyCheckupRepository(DailyCheckupRepository): + def __init__(self, session: AsyncSession): + self._session = session + + 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, + 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=daily_checkup.picture, + created_at=daily_checkup.created_at, + ) + self._session.add(orm_checkup) + await self._session.commit() + await self._session.refresh(orm_checkup) + return self._to_domain(orm_checkup) + + async def find_by_id(self, id: UUID) -> Optional[DomainDailyCheckup]: + """Trouve un daily checkup par son ID""" + 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[DomainDailyCheckup]: + """Trouve tous les daily checkups d'un profil""" + 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, target_date: date) -> Optional[DomainDailyCheckup]: + """Trouve un daily checkup par profil et 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 + ) + ) + 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""" + 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, 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 diff --git a/src/container.py b/src/container.py index bf72f38..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_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") @@ -40,17 +40,20 @@ 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 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"): @@ -70,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"): @@ -164,5 +167,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 SqlAlchemyDailyCheckupRepository + + 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(SqlAlchemyDailyCheckupRepository, self.SessionFactory) + return DailyCheckupService(repo, self.daily_checkup_image_storage) + + container = Container() \ No newline at end of file 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 diff --git a/src/domain/model/daily_checkup.py b/src/domain/model/daily_checkup.py new file mode 100644 index 0000000..9828e2f --- /dev/null +++ b/src/domain/model/daily_checkup.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass, field +from uuid import UUID +from datetime import datetime +from typing import List, Optional + +@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 new file mode 100644 index 0000000..db6d81f --- /dev/null +++ b/src/domain/ports/daily_checkup_repository.py @@ -0,0 +1,26 @@ +from abc import ABC, abstractmethod +from typing import List, Optional +from uuid import UUID +from datetime import date +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, target_date: date) -> Optional[DailyCheckup]: + pass + + @abstractmethod + async def delete(self, id: UUID) -> None: + pass \ No newline at end of file diff --git a/src/domain/ports/image_storage.py b/src/domain/ports/image_storage.py index 73d0150..88afccc 100644 --- a/src/domain/ports/image_storage.py +++ b/src/domain/ports/image_storage.py @@ -1,32 +1,28 @@ from abc import ABC, abstractmethod -from typing import BinaryIO +from typing import BinaryIO, Union from enum import Enum 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: ProfileImageType) -> str: - """Upload an image file to the profile pictures bucket and return the public URL""" + async def upload(self, file: BinaryIO, filename: str, image_type: Union[ProfileImageType, None] = None) -> str: 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_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 diff --git a/src/entrypoints/api/routers/daily_checkup.py b/src/entrypoints/api/routers/daily_checkup.py new file mode 100644 index 0000000..a464a7e --- /dev/null +++ b/src/entrypoints/api/routers/daily_checkup.py @@ -0,0 +1,112 @@ +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.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.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), + 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) +): + service = container.get_daily_checkup_service() + + try: + 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 = [] + for file in pictures: + if file.filename: + picture_files.append((file.file, file.filename)) + + 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 + ) + 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)) + except Exception as 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=DailyCheckupRead) +async def get_daily_checkup( + checkup_id: UUID, + user=Depends(get_current_user) +): + service = container.get_daily_checkup_service() + try: + 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") + +@router.delete("/{checkup_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_daily_checkup( + checkup_id: UUID, + user=Depends(get_current_user) +): + service = container.get_daily_checkup_service() + try: + 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) + except NotFoundError: + 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 new file mode 100644 index 0000000..c9de37d --- /dev/null +++ b/src/entrypoints/api/schemas/daily_checkup.py @@ -0,0 +1,29 @@ +from pydantic import BaseModel, Field +from typing import List, Optional +from uuid import UUID +from datetime import datetime + +class DailyCheckupCreate(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 + 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] + created_at: datetime + + model_config = {"from_attributes": True} \ No newline at end of file diff --git a/src/main.py b/src/main.py index 4d88aaf..34b1501 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() @@ -26,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, ) @@ -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) 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" },