diff --git a/.env.example b/.env.example index dc68c06..daadb6c 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,37 @@ -# BDD ENvironment Variables -DATABASE_URL = "postgresql+asyncpg://user:password@localhost:5432/mydatabase" -ENV = "ENV" -SECRET_KEY= "jwt sercret" -ACCESS_TOKEN_EXPIRE_MINUTES = "in minutes" \ No newline at end of file +# ======================== +# 🔐 SÉCURITÉ & AUTH +# ======================== +# Secret key for jwt signature +SECRET_KEY=change-me-with-openssl-rand-hex-32 +ACCESS_TOKEN_EXPIRE_MINUTES=in-minutes + +# ======================== +# đŸ—„ïž BASE DE DONNÉES +# ======================== +# Connexion PostgreSQL +BDD_NAME=bdd +BDD_USER=uruser +BDD_PASSWORD=urpassword +DATABASE_URL=postgresql+psycopg2://$BDD_USER:$BDD_PASSWORD@ip:5432/$BDD_NAME + +# ======================== +# 🏠 ENV +# ======================== +ENV=ENV + +# ======================== +# ☁ STORAGE MINIO +# ======================== + +# URL connexion for minio +MINIO_ENDPOINT=https://exemple.minio.fr +MINIO_PUBLIC_URL=https://exemple.minio.fr +MINIO_REGION=us-east-1 + +# Acces key for MinIO (user/password) +MINIO_ACCESS_KEY=minioadminuser +MINIO_SECRET_KEY=minioadminpassword + +# name of the bucket +MINIO_BUCKET_PP=profile-pictures +MINIO_BUCKET_USERS=user-content \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 33ddb56..1f30533 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,13 +17,21 @@ services: volumes: - ./src:/app/src build: + context: . dockerfile: Dockerfile container_name: trackntrain_backend ports: - '8000:8000' environment: - ENV: prod - SECRET_KEY: 123456789 - ACCESS_TOKEN_EXPIRE_MINUTES: 60 - DATABASE_URL: postgresql://user:user@91.169.178.154:5400/postgres \ No newline at end of file + ENV: ${ENV} + SECRET_KEY: ${SECRET_KEY} + ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES} + DATABASE_URL: ${DATABASE_URL} + MINIO_ENDPOINT: ${MINIO_ENDPOINT} + MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL} + MINIO_REGION: ${MINIO_REGION} + MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY} + MINIO_SECRET_KEY: ${MINIO_SECRET_KEY} + MINIO_BUCKET_PP: ${MINIO_BUCKET_PP} + MINIO_BUCKET_USERS: ${MINIO_BUCKET_USERS} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 7b919a7..455a9ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,52 @@ name = "tracknatrainapi" version = "0.6.0" requires-python = ">=3.12" -dependencies = [ "annotated-types==0.7.0", "anyio==4.9.0", "bcrypt==4.3.0", "boto3==1.37.37", "botocore==1.37.37", "cffi==1.17.1", "click==8.1.8", "cryptography==44.0.2", "dnspython==2.7.0", "ecdsa==0.19.1", "email-validator==2.2.0", "exceptiongroup==1.2.2", "fastapi==0.115.12", "greenlet==3.1.1", "h11==0.14.0", "idna==3.10", "jmespath==1.0.1", "passlib[bcrypt]>=1.7.4", "psycopg2-binary==2.9.10", "pyasn1==0.4.8", "pycparser==2.22", "pydantic==2.11.3", "pydantic-core==2.33.1", "python-dateutil==2.9.0.post0", "python-dotenv==1.1.0", "python-jose==3.4.0", "python-multipart==0.0.20", "rsa==4.9", "s3transfer==0.11.5", "six==1.17.0", "sniffio==1.3.1", "sqlalchemy==2.0.40", "starlette==0.46.2", "typing-extensions==4.13.2", "typing-inspection==0.4.0", "urllib3==2.4.0", "uvicorn==0.34.1", "pytest>=7.0", "pytest-asyncio>=0.20", "httpx>=0.24", "pytest-cov>=4.0", "coverage>=6.0", "asyncpg==0.30.0", "pytest-mock>=3.10,<4.0",] +dependencies = [ + "annotated-types==0.7.0", + "anyio==4.9.0", + "bcrypt==4.3.0", + "boto3==1.37.37", + "botocore==1.37.37", + "cffi==1.17.1", + "click==8.1.8", + "cryptography==44.0.2", + "dnspython==2.7.0", + "ecdsa==0.19.1", + "email-validator==2.2.0", + "exceptiongroup==1.2.2", + "fastapi==0.115.12", + "greenlet==3.1.1", + "h11==0.14.0", + "idna==3.10", + "jmespath==1.0.1", + "passlib[bcrypt]>=1.7.4", + "psycopg2-binary==2.9.10", + "pyasn1==0.4.8", + "pycparser==2.22", + "pydantic==2.11.3", + "pydantic-core==2.33.1", + "python-dateutil==2.9.0.post0", + "python-dotenv==1.1.0", + "python-jose==3.4.0", + "python-multipart==0.0.20", + "rsa==4.9", + "s3transfer==0.11.5", + "six==1.17.0", + "sniffio==1.3.1", + "sqlalchemy==2.0.40", + "starlette==0.46.2", + "typing-extensions==4.13.2", + "typing-inspection==0.4.0", + "urllib3==2.4.0", + "uvicorn==0.34.1", + "pytest>=7.0", + "pytest-asyncio>=0.20", + "httpx>=0.24", + "pytest-cov>=4.0", + "coverage>=6.0", + "asyncpg==0.30.0", + "pytest-mock>=3.10,<4.0", +] [build-system] requires = [ "setuptools>=42", "wheel",] diff --git a/src/adapters/inmemory/repositories/image_storage.py b/src/adapters/inmemory/repositories/image_storage.py new file mode 100644 index 0000000..5cde65f --- /dev/null +++ b/src/adapters/inmemory/repositories/image_storage.py @@ -0,0 +1,38 @@ +from typing import BinaryIO +from src.domain.ports.image_storage import ImageStorage, ProfileImageType + + +class InMemoryImageStorage(ImageStorage): + """In-memory implementation for profile 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: + """Store file content in memory and return mock URL""" + content = file.read() + self._files[filename] = content + return f"http://localhost/mock/profile-pictures/{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: + """Generate mock presigned upload URL""" + url = f"http://localhost/mock/upload/profile-pictures/{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 new file mode 100644 index 0000000..f99f0e3 --- /dev/null +++ b/src/adapters/minio/image_storage.py @@ -0,0 +1,92 @@ +import asyncio +import boto3 +import mimetypes +import os +from typing import BinaryIO +from botocore.exceptions import ClientError + +from src.domain.ports.image_storage import ImageStorage, ProfileImageType + + +class MinioImageStorage(ImageStorage): + """Minio implementation for profile images storage""" + + def __init__(self): + self.bucket_name = 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") + + self.s3_client = boto3.client( + "s3", + endpoint_url=os.getenv("MINIO_ENDPOINT", "http://localhost:9000"), + aws_access_key_id=os.getenv("MINIO_ACCESS_KEY"), + aws_secret_access_key=os.getenv("MINIO_SECRET_KEY"), + region_name=self.region, + ) + + def _guess_mime_type(self, filename: str) -> str: + """Guess MIME type from filename""" + mime, _ = mimetypes.guess_type(filename) + return mime or "application/octet-stream" + + def extract_key_from_url(self, url: str) -> str: + """Extract object key from full URL""" + return url.split(f"{self.bucket_name}/")[-1] + + async def upload(self, file: BinaryIO, filename: str, image_type: ProfileImageType) -> str: + """Upload file to Minio bucket and return public URL""" + try: + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, + lambda: self.s3_client.upload_fileobj( + Fileobj=file, + Bucket=self.bucket_name, + Key=filename, + ExtraArgs={ + "ContentType": self._guess_mime_type(filename), + "ContentDisposition": "inline" + } + ) + ) + + return f"{self.public_url}/{self.bucket_name}/{filename}" + + except ClientError as e: + raise Exception(f"Failed to upload {image_type.value}: {str(e)}") + + async def delete(self, object_key: str) -> None: + """Delete object from Minio bucket""" + try: + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, + lambda: self.s3_client.delete_object( + Bucket=self.bucket_name, + Key=object_key + ) + ) + except ClientError as e: + if e.response['Error']['Code'] != 'NoSuchKey': + raise Exception(f"Failed to delete image: {str(e)}") + + async def get_upload_url(self, filename: str, image_type: ProfileImageType) -> str: + """Generate presigned URL for direct upload""" + try: + loop = asyncio.get_event_loop() + url = await loop.run_in_executor( + None, + lambda: self.s3_client.generate_presigned_url( + 'put_object', + Params={ + 'Bucket': self.bucket_name, + 'Key': filename, + 'ContentType': self._guess_mime_type(filename) + }, + ExpiresIn=3600 + ) + ) + return url + except ClientError as e: + raise Exception(f"Failed to generate upload URL for {image_type.value}: {str(e)}") \ No newline at end of file diff --git a/src/adapters/sqlalchemy/db.py b/src/adapters/sqlalchemy/db.py index 6910307..622176d 100644 --- a/src/adapters/sqlalchemy/db.py +++ b/src/adapters/sqlalchemy/db.py @@ -1,9 +1,7 @@ from src.adapters.sqlalchemy.models import Base from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession -from dotenv import load_dotenv import os -load_dotenv() db_url = os.getenv("DATABASE_URL") or "postgresql://user:user@localhost:5432/postgres" if db_url.startswith("postgresql://"): diff --git a/src/adapters/sqlalchemy/models.py b/src/adapters/sqlalchemy/models.py index fbb4039..773c402 100644 --- a/src/adapters/sqlalchemy/models.py +++ b/src/adapters/sqlalchemy/models.py @@ -7,6 +7,7 @@ Enum, ForeignKey, Table, + Boolean, ) from sqlalchemy.dialects.postgresql import UUID, ARRAY, JSONB from sqlalchemy.ext.declarative import declarative_base @@ -17,7 +18,7 @@ Base = declarative_base() -# Many-to-many for group membership + group_users = Table( 'group_users', Base.metadata, Column('group_id', UUID(as_uuid=True), ForeignKey('groups.id'), primary_key=True), @@ -45,13 +46,14 @@ class Profile(Base): pricing = Column(Float) description = Column(String) legacy = Column(String) + profilepicture = Column(String) + profilebackground = Column(String) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) roles = Column(ARRAY(String), nullable=False, default=lambda: ["user"]) groups = relationship("Group", secondary=group_users, back_populates="users") - # Other relationships notifications = relationship("Notification", back_populates="profile") mensurations = relationship("Mensuration", back_populates="profile") weights = relationship("Weight", back_populates="profile") @@ -61,6 +63,7 @@ class Profile(Base): diets = relationship("Diet", back_populates="owner") requests_sent = relationship("Request", foreign_keys='Request.owner_id', back_populates="owner") requests_received = relationship("Request", foreign_keys='Request.target_id', back_populates="target") + daily_checkups = relationship("DailyCheckup", back_populates="profile") class Role(Base): __tablename__ = 'roles' @@ -228,3 +231,21 @@ class Group(Base): owner = relationship("Profile", backref="owned_groups") users = relationship("Profile", secondary=group_users, back_populates="groups") requests = relationship("Request", back_populates="group") + +class DailyCheckup(Base): + __tablename__ = 'daily_checkups' + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + profile_id = Column(UUID(as_uuid=True), ForeignKey('profiles.id')) + + sleepduration = Column(String) + sleepquality = Column(Integer) + weight = Column(Float) + shape = Column(Integer) + soreness = Column(Integer) + steps = Column(Integer) + digestion = Column(Integer) + dayon = Column(Boolean) + picture = Column(ARRAY(String), nullable=False, default=list) + + profile = relationship("Profile", back_populates="daily_checkups") diff --git a/src/adapters/sqlalchemy/repositories/profile.py b/src/adapters/sqlalchemy/repositories/profile.py index dd7b733..f64a7fd 100644 --- a/src/adapters/sqlalchemy/repositories/profile.py +++ b/src/adapters/sqlalchemy/repositories/profile.py @@ -20,6 +20,8 @@ def profil_from_orm(orm_profile) ->DomainProfile: legacy=orm_profile.legacy, roles=orm_profile.roles, created_at=orm_profile.created_at, + profile_picture_url=orm_profile.profilepicture, + background_picture_url=orm_profile.profilebackground, ) diff --git a/src/container.py b/src/container.py index d0541ef..bf72f38 100644 --- a/src/container.py +++ b/src/container.py @@ -39,27 +39,30 @@ def __init__(self, env: str | None = None): from src.adapters.inmemory.repositories.training import InMemoryTrainingRepository 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 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() else: from src.adapters.sqlalchemy.db import SessionLocal + from src.adapters.minio.image_storage import MinioImageStorage self.SessionFactory = SessionLocal + self.image_storage = MinioImageStorage() def get_profile_service(self): if self.env in ("dev", "test"): repo = self.profile_repo - return ProfileService(repo, self.hasher) + return ProfileService(repo, self.hasher, self.image_repo) else: from src.adapters.sqlalchemy.repositories.profile import SqlAlchemyProfileRepository - 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: @@ -67,9 +70,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) + repo = SessionManagedRepository(SqlAlchemyProfileRepository, self.SessionFactory) + return ProfileService(repo, self.hasher, self.image_storage) def get_group_service(self): if self.env in ("dev", "test"): diff --git a/src/domain/lib/jwt_manager.py b/src/domain/lib/jwt_manager.py index 270d95d..1a9cbe4 100644 --- a/src/domain/lib/jwt_manager.py +++ b/src/domain/lib/jwt_manager.py @@ -7,7 +7,7 @@ from src.domain.exceptions import TokenInvalidError, TokenExpiredError -# Charger le SECRET_KEY depuis l'env (charge .env en amont) + SECRET_KEY = os.getenv("SECRET_KEY", "change_me_please") ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30")) @@ -33,7 +33,6 @@ def decode_access_token(token: str) -> dict: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) return payload except JWTError as e: - # jose lĂšvera ExpiredSignatureError (sous-classe de JWTError) si expirĂ© if "Signature has expired" in str(e): raise TokenExpiredError("Token expired") from e raise TokenInvalidError("Invalid JWT") from e diff --git a/src/domain/model/profile.py b/src/domain/model/profile.py index f307d89..6020a5a 100644 --- a/src/domain/model/profile.py +++ b/src/domain/model/profile.py @@ -15,6 +15,8 @@ class Profile: pricing: Optional[float] = None description: Optional[str] = None legacy: Optional[str] = None + profile_picture_url: Optional[str] = None + background_picture_url: Optional[str] = None roles: List[str] = field(default_factory=list) created_at: datetime = field(default_factory=datetime.utcnow) @@ -31,6 +33,8 @@ def to_orm_dict(self) -> dict: "pricing": self.pricing, "description": self.description, "legacy": self.legacy, + "profilepicture": self.profile_picture_url, + "profilebackground": self.background_picture_url, "roles": self.roles, "created_at": self.created_at, } \ No newline at end of file diff --git a/src/domain/ports/image_storage.py b/src/domain/ports/image_storage.py new file mode 100644 index 0000000..73d0150 --- /dev/null +++ b/src/domain/ports/image_storage.py @@ -0,0 +1,32 @@ +from abc import ABC, abstractmethod +from typing import BinaryIO +from enum import Enum + + +class ProfileImageType(Enum): + PROFILE_PICTURE = "profile_picture" + BACKGROUND_PICTURE = "background_picture" + + +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""" + 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/profile.py b/src/domain/services/profile.py index c5d7d3e..906079a 100644 --- a/src/domain/services/profile.py +++ b/src/domain/services/profile.py @@ -1,17 +1,20 @@ from uuid import UUID, uuid4 from datetime import datetime -from typing import List, Optional +from typing import List, Optional, BinaryIO +import os from src.domain.model.profile import Profile as DomainProfile from src.domain.ports.profile_repository import ProfileRepository +from src.domain.ports.image_storage import ImageStorage, ProfileImageType from src.domain.ports.password_hasher import PasswordHasher from src.domain.exceptions import DuplicateProfileError, AuthenticationError, NotFoundError, InvalidConfirmPasswordError, InvalidFormatEmailError import re class ProfileService: - def __init__(self, repo: ProfileRepository, hasher: PasswordHasher): + def __init__(self, repo: ProfileRepository, hasher: PasswordHasher, image_storage: ImageStorage): self._repo = repo self._hasher = hasher + self._image_storage = image_storage async def create(self, @@ -146,4 +149,103 @@ async def update_roles(self, id: UUID, roles: List[str]) -> DomainProfile: if not roles: raise ValueError("Roles cannot be empty") profile.roles = roles - return await self._repo.update(profile) \ No newline at end of file + return await self._repo.update(profile) + + def _generate_image_filename(self, original_filename: str, user_id: UUID, image_type: ProfileImageType) -> str: + """Generate unique filename for profile 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] + + if image_type == ProfileImageType.PROFILE_PICTURE: + return f"users/{user_id}/profile/{timestamp}_{unique_id}{ext}" + else: + return f"users/{user_id}/background/{timestamp}_{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 update_profile_picture(self, user_id: UUID, file: BinaryIO, filename: str) -> DomainProfile: + """Update user's profile picture""" + profile = await self.get_by_id(user_id) + if not profile: + raise NotFoundError(f"Profile with id {user_id} not found") + + if not self._validate_image_file(filename): + raise ValueError("Invalid file type. Only JPG, PNG, and WebP are allowed.") + + if profile.profile_picture_url: + try: + old_key = self._image_storage.extract_key_from_url(profile.profile_picture_url) + await self._image_storage.delete(old_key) + except Exception: + pass + + new_filename = self._generate_image_filename(filename, user_id, ProfileImageType.PROFILE_PICTURE) + new_url = await self._image_storage.upload(file, new_filename, ProfileImageType.PROFILE_PICTURE) + + profile.profile_picture_url = new_url + + return await self._repo.update(profile) + + async def update_background_picture(self, user_id: UUID, file: BinaryIO, filename: str) -> DomainProfile: + """Update user's background picture""" + profile = await self._repo.find_by_id(user_id) + if not profile: + raise NotFoundError(f"Profile with id {user_id} not found") + + if not self._validate_image_file(filename): + raise ValueError("Invalid file type. Only JPG, PNG, and WebP are allowed.") + + if profile.background_picture_url: + try: + old_key = self._image_storage.extract_key_from_url(profile.background_picture_url) + await self._image_storage.delete(old_key) + except Exception: + pass + + new_filename = self._generate_image_filename(filename, user_id, ProfileImageType.BACKGROUND_PICTURE) + new_url = await self._image_storage.upload(file, new_filename, ProfileImageType.BACKGROUND_PICTURE) + + profile.background_picture_url = new_url + + return await self._repo.update(profile) + + async def delete_profile_picture(self, user_id: UUID) -> DomainProfile: + """Delete user's profile picture""" + profile = await self._repo.find_by_id(user_id) + if not profile: + raise NotFoundError(f"Profile with id {user_id} not found") + + if not profile.profile_picture_url: + raise ValueError("No profile picture to delete") + + old_key = self._image_storage.extract_key_from_url(profile.profile_picture_url) + await self._image_storage.delete(old_key) + + profile.profile_picture_url = None + + return await self._repo.update(profile) + + async def delete_background_picture(self, user_id: UUID) -> DomainProfile: + profile = await self._repo.find_by_id(user_id) + if not profile: + raise NotFoundError(f"Profile with id {user_id} not found") + + if not profile.background_picture_url: + raise ValueError("No background picture to delete") + + old_key = self._image_storage.extract_key_from_url(profile.background_picture_url) + await self._image_storage.delete(old_key) + + profile.background_picture_url = None + + return await self._repo.update(profile) + + \ No newline at end of file diff --git a/src/domain/tests/test_profile_service.py b/src/domain/tests/test_profile_service.py index 4ffc03c..6d1e302 100644 --- a/src/domain/tests/test_profile_service.py +++ b/src/domain/tests/test_profile_service.py @@ -25,10 +25,17 @@ def mock_hasher(): return hasher +# Fixture pour simuler l'ImageStorage +@pytest.fixture +def mock_image_storage(): + storage = AsyncMock() + return storage + + # Fixture pour crĂ©er une instance de ProfileService avec les mocks @pytest.fixture -def profile_service(mock_repo, mock_hasher): - return ProfileService(repo=mock_repo, hasher=mock_hasher) +def profile_service(mock_repo, mock_hasher, mock_image_storage): + return ProfileService(repo=mock_repo, hasher=mock_hasher, image_storage=mock_image_storage) # Test de la crĂ©ation de profil avec email dĂ©jĂ  existant @@ -234,4 +241,85 @@ async def test_update_roles(profile_service, mock_repo): mock_repo.update.assert_called_once_with(profile) # VĂ©rifier que les rĂŽles ont Ă©tĂ© mis Ă  jour sur le profil original assert profile.roles == new_roles + assert updated_profile == profile + + +# Tests pour les nouvelles mĂ©thodes de gestion d'images +@pytest.mark.asyncio +async def test_update_profile_picture(profile_service, mock_repo, mock_image_storage): + profile_id = uuid4() + file_content = b"fake_image_content" + filename = "profile.jpg" + + # Simuler un profil existant + profile = AsyncMock() + profile.id = profile_id + mock_repo.find_by_id.return_value = profile + mock_repo.update.return_value = profile + mock_image_storage.upload.return_value = "http://example.com/profile.jpg" + + updated_profile = await profile_service.update_profile_picture(profile_id, file_content, filename) + + mock_repo.find_by_id.assert_called_once_with(profile_id) + mock_image_storage.upload.assert_called_once() + mock_repo.update.assert_called_once_with(profile) + assert updated_profile == profile + + +@pytest.mark.asyncio +async def test_update_background_picture(profile_service, mock_repo, mock_image_storage): + profile_id = uuid4() + file_content = b"fake_image_content" + filename = "background.jpg" + + # Simuler un profil existant + profile = AsyncMock() + profile.id = profile_id + mock_repo.find_by_id.return_value = profile + mock_repo.update.return_value = profile + mock_image_storage.upload.return_value = "http://example.com/background.jpg" + + updated_profile = await profile_service.update_background_picture(profile_id, file_content, filename) + + mock_repo.find_by_id.assert_called_once_with(profile_id) + mock_image_storage.upload.assert_called_once() + mock_repo.update.assert_called_once_with(profile) + assert updated_profile == profile + + +@pytest.mark.asyncio +async def test_delete_profile_picture(profile_service, mock_repo, mock_image_storage): + profile_id = uuid4() + + # Simuler un profil existant avec une photo + profile = AsyncMock() + profile.id = profile_id + profile.profile_picture_url = "http://example.com/profile.jpg" + mock_repo.find_by_id.return_value = profile + mock_repo.update.return_value = profile + + updated_profile = await profile_service.delete_profile_picture(profile_id) + + mock_repo.find_by_id.assert_called_once_with(profile_id) + mock_image_storage.delete.assert_called_once() + mock_repo.update.assert_called_once_with(profile) + assert updated_profile == profile + + +@pytest.mark.asyncio +async def test_delete_background_picture(profile_service, mock_repo, mock_image_storage): + profile_id = uuid4() + + # Simuler un profil existant avec une image de fond + profile = AsyncMock() + profile.id = profile_id + profile.background_picture_url = "http://example.com/background.jpg" + mock_repo.find_by_id.return_value = profile + mock_repo.update.return_value = profile + + updated_profile = await profile_service.delete_background_picture(profile_id) + + mock_repo.find_by_id.assert_called_once_with(profile_id) + mock_image_storage.delete.assert_called_once() + mock_repo.update.assert_called_once_with(profile) assert updated_profile == profile \ No newline at end of file diff --git a/src/entrypoints/api/deps/auth.py b/src/entrypoints/api/deps/auth.py index f66065b..59f684b 100644 --- a/src/entrypoints/api/deps/auth.py +++ b/src/entrypoints/api/deps/auth.py @@ -92,7 +92,7 @@ async def require_coach_for_user_or_admin( profile_svc = container.get_profile_service() try: - target_profile = await profile_svc.get_by_id(target_user_id) # ✅ Ajout d'await + target_profile = await profile_svc.get_by_id(target_user_id) except NotFoundError: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -113,13 +113,13 @@ async def require_coach_for_user_or_admin( group_svc = container.get_group_service() try: - coach_groups = await group_svc.list_owner_groups(UUID(user_sub)) # ✅ Ajout d'await + coach_groups = await group_svc.list_owner_groups(UUID(user_sub)) except NotFoundError: coach_groups = [] for grp in coach_groups: try: - members = await group_svc.list_members(grp.id) # ✅ Ajout d'await + members = await group_svc.list_members(grp.id) except NotFoundError: continue if any(m.id == target_user_id for m in members): @@ -144,7 +144,7 @@ async def require_training_owner_or_coach_or_admin( svc = container.get_training_service() try: - training = await svc.get_training(training_id) # ✅ Ajout d'await + training = await svc.get_training(training_id) except NotFoundError: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -157,10 +157,10 @@ async def require_training_owner_or_coach_or_admin( if "coach" in roles: group_svc = container.get_group_service() try: - coach_groups = await group_svc.list_owner_groups(UUID(sub)) # ✅ Ajout d'await + coach_groups = await group_svc.list_owner_groups(UUID(sub)) for grp in coach_groups: try: - members = await group_svc.list_members(grp.id) # ✅ Ajout d'await + members = await group_svc.list_members(grp.id) if any(m.id == training.owner_id for m in members): return user except NotFoundError: diff --git a/src/entrypoints/api/routers/profile.py b/src/entrypoints/api/routers/profile.py index fdbe2a7..7315aa0 100644 --- a/src/entrypoints/api/routers/profile.py +++ b/src/entrypoints/api/routers/profile.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File from uuid import UUID from sqlalchemy.sql.functions import user @@ -216,4 +216,108 @@ async def patch_roles( except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) - return ProfileRead.model_validate(updated) \ No newline at end of file + return ProfileRead.model_validate(updated) + +@router.patch( + "/profile-picture/{profile_id}", + response_model=ProfileRead, + status_code=status.HTTP_200_OK, + dependencies=[Depends(require_owner_or_admin)] +) +async def update_my_profile_picture( + file: UploadFile = File(...), + user: UserPayload = Depends(get_current_user) +): + """Update current user's profile picture""" + if file.size > 2 * 1024 * 1024: + raise HTTPException(400, "File too large. Maximum size is 2MB.") + + try: + user_id = UUID(user["sub"]) + service = container.get_profile_service() + + profile = await service.update_profile_picture(user_id, file.file, file.filename) + return ProfileRead.model_validate(profile) + + except ValueError as e: + raise HTTPException(400, str(e)) + except NotFoundError as e: + raise HTTPException(404, str(e)) + except Exception as e: + raise HTTPException(500, f"Upload failed: {str(e)}") + +@router.patch( + "/background-picture/{profile_id}", + response_model=ProfileRead, + status_code=status.HTTP_200_OK, + dependencies=[Depends(require_owner_or_admin)] +) +async def update_my_background_picture( + file: UploadFile = File(...), + user: UserPayload = Depends(get_current_user) +): + """Update current user's background picture""" + if file.size > 5 * 1024 * 1024: + raise HTTPException(400, "File too large. Maximum size is 5MB.") + + try: + user_id = UUID(user["sub"]) + service = container.get_profile_service() + + profile = await service.update_background_picture(user_id, file.file, file.filename) + return ProfileRead.model_validate(profile) + + except ValueError as e: + raise HTTPException(400, str(e)) + except NotFoundError as e: + raise HTTPException(404, str(e)) + except Exception as e: + raise HTTPException(500, f"Upload failed: {str(e)}") + +@router.delete( + "/profile-picture/{profile_id}", + response_model=ProfileRead, + status_code=status.HTTP_200_OK, + dependencies=[Depends(require_owner_or_admin)] +) +async def delete_my_profile_picture( + user: UserPayload = Depends(get_current_user) +): + """Delete current user's profile picture""" + try: + user_id = UUID(user["sub"]) + service = container.get_profile_service() + + profile = await service.delete_profile_picture(user_id) + return ProfileRead.model_validate(profile) + + except ValueError as e: + raise HTTPException(400, str(e)) + except NotFoundError as e: + raise HTTPException(404, str(e)) + except Exception as e: + raise HTTPException(500, f"Delete failed: {str(e)}") + +@router.delete( + "/background-picture/{profile_id}", + response_model=ProfileRead, + status_code=status.HTTP_200_OK, + dependencies=[Depends(require_owner_or_admin)] +) +async def delete_my_background_picture( + user: UserPayload = Depends(get_current_user) +): + """Delete current user's background picture""" + try: + user_id = UUID(user["sub"]) + service = container.get_profile_service() + + profile = await service.delete_background_picture(user_id) + return ProfileRead.model_validate(profile) + + except ValueError as e: + raise HTTPException(400, str(e)) + except NotFoundError as e: + raise HTTPException(404, str(e)) + except Exception as e: + raise HTTPException(500, f"Delete failed: {str(e)}") \ No newline at end of file diff --git a/src/entrypoints/api/schemas/profile.py b/src/entrypoints/api/schemas/profile.py index ecb291a..2120033 100644 --- a/src/entrypoints/api/schemas/profile.py +++ b/src/entrypoints/api/schemas/profile.py @@ -27,6 +27,8 @@ class ProfileRead(BaseModel): legacy: Optional[str] roles: List[str] created_at: datetime + profile_picture_url: Optional[str] = None + background_picture_url: Optional[str] = None model_config = { "from_attributes": True @@ -41,6 +43,8 @@ class CoachProfileRead(BaseModel): pricing: Optional[float] = None description: Optional[str] = None legacy: Optional[str] = None + profile_picture_url: Optional[str] = None + background_picture_url: Optional[str] = None model_config = { "from_attributes": True diff --git a/src/main.py b/src/main.py index 78aa90d..4d88aaf 100644 --- a/src/main.py +++ b/src/main.py @@ -1,5 +1,15 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.openapi.utils import get_openapi +from fastapi.security import HTTPBearer +import os + +from dotenv import load_dotenv + +load_dotenv() +print("DEBUG ENV =", os.getenv("ENV")) +print("DEBUG DATABASE_URL =", os.getenv("DATABASE_URL")) +app = FastAPI() from src.entrypoints.api.routers.profile import router as profile_router from src.entrypoints.api.routers.group import router as group_router @@ -8,14 +18,39 @@ from src.entrypoints.api.routers.diet import router as diet_router -app = FastAPI( - title="API Hexagonale", - version="0.1.0", -) +bearer_scheme = HTTPBearer() + +def custom_openapi(): + if app.openapi_schema: + return app.openapi_schema + + openapi_schema = get_openapi( + title="TracknTrain", + version="1.0.0", + description="API For TracknTrain", + routes=app.routes, + ) + + openapi_schema["components"]["securitySchemes"] = { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + } + + for path in openapi_schema["paths"].values(): + for method in path.values(): + method.setdefault("security", [{"BearerAuth": []}]) + + app.openapi_schema = openapi_schema + return openapi_schema + +app.openapi = custom_openapi origins = [ "http://localhost:3000", - "http://127.0.0.1:3000" + "http://127.0.0.1:3000", "http://localhost:8000", "http://localhost:5173", "http://127.0.0.1:8000", diff --git a/uv.lock b/uv.lock index 99f47d2..7cd7e4f 100644 --- a/uv.lock +++ b/uv.lock @@ -751,7 +751,7 @@ wheels = [ [[package]] name = "tracknatrainapi" -version = "0.5.9" +version = "0.6.0" source = { editable = "." } dependencies = [ { name = "annotated-types" },