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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name = "tracknatrainapi"
version = "0.8.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", "apscheduler==3.10.4",]

[build-system]
requires = [ "setuptools>=42", "wheel",]
Expand Down
41 changes: 41 additions & 0 deletions src/adapters/inmemory/repositories/notification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from typing import List, Optional
from uuid import UUID

from src.domain.model.notification import Notification as DomainNotification
from src.domain.ports.notification_repository import NotificationRepository

class InMemoryNotificationRepository(NotificationRepository):
def __init__(self, initial: List[DomainNotification] = None):
self.notifications = initial or []

async def add_notification(self, notification: DomainNotification) -> DomainNotification:
self.notifications.append(notification)
return notification

async def find_by_id(self, id: UUID) -> Optional[DomainNotification]:
for notification in self.notifications:
if notification.id == id:
return notification
return None

async def find_unread_by_profile_id(self, profile_id: UUID) -> List[DomainNotification]:
unread_notifications = []
for notification in self.notifications:
if notification.profile_id == profile_id and not notification.is_read():
unread_notifications.append(notification)

unread_notifications.sort(key=lambda x: x.created_at, reverse=True)
return unread_notifications

async def update_notification(self, notification: DomainNotification) -> DomainNotification:
for i, existing_notification in enumerate(self.notifications):
if existing_notification.id == notification.id:
self.notifications[i] = notification
return notification
return notification

async def find_all_profile_ids(self) -> List[UUID]:
profile_ids = set()
for notification in self.notifications:
profile_ids.add(notification.profile_id)
return list(profile_ids)
28 changes: 28 additions & 0 deletions src/adapters/scheduler/daily_notification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
import logging

from src.container import container
from src.adapters.sqlalchemy.repositories.notification import SqlAlchemyNotificationRepository
from src.domain.services.notification import NotificationService

logger = logging.getLogger("apscheduler")

def start_daily_notification_scheduler():
scheduler = AsyncIOScheduler()
trigger = CronTrigger(hour=0, minute=0)
print(f"Daily notification scheduler started 🚀 at {trigger}")

async def job():
print("Running daily notification job...")
try:
async with container.SessionFactory() as session:
repo = SqlAlchemyNotificationRepository(session)
notification_service = NotificationService(repo)
notifications = await notification_service.create_daily_notifications_for_all_users()
logger.info(f"[Scheduler] {len(notifications)} daily notifications created successfully")
except Exception as e:
logger.error(f"[Scheduler] Error creating daily notifications: {e}")

scheduler.add_job(job, trigger)
scheduler.start()
89 changes: 89 additions & 0 deletions src/adapters/sqlalchemy/repositories/notification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List, Optional
from uuid import UUID

from src.domain.model.notification import Notification as DomainNotification
from src.domain.ports.notification_repository import NotificationRepository
from src.adapters.sqlalchemy.models import Notification as SqlAlchemyNotification, Profile as SqlAlchemyProfile

class SqlAlchemyNotificationRepository(NotificationRepository):
def __init__(self, session: AsyncSession):
self.session = session

async def add_notification(self, notification: DomainNotification) -> DomainNotification:
orm_notification = SqlAlchemyNotification(**notification.to_orm_dict())
self.session.add(orm_notification)
await self.session.commit()
await self.session.refresh(orm_notification)

return DomainNotification(
id=orm_notification.id,
profile_id=orm_notification.profile_id,
title=orm_notification.title,
description=orm_notification.description,
created_at=orm_notification.created_at,
read_at=orm_notification.read_at
)

async def find_by_id(self, id: UUID) -> Optional[DomainNotification]:
result = await self.session.execute(
select(SqlAlchemyNotification).where(SqlAlchemyNotification.id == id)
)
orm_notification = result.scalar_one_or_none()

if not orm_notification:
return None

return DomainNotification(
id=orm_notification.id,
profile_id=orm_notification.profile_id,
title=orm_notification.title,
description=orm_notification.description,
created_at=orm_notification.created_at,
read_at=orm_notification.read_at
)

async def find_unread_by_profile_id(self, profile_id: UUID) -> List[DomainNotification]:
result = await self.session.execute(
select(SqlAlchemyNotification)
.where(SqlAlchemyNotification.profile_id == profile_id)
.where(SqlAlchemyNotification.read_at.is_(None))
.order_by(SqlAlchemyNotification.created_at.desc())
)
orm_notifications = result.scalars().all()

return [
DomainNotification(
id=orm_notification.id,
profile_id=orm_notification.profile_id,
title=orm_notification.title,
description=orm_notification.description,
created_at=orm_notification.created_at,
read_at=orm_notification.read_at
)
for orm_notification in orm_notifications
]

async def update_notification(self, notification: DomainNotification) -> DomainNotification:
result = await self.session.execute(
select(SqlAlchemyNotification).where(SqlAlchemyNotification.id == notification.id)
)
orm_notification = result.scalar_one_or_none()

