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
42 changes: 37 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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"
# ========================
# 🔐 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
16 changes: 12 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
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}
47 changes: 46 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",]
Expand Down
38 changes: 38 additions & 0 deletions src/adapters/inmemory/repositories/image_storage.py
Original file line number Diff line number Diff line change
@@ -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
92 changes: 92 additions & 0 deletions src/adapters/minio/image_storage.py
Original file line number Diff line number Diff line change
@@ -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)}")
2 changes: 0 additions & 2 deletions src/adapters/sqlalchemy/db.py
Original file line number Diff line number Diff line change
@@ -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://"):
Expand Down
25 changes: 23 additions & 2 deletions src/adapters/sqlalchemy/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
Enum,
ForeignKey,
Table,
Boolean,
)
from sqlalchemy.dialects.postgresql import UUID, ARRAY, JSONB
from sqlalchemy.ext.declarative import declarative_base
Expand All @@ -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),
Expand Down Expand Up @@ -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")
Expand All @@ -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'
Expand Down Expand Up @@ -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")
2 changes: 2 additions & 0 deletions src/adapters/sqlalchemy/repositories/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down
14 changes: 8 additions & 6 deletions src/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,37 +39,39 @@ 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:
repo = self.repo_class(session)
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"):
Expand Down
Loading