Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/adapters/inmemory/repositories/daily_checkup.py
Original file line number Diff line number Diff line change
@@ -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)
64 changes: 49 additions & 15 deletions src/adapters/inmemory/repositories/image_storage.py
Original file line number Diff line number Diff line change
@@ -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
38 changes: 34 additions & 4 deletions src/adapters/minio/image_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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"""
Expand Down
115 changes: 115 additions & 0 deletions src/adapters/sqlalchemy/repositories/daily_checkup.py
Original file line number Diff line number Diff line change
@@ -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,
)
37 changes: 32 additions & 5 deletions src/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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"):
Expand All @@ -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"):
Expand Down Expand Up @@ -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()
4 changes: 4 additions & 0 deletions src/domain/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,7 @@ class TokenMissingError(DomainError):

class AuthenticationError(DomainError):
"""Authentification failed."""

class ValidationError(DomainError):
"""Validation error."""
pass
Loading