if orm_notification:
orm_notification.title = notification.title
orm_notification.description = notification.description
orm_notification.read_at = notification.read_at

await self.session.commit()
await self.session.refresh(orm_notification)

return notification

async def find_all_profile_ids(self) -> List[UUID]:
result = await self.session.execute(
select(SqlAlchemyProfile.id)
)
profile_ids = result.scalars().all()
return list(profile_ids)
25 changes: 25 additions & 0 deletions src/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from src.domain.services.exercise import ExerciseService
from src.domain.services.diet import DietService
from src.domain.services.daily_checkup import DailyCheckupService
from src.domain.services.notification import NotificationService
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 @@ -41,13 +42,15 @@ def __init__(self, env: str | None = None):
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
from src.adapters.inmemory.repositories.notification import InMemoryNotificationRepository
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()
self.notification_repo = InMemoryNotificationRepository()
else:
from src.adapters.sqlalchemy.db import SessionLocal
from src.adapters.minio.image_storage import MinioImageStorage
Expand Down Expand Up @@ -191,5 +194,27 @@ async def method(*args, **kwargs):
repo = SessionManagedRepository(SqlAlchemyDailyCheckupRepository, self.SessionFactory)
return DailyCheckupService(repo, self.daily_checkup_image_storage)

def get_notification_service(self):
if self.env in ("dev", "test"):
repo = self.notification_repo
return NotificationService(repo)
else:
from src.adapters.sqlalchemy.repositories.notification import SqlAlchemyNotificationRepository

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(SqlAlchemyNotificationRepository, self.SessionFactory)
return NotificationService(repo)

container = Container()
30 changes: 30 additions & 0 deletions src/domain/model/notification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from dataclasses import dataclass, field
from typing import Optional
from uuid import UUID, uuid4
from datetime import datetime

@dataclass
class Notification:
id: UUID = field(default_factory=uuid4)
profile_id: UUID = field(default_factory=uuid4)
title: str = ""
description: Optional[str] = None
created_at: datetime = field(default_factory=datetime.utcnow)
read_at: Optional[datetime] = None

def to_orm_dict(self) -> dict:
return {
"id": str(self.id),
"profile_id": str(self.profile_id),
"title": self.title,
"description": self.description,
"created_at": self.created_at,
"read_at": self.read_at,
}

def is_read(self) -> bool:
return self.read_at is not None

def mark_as_read(self) -> None:
if self.read_at is None:
self.read_at = datetime.utcnow()
25 changes: 25 additions & 0 deletions src/domain/ports/notification_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from abc import ABC, abstractmethod
from uuid import UUID
from typing import List, Optional
from src.domain.model.notification import Notification as DomainNotification

class NotificationRepository(ABC):
@abstractmethod
async def add_notification(self, notification: DomainNotification) -> DomainNotification:
pass

@abstractmethod
async def find_by_id(self, id: UUID) -> Optional[DomainNotification]:
pass

@abstractmethod
async def find_unread_by_profile_id(self, profile_id: UUID) -> List[DomainNotification]:
pass

@abstractmethod
async def update_notification(self, notification: DomainNotification) -> DomainNotification:
pass

@abstractmethod
async def find_all_profile_ids(self) -> List[UUID]:
pass
46 changes: 46 additions & 0 deletions src/domain/services/notification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from uuid import UUID, uuid4
from datetime import datetime
from typing import List

from src.domain.model.notification import Notification as DomainNotification
from src.domain.ports.notification_repository import NotificationRepository
from src.domain.exceptions import NotFoundError

class NotificationService:
def __init__(self, repo: NotificationRepository):
self._repo = repo

async def get_unread_notifications(self, profile_id: UUID) -> List[DomainNotification]:
return await self._repo.find_unread_by_profile_id(profile_id)

async def mark_notification_as_read(self, notification_id: UUID, profile_id: UUID) -> DomainNotification:
notification = await self._repo.find_by_id(notification_id)
if not notification:
raise NotFoundError(f"Notification with id {notification_id} not found")

if notification.profile_id != profile_id:
raise NotFoundError(f"Notification with id {notification_id} not found")

if not notification.is_read():
notification.mark_as_read()
notification = await self._repo.update_notification(notification)

return notification

async def create_daily_notifications_for_all_users(self) -> List[DomainNotification]:
profile_ids = await self._repo.find_all_profile_ids()

notifications = []
for profile_id in profile_ids:
notification = DomainNotification(
id=uuid4(),
profile_id=profile_id,
title="Daily Checkup",
description="votre checkup journalié a remplir vous attends, ne faite pas attendre votre coach donner lui votre ressentie quautidien ;)",
created_at=datetime.utcnow(),
read_at=None
)
created_notification = await self._repo.add_notification(notification)
notifications.append(created_notification)

return notifications
Loading