From a2e2a3305aa255521743c88288454ea11a696644 Mon Sep 17 00:00:00 2001 From: Joaquin Sarro Date: Fri, 28 Mar 2025 16:44:28 -0300 Subject: [PATCH 1/6] add polymorfic table --- .../2024_03_24_1223-646ed9779614_add_user.py | 36 +++++++++++ app/auth/api/{endpoints.py => auth.py} | 30 ++++++++- app/auth/api/routers.py | 4 +- app/auth/use_cases/auth_user_use_case.py | 9 ++- app/celery/tasks/emails.py | 21 ++++--- app/common/enums/extended_enum.py | 7 +++ ...urrent_user.py => get_current_provider.py} | 12 ++-- app/users/api/endpoints.py | 23 ------- app/users/api/providers.py | 26 ++++++++ app/users/api/routers.py | 6 +- app/users/enums/user_type_enum.py | 6 ++ app/users/models/__init__.py | 4 +- app/users/models/patient.py | 18 ++++++ app/users/models/provider.py | 19 ++++++ app/users/models/user.py | 11 +++- app/users/repositories/patients_repository.py | 12 ++++ .../repositories/providers_repository.py | 12 ++++ app/users/repositories/users_repository.py | 13 ++-- app/users/schemas/patient_schema.py | 39 ++++++++++++ app/users/schemas/provider_schema.py | 26 ++++++++ app/users/schemas/user_schema.py | 12 ++-- app/users/services/patients_service.py | 58 +++++++++++++++++ app/users/services/providers_service.py | 60 ++++++++++++++++++ app/users/services/users_service.py | 40 +++++++----- .../use_cases/create_patient_use_case.py | 62 +++++++++++++++++++ app/users/use_cases/create_user_use_case.py | 43 ------------- 26 files changed, 491 insertions(+), 118 deletions(-) rename app/auth/api/{endpoints.py => auth.py} (55%) create mode 100644 app/common/enums/extended_enum.py rename app/users/api/dependencies/{get_current_user.py => get_current_provider.py} (69%) delete mode 100644 app/users/api/endpoints.py create mode 100644 app/users/api/providers.py create mode 100644 app/users/enums/user_type_enum.py create mode 100644 app/users/models/patient.py create mode 100644 app/users/models/provider.py create mode 100644 app/users/repositories/patients_repository.py create mode 100644 app/users/repositories/providers_repository.py create mode 100644 app/users/schemas/patient_schema.py create mode 100644 app/users/schemas/provider_schema.py create mode 100644 app/users/services/patients_service.py create mode 100644 app/users/services/providers_service.py create mode 100644 app/users/use_cases/create_patient_use_case.py delete mode 100644 app/users/use_cases/create_user_use_case.py diff --git a/alembic/versions/2024_03_24_1223-646ed9779614_add_user.py b/alembic/versions/2024_03_24_1223-646ed9779614_add_user.py index 80ee5ff..47844da 100644 --- a/alembic/versions/2024_03_24_1223-646ed9779614_add_user.py +++ b/alembic/versions/2024_03_24_1223-646ed9779614_add_user.py @@ -41,10 +41,46 @@ def upgrade() -> None: sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("email"), ) + op.create_table( + "providers", + sa.Column("id", sa.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "patients", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("provider_id", sa.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["id"], + ["users.id"], + ), + sa.ForeignKeyConstraint( + ["provider_id"], + ["providers.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.add_column("users", sa.Column("type", sa.String(), nullable=False)) + op.add_column( + "users", sa.Column("first_name", sa.String(length=30), nullable=False) + ) + op.add_column( + "users", sa.Column("last_name", sa.String(length=30), nullable=False) + ) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("users", "last_name") + op.drop_column("users", "first_name") + op.drop_column("users", "type") + + op.drop_table("patients") + op.drop_table("providers") op.drop_table("users") # ### end Alembic commands ### diff --git a/app/auth/api/endpoints.py b/app/auth/api/auth.py similarity index 55% rename from app/auth/api/endpoints.py rename to app/auth/api/auth.py index 869d69a..0a5c022 100644 --- a/app/auth/api/endpoints.py +++ b/app/auth/api/auth.py @@ -13,6 +13,9 @@ from slowapi import Limiter from slowapi.util import get_remote_address +from app.users.repositories.providers_repository import providers_repository +from app.users.repositories.patients_repository import patients_repository + router = APIRouter() settings = get_settings() @@ -20,16 +23,37 @@ limiter = Limiter(key_func=get_remote_address) -@router.post("/login", status_code=status.HTTP_204_NO_CONTENT) +@router.post("/providers/login", status_code=status.HTTP_204_NO_CONTENT) @limiter.limit(settings.AUTHENTICATION_API_RATE_LIMIT) -def login_access_token( +def login_provider_access_token( request: Request, session: SessionDependency, login_data: UserLogin, response: Response, ) -> None: try: - AuthUserUseCase(session).execute(login_data, response) + AuthUserUseCase(session).execute( + login_data, response, providers_repository + ) + except (ModelNotFoundException, InvalidCredentialsException): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials", + ) + + +@router.post("/patients/login", status_code=status.HTTP_204_NO_CONTENT) +@limiter.limit(settings.AUTHENTICATION_API_RATE_LIMIT) +def login_patient_access_token( + request: Request, + session: SessionDependency, + login_data: UserLogin, + response: Response, +) -> None: + try: + AuthUserUseCase(session).execute( + login_data, response, patients_repository + ) except (ModelNotFoundException, InvalidCredentialsException): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, diff --git a/app/auth/api/routers.py b/app/auth/api/routers.py index 0f1b914..441b285 100644 --- a/app/auth/api/routers.py +++ b/app/auth/api/routers.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.auth.api import endpoints +from app.auth.api import auth api_router = APIRouter() -api_router.include_router(endpoints.router, prefix="/auth", tags=["login"]) +api_router.include_router(auth.router, prefix="/auth", tags=["login"]) diff --git a/app/auth/use_cases/auth_user_use_case.py b/app/auth/use_cases/auth_user_use_case.py index 1e18d98..08a8b6c 100644 --- a/app/auth/use_cases/auth_user_use_case.py +++ b/app/auth/use_cases/auth_user_use_case.py @@ -6,14 +6,19 @@ from app.auth.schemas.auth_schema import UserLogin from app.auth.services.auth_service import AuthService from app.auth.utils.set_http_only_cookie import set_http_only_cookie -from app.users.repositories.users_repository import users_repository +from app.users.repositories.users_repository import UsersRepository class AuthUserUseCase: def __init__(self, session: Session): self.session = session - def execute(self, login_data: UserLogin, response: Response) -> None: + def execute( + self, + login_data: UserLogin, + response: Response, + users_repository: UsersRepository, + ) -> None: patient = AuthService(self.session, users_repository).authenticate( login_data ) diff --git a/app/celery/tasks/emails.py b/app/celery/tasks/emails.py index 8bf92e9..9893b5c 100644 --- a/app/celery/tasks/emails.py +++ b/app/celery/tasks/emails.py @@ -8,9 +8,10 @@ from app.core.config import get_settings -from app.users.repositories.users_repository import users_repository +from app.users.repositories.patients_repository import patients_repository +from app.users.schemas.patient_schema import PatientInDB from app.users.schemas.user_schema import UserInDB -from app.users.services.users_service import UsersService +from app.users.services.patients_service import PatientsService settings = get_settings() @@ -19,12 +20,12 @@ def send_reminder_email() -> None: session = SessionLocal() try: - users = UsersService(session, users_repository).list( + patients = PatientsService(session, patients_repository).list( ListFilter(page=1, page_size=100) ) - for user in users.data: + for patient in patients.data: EmailService(ExampleEmailClient()).send_user_remind_email( - UserInDB.model_validate(user) + UserInDB.model_validate(patient) ) finally: session.close() @@ -36,13 +37,15 @@ def send_reminder_email() -> None: max_retries=settings.SEND_WELCOME_EMAIL_MAX_RETRIES, retry_jitter=False, ) -def send_welcome_email(user_id: UUID) -> None: +def send_welcome_email(patient_id: UUID) -> None: session = SessionLocal() try: - user = UsersService(session, users_repository).get_by_id(user_id) - if user: + patient = PatientsService(session, patients_repository).get_by_id( + patient_id + ) + if patient: EmailService(ExampleEmailClient()).send_new_user_email( - UserInDB.model_validate(user) + PatientInDB.model_validate(patient) ) finally: session.close() diff --git a/app/common/enums/extended_enum.py b/app/common/enums/extended_enum.py new file mode 100644 index 0000000..c59bb31 --- /dev/null +++ b/app/common/enums/extended_enum.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class ExtendedEnum(Enum): + @classmethod + def list(cls) -> list[str]: + return list(map(lambda c: c.value, cls)) diff --git a/app/users/api/dependencies/get_current_user.py b/app/users/api/dependencies/get_current_provider.py similarity index 69% rename from app/users/api/dependencies/get_current_user.py rename to app/users/api/dependencies/get_current_provider.py index b1ba920..72ff589 100644 --- a/app/users/api/dependencies/get_current_user.py +++ b/app/users/api/dependencies/get_current_provider.py @@ -11,19 +11,21 @@ from app.auth.exceptions.invalid_credentials_exception import ( InvalidCredentialsException, ) -from app.users.repositories.users_repository import users_repository from app.users.schemas.user_schema import UserInDB -from app.users.services.users_service import UsersService +from app.users.services.providers_service import ProvidersService +from app.users.repositories.providers_repository import providers_repository -def get_current_user(session: SessionDependency, token: TokenDep) -> UserInDB: +def get_current_provider( + session: SessionDependency, token: TokenDep +) -> UserInDB: try: token_data = validate_token(token) except InvalidCredentialsException as e: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=e.message ) - provider = UsersService(session, users_repository).get_by_id( + provider = ProvidersService(session, providers_repository).get_by_id( UUID(token_data.user_id) ) if not provider: @@ -31,4 +33,4 @@ def get_current_user(session: SessionDependency, token: TokenDep) -> UserInDB: return provider -CurrentUser = Annotated[UserInDB, Depends(get_current_user)] +CurrentProvider = Annotated[UserInDB, Depends(get_current_provider)] diff --git a/app/users/api/endpoints.py b/app/users/api/endpoints.py deleted file mode 100644 index 7cb1655..0000000 --- a/app/users/api/endpoints.py +++ /dev/null @@ -1,23 +0,0 @@ -from fastapi import APIRouter, status - -from app.users.schemas.user_schema import CreateUserRequest, UserResponse -from app.users.use_cases.create_user_use_case import CreateUserUseCase -from app.users.api.dependencies.get_current_user import CurrentUser -from app.common.api.dependencies.get_session import SessionDependency - -router = APIRouter() - - -@router.get("/current", status_code=status.HTTP_200_OK) -def get_current_user( - current_user: CurrentUser, -) -> UserResponse: - return UserResponse.model_validate(current_user) - - -@router.post("", status_code=status.HTTP_201_CREATED) -def create_user( - session: SessionDependency, - create_user_request: CreateUserRequest, -) -> UserResponse: - return CreateUserUseCase(session).execute(create_user_request) diff --git a/app/users/api/providers.py b/app/users/api/providers.py new file mode 100644 index 0000000..27bc021 --- /dev/null +++ b/app/users/api/providers.py @@ -0,0 +1,26 @@ +from fastapi import APIRouter, status + +from app.users.schemas.patient_schema import ( + CreatePatientRequest, + PatientResponse, +) +from app.users.api.dependencies.get_current_provider import CurrentProvider +from app.common.api.dependencies.get_session import SessionDependency +from app.users.use_cases.create_patient_use_case import CreatePatientUseCase + +router = APIRouter() + + +@router.get("/current", status_code=status.HTTP_200_OK) +def get_current_patient( + current_user: CurrentProvider, +) -> PatientResponse: + return PatientResponse.model_validate(current_user) + + +@router.post("", status_code=status.HTTP_201_CREATED) +def create_patient( + session: SessionDependency, + create_patient_request: CreatePatientRequest, +) -> PatientResponse: + return CreatePatientUseCase(session).execute(create_patient_request) diff --git a/app/users/api/routers.py b/app/users/api/routers.py index d2cd052..ef522d1 100644 --- a/app/users/api/routers.py +++ b/app/users/api/routers.py @@ -1,6 +1,8 @@ from fastapi import APIRouter -from app.users.api import endpoints +from app.users.api import providers api_router = APIRouter() -api_router.include_router(endpoints.router, prefix="/users", tags=["users"]) +api_router.include_router( + providers.router, prefix="/providers", tags=["providers"] +) diff --git a/app/users/enums/user_type_enum.py b/app/users/enums/user_type_enum.py new file mode 100644 index 0000000..f1888f0 --- /dev/null +++ b/app/users/enums/user_type_enum.py @@ -0,0 +1,6 @@ +from app.common.enums.extended_enum import ExtendedEnum + + +class UserTypeEnum(ExtendedEnum): + PATIENT = "patient" + PROVIDER = "provider" diff --git a/app/users/models/__init__.py b/app/users/models/__init__.py index a5057a0..2f6133e 100644 --- a/app/users/models/__init__.py +++ b/app/users/models/__init__.py @@ -1 +1,3 @@ -from .user import User # noqa +from .user import User +from .patient import Patient +from .provider import Provider diff --git a/app/users/models/patient.py b/app/users/models/patient.py new file mode 100644 index 0000000..af57cd1 --- /dev/null +++ b/app/users/models/patient.py @@ -0,0 +1,18 @@ +from sqlalchemy import UUID, Column, ForeignKey +from sqlalchemy.orm import relationship + +from app.users.models.user import User + + +class Patient(User): + __tablename__ = "patients" + + id = Column(UUID, ForeignKey("users.id"), primary_key=True) + provider_id = Column(UUID, ForeignKey("providers.id"), nullable=False) + provider = relationship( + "Provider", foreign_keys=[provider_id], back_populates="patients" + ) + + __mapper_args__ = { + "polymorphic_identity": "patient", + } diff --git a/app/users/models/provider.py b/app/users/models/provider.py new file mode 100644 index 0000000..e509d62 --- /dev/null +++ b/app/users/models/provider.py @@ -0,0 +1,19 @@ +from sqlalchemy import UUID, Column, ForeignKey +from sqlalchemy.orm import relationship + +from app.users.models.user import User + + +class Provider(User): + __tablename__ = "providers" + + id = Column(UUID, ForeignKey("users.id"), primary_key=True) + patients = relationship( + "Patient", + back_populates="provider", + foreign_keys="[Patient.provider_id]", + ) + + __mapper_args__ = { + "polymorphic_identity": "provider", + } diff --git a/app/users/models/user.py b/app/users/models/user.py index df92709..6575870 100644 --- a/app/users/models/user.py +++ b/app/users/models/user.py @@ -4,6 +4,15 @@ class User(Base): - __tablename__ = "users" + __tablename__ = "users" # type: ignore + email = Column(String(100), unique=True, nullable=False) hashed_password = Column(String, nullable=False) + type = Column(String, nullable=False) + first_name = Column(String(30), nullable=False) + last_name = Column(String(30), nullable=False) + + __mapper_args__ = { + "polymorphic_identity": "users", + "polymorphic_on": "type", + } diff --git a/app/users/repositories/patients_repository.py b/app/users/repositories/patients_repository.py new file mode 100644 index 0000000..118e4bb --- /dev/null +++ b/app/users/repositories/patients_repository.py @@ -0,0 +1,12 @@ +from app.users.models import Patient +from app.users.repositories.users_repository import UsersRepository +from app.users.schemas.patient_schema import PatientCreate, PatientUpdate + + +class PatientsRepository( + UsersRepository[Patient, PatientCreate, PatientUpdate] +): + pass + + +patients_repository = PatientsRepository(Patient) diff --git a/app/users/repositories/providers_repository.py b/app/users/repositories/providers_repository.py new file mode 100644 index 0000000..3692db2 --- /dev/null +++ b/app/users/repositories/providers_repository.py @@ -0,0 +1,12 @@ +from app.users.models import Provider +from app.users.repositories.users_repository import UsersRepository +from app.users.schemas.provider_schema import ProviderCreate, ProviderUpdate + + +class ProvidersRepository( + UsersRepository[Provider, ProviderCreate, ProviderUpdate] +): + pass + + +providers_repository = ProvidersRepository(Provider) diff --git a/app/users/repositories/users_repository.py b/app/users/repositories/users_repository.py index af5ee36..5a9faaa 100644 --- a/app/users/repositories/users_repository.py +++ b/app/users/repositories/users_repository.py @@ -1,14 +1,17 @@ +from abc import ABC +from typing import Any, TypeVar +from pydantic import BaseModel from sqlalchemy.orm import Session from app.common.repositories.base_repository import BaseRepository from app.users.models.user import User -from app.users.schemas.user_schema import UserCreate, UserUpdate +ModelType = TypeVar("ModelType", bound=Any) +TCreate = TypeVar("TCreate", bound=BaseModel) +TUpdate = TypeVar("TUpdate", bound=BaseModel) -class UsersRepository(BaseRepository[User, UserCreate, UserUpdate]): + +class UsersRepository(BaseRepository[ModelType, TCreate, TUpdate], ABC): def get_by_email(self, db: Session, email: str) -> User | None: return db.query(self.model).filter(User.email == email).first() - - -users_repository = UsersRepository(User) diff --git a/app/users/schemas/patient_schema.py b/app/users/schemas/patient_schema.py new file mode 100644 index 0000000..6253918 --- /dev/null +++ b/app/users/schemas/patient_schema.py @@ -0,0 +1,39 @@ +from typing import Annotated +from uuid import UUID + +from pydantic import BaseModel, EmailStr, StringConstraints + +from app.users.schemas.user_schema import ( + UserBase, + UserCreate, + UserUpdate, + UserInDB, +) + + +class PatientBase(UserBase): + provider_id: UUID + + +class PatientCreate(UserCreate): + provider_id: UUID + + +class PatientUpdate(UserUpdate): + pass + + +class PatientInDB(UserInDB, PatientBase): + provider_id: UUID + + +class PatientResponse(PatientInDB): + pass + + +class CreatePatientRequest(BaseModel): + email: EmailStr + password: str + first_name: Annotated[str, StringConstraints(max_length=30)] + last_name: Annotated[str, StringConstraints(max_length=30)] + provider_id: UUID diff --git a/app/users/schemas/provider_schema.py b/app/users/schemas/provider_schema.py new file mode 100644 index 0000000..2d6beb1 --- /dev/null +++ b/app/users/schemas/provider_schema.py @@ -0,0 +1,26 @@ +from app.users.schemas.user_schema import ( + UserBase, + UserCreate, + UserUpdate, + UserInDB, +) + + +class ProviderBase(UserBase): + pass + + +class ProviderCreate(UserCreate): + pass + + +class ProviderUpdate(UserUpdate): + pass + + +class ProviderInDB(UserInDB): + pass + + +class ProviderResponse(ProviderInDB): + pass diff --git a/app/users/schemas/user_schema.py b/app/users/schemas/user_schema.py index 8bc7924..b329be2 100644 --- a/app/users/schemas/user_schema.py +++ b/app/users/schemas/user_schema.py @@ -2,14 +2,21 @@ from pydantic import BaseModel, ConfigDict, EmailStr +from app.users.enums.user_type_enum import UserTypeEnum + class UserBase(BaseModel): email: EmailStr + hashed_password: str + type: UserTypeEnum class UserCreate(BaseModel): email: EmailStr hashed_password: str + type: UserTypeEnum + first_name: str + last_name: str class UserUpdate(BaseModel): @@ -26,11 +33,6 @@ class UserResponse(UserInDB): pass -class CreateUserRequest(BaseModel): - email: EmailStr - password: str - - class UserAuth(BaseModel): model_config = ConfigDict(from_attributes=True) id: UUID diff --git a/app/users/services/patients_service.py b/app/users/services/patients_service.py new file mode 100644 index 0000000..7aaf32c --- /dev/null +++ b/app/users/services/patients_service.py @@ -0,0 +1,58 @@ +from typing import TypeVar +from uuid import UUID + +from sqlalchemy.orm import Session + +from app.common.exceptions.model_not_found_exception import ( + ModelNotFoundException, +) +from app.users.repositories.patients_repository import PatientsRepository +from app.users.schemas.patient_schema import ( + PatientInDB, + PatientCreate, + PatientUpdate, +) +from app.users.services.users_service import UsersService + +TInDB = TypeVar("TInDB") +TCode = TypeVar("TCode") +TCreate = TypeVar("TCreate") +TUpdate = TypeVar("TUpdate") + + +class PatientsService( + UsersService[ + PatientInDB, + PatientCreate, + PatientUpdate, + ] +): + def __init__(self, session: Session, repository: PatientsRepository): + self.session = session + self.repository = repository + + def create(self, create_data: PatientCreate) -> PatientInDB: + created_patient = self.repository.create(self.session, create_data) + return PatientInDB.model_validate(created_patient) + + def update(self, user_id: UUID, update_data: PatientUpdate) -> PatientInDB: + patient_model = self.repository.get(self.session, user_id) + if patient_model is None: + raise ModelNotFoundException("Patient not found") + + created_patient = self.repository.update( + self.session, patient_model, update_data + ) + return PatientInDB.model_validate(created_patient) + + def get_by_email(self, email: str) -> PatientInDB | None: + user = self.repository.get_by_email(self.session, email) + if not user: + return None + return PatientInDB.model_validate(user) + + def get_by_id(self, user_id: UUID) -> PatientInDB | None: + user = self.repository.get(self.session, user_id) + if not user: + return None + return PatientInDB.model_validate(user) diff --git a/app/users/services/providers_service.py b/app/users/services/providers_service.py new file mode 100644 index 0000000..d20c4bc --- /dev/null +++ b/app/users/services/providers_service.py @@ -0,0 +1,60 @@ +from typing import TypeVar +from uuid import UUID + +from sqlalchemy.orm import Session + +from app.common.exceptions.model_not_found_exception import ( + ModelNotFoundException, +) +from app.users.repositories.providers_repository import ProvidersRepository +from app.users.schemas.provider_schema import ( + ProviderUpdate, + ProviderInDB, + ProviderCreate, +) +from app.users.services.users_service import UsersService + +TInDB = TypeVar("TInDB") +TCode = TypeVar("TCode") +TCreate = TypeVar("TCreate") +TUpdate = TypeVar("TUpdate") + + +class ProvidersService( + UsersService[ + ProviderInDB, + ProviderCreate, + ProviderUpdate, + ] +): + def __init__(self, session: Session, repository: ProvidersRepository): + self.session = session + self.repository = repository + + def create(self, create_data: ProviderCreate) -> ProviderInDB: + created_patient = self.repository.create(self.session, create_data) + return ProviderInDB.model_validate(created_patient) + + def update( + self, user_id: UUID, update_data: ProviderUpdate + ) -> ProviderInDB: + patient_model = self.repository.get(self.session, user_id) + if patient_model is None: + raise ModelNotFoundException("Patient not found") + + created_patient = self.repository.update( + self.session, patient_model, update_data + ) + return ProviderInDB.model_validate(created_patient) + + def get_by_email(self, email: str) -> ProviderInDB | None: + user = self.repository.get_by_email(self.session, email) + if not user: + return None + return ProviderInDB.model_validate(user) + + def get_by_id(self, user_id: UUID) -> ProviderInDB | None: + user = self.repository.get(self.session, user_id) + if not user: + return None + return ProviderInDB.model_validate(user) diff --git a/app/users/services/users_service.py b/app/users/services/users_service.py index a392e91..bffedbe 100644 --- a/app/users/services/users_service.py +++ b/app/users/services/users_service.py @@ -1,32 +1,38 @@ +from abc import ABC, abstractmethod +from typing import Generic, TypeVar from uuid import UUID from sqlalchemy.orm import Session from app.common.schemas.pagination_schema import ListFilter, ListResponse from app.users.repositories.users_repository import UsersRepository -from app.users.schemas.user_schema import UserCreate, UserInDB +TInDB = TypeVar("TInDB") +TCode = TypeVar("TCode") +TCreate = TypeVar("TCreate") +TUpdate = TypeVar("TUpdate") -class UsersService: + +class UsersService(ABC, Generic[TInDB, TCreate, TUpdate]): def __init__(self, session: Session, repository: UsersRepository): self.session = session self.repository = repository - def get_by_email(self, email: str) -> UserInDB | None: - user = self.repository.get_by_email(self.session, email) - if not user: - return None - return UserInDB.model_validate(user) - - def get_by_id(self, user_id: UUID) -> UserInDB | None: - user = self.repository.get(self.session, user_id) - if not user: - return None - return UserInDB.model_validate(user) - - def create_user(self, user: UserCreate) -> UserInDB: - created_user = self.repository.create(self.session, user) - return UserInDB.model_validate(created_user) + @abstractmethod + def create(self, create_data: TCreate) -> TInDB: + pass + + @abstractmethod + def update(self, user_id: UUID, update_data: TUpdate) -> TInDB: + pass + + @abstractmethod + def get_by_email(self, email: str) -> TInDB | None: + pass + + @abstractmethod + def get_by_id(self, user_id: UUID) -> TInDB | None: + pass def list(self, list_options: ListFilter) -> ListResponse: return self.repository.list(self.session, list_options) diff --git a/app/users/use_cases/create_patient_use_case.py b/app/users/use_cases/create_patient_use_case.py new file mode 100644 index 0000000..f2ba9c7 --- /dev/null +++ b/app/users/use_cases/create_patient_use_case.py @@ -0,0 +1,62 @@ +from fastapi.exceptions import HTTPException +from sqlalchemy.orm import Session +from fastapi import status + +from app.auth.utils import security +from app.users.enums.user_type_enum import UserTypeEnum +from app.users.repositories.providers_repository import providers_repository +from app.users.repositories.patients_repository import patients_repository +from app.users.schemas.patient_schema import ( + CreatePatientRequest, + PatientCreate, + PatientResponse, +) +from app.users.services.patients_service import PatientsService +from app.users.services.providers_service import ProvidersService + + +class CreatePatientUseCase: + def __init__(self, session: Session): + self.session = session + + def execute( + self, create_patient_request: CreatePatientRequest + ) -> PatientResponse: + # from app.celery.tasks.emails import send_welcome_email + + patients_service = PatientsService(self.session, patients_repository) + providers_service = ProvidersService( + self.session, providers_repository + ) + + if ( + patients_service.get_by_email(create_patient_request.email) + is not None + ): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Patient with that email already registered.", + ) + + if ( + providers_service.get_by_id(create_patient_request.provider_id) + is None + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Provider not found.", + ) + + created_patient = patients_service.create( + PatientCreate( + **create_patient_request.model_dump(), + hashed_password=security.get_password_hash( + create_patient_request.password + ), + type=UserTypeEnum.PATIENT, + ) + ) + + # send_welcome_email.delay(created_user.id) # type: ignore + + return PatientResponse(**created_patient.model_dump()) diff --git a/app/users/use_cases/create_user_use_case.py b/app/users/use_cases/create_user_use_case.py deleted file mode 100644 index 31a5c69..0000000 --- a/app/users/use_cases/create_user_use_case.py +++ /dev/null @@ -1,43 +0,0 @@ -from fastapi.exceptions import HTTPException -from sqlalchemy.orm import Session -from fastapi import status - -from app.auth.utils import security -from app.users.repositories.users_repository import users_repository -from app.users.schemas.user_schema import ( - CreateUserRequest, - UserCreate, - UserResponse, -) -from app.users.services.users_service import UsersService - - -class CreateUserUseCase: - def __init__(self, session: Session): - self.session = session - - def execute(self, create_user_request: CreateUserRequest) -> UserResponse: - from app.celery.tasks.emails import send_welcome_email - - users_service = UsersService(self.session, users_repository) - if users_service.get_by_email(create_user_request.email): - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="User with that email already registered.", - ) - - created_user = users_service.create_user( - UserCreate( - email=create_user_request.email, - hashed_password=security.get_password_hash( - create_user_request.password - ), - ) - ) - - send_welcome_email.delay(created_user.id) # type: ignore - - return UserResponse( - id=created_user.id, - email=created_user.email, - ) From f1a301696cd1524d42692398a37d783186ef5c40 Mon Sep 17 00:00:00 2001 From: Joaquin Sarro Date: Fri, 28 Mar 2025 17:19:28 -0300 Subject: [PATCH 2/6] add eager loading in base repository --- .vscode/settings.json | 2 +- app/common/repositories/base_repository.py | 21 +++++++++++--- app/db/session.py | 4 ++- app/users/api/providers.py | 28 ++++++++++++++++--- app/users/repositories/users_repository.py | 19 +++++++++---- app/users/schemas/patient_schema.py | 2 ++ app/users/services/patients_service.py | 5 +++- .../use_cases/create_patient_use_case.py | 4 +-- app/users/use_cases/get_patient_use_case.py | 26 +++++++++++++++++ 9 files changed, 93 insertions(+), 18 deletions(-) create mode 100644 app/users/use_cases/get_patient_use_case.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 92f8ba7..e68edec 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "cSpell.words": ["pydantic", "RABBITMQ"] + "cSpell.words": ["joinedload", "pydantic", "RABBITMQ"] } diff --git a/app/common/repositories/base_repository.py b/app/common/repositories/base_repository.py index 2f48c8f..bdde5ed 100644 --- a/app/common/repositories/base_repository.py +++ b/app/common/repositories/base_repository.py @@ -1,11 +1,11 @@ from math import ceil -from typing import Any, Generic, Optional, Type, TypeVar +from typing import Any, Generic, List, Type, TypeVar from uuid import UUID from fastapi.encoders import jsonable_encoder from pydantic import BaseModel from sqlalchemy import asc, desc -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm.query import Query from app.common.schemas.pagination_schema import ListFilter, ListResponse @@ -28,8 +28,21 @@ def __init__(self, model: Type[ModelType]): """ self.model = model - def get(self, db: Session, model_id: UUID) -> Optional[ModelType]: - return db.query(self.model).filter(self.model.id == model_id).first() + def get( + self, + db: Session, + model_id: UUID, + joined_loads: List[str] | None = None, + ) -> ModelType | None: + query = db.query(self.model).filter(self.model.id == model_id) + + if joined_loads: + for relation in joined_loads: + query = query.options( + joinedload(getattr(self.model, relation)) + ) + + return query.first() def list( self, db: Session, list_options: ListFilter, query: Query | None = None diff --git a/app/db/session.py b/app/db/session.py index 2dcc76b..ce11293 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -4,5 +4,7 @@ from app.core.config import get_settings settings = get_settings() -engine = create_engine(settings.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True) +engine = create_engine( + settings.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True, echo=True +) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/app/users/api/providers.py b/app/users/api/providers.py index 27bc021..2461cf7 100644 --- a/app/users/api/providers.py +++ b/app/users/api/providers.py @@ -1,21 +1,28 @@ +from uuid import UUID from fastapi import APIRouter, status +from fastapi.exceptions import HTTPException +from app.common.exceptions.model_not_found_exception import ( + ModelNotFoundException, +) from app.users.schemas.patient_schema import ( CreatePatientRequest, PatientResponse, ) from app.users.api.dependencies.get_current_provider import CurrentProvider from app.common.api.dependencies.get_session import SessionDependency +from app.users.schemas.provider_schema import ProviderResponse from app.users.use_cases.create_patient_use_case import CreatePatientUseCase +from app.users.use_cases.get_patient_use_case import GetPatientUseCase router = APIRouter() @router.get("/current", status_code=status.HTTP_200_OK) -def get_current_patient( - current_user: CurrentProvider, -) -> PatientResponse: - return PatientResponse.model_validate(current_user) +def get_current_provider( + current_provider: CurrentProvider, +) -> ProviderResponse: + return ProviderResponse.model_validate(current_provider) @router.post("", status_code=status.HTTP_201_CREATED) @@ -24,3 +31,16 @@ def create_patient( create_patient_request: CreatePatientRequest, ) -> PatientResponse: return CreatePatientUseCase(session).execute(create_patient_request) + + +@router.get("/{patient_id}", status_code=status.HTTP_200_OK) +def get_patient( + session: SessionDependency, + patient_id: UUID, +) -> PatientResponse: + try: + return GetPatientUseCase(session).execute(patient_id) + except ModelNotFoundException as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=e.message + ) diff --git a/app/users/repositories/users_repository.py b/app/users/repositories/users_repository.py index 5a9faaa..58b2026 100644 --- a/app/users/repositories/users_repository.py +++ b/app/users/repositories/users_repository.py @@ -1,8 +1,7 @@ from abc import ABC -from typing import Any, TypeVar +from typing import Any, TypeVar, List from pydantic import BaseModel -from sqlalchemy.orm import Session - +from sqlalchemy.orm import Session, joinedload from app.common.repositories.base_repository import BaseRepository from app.users.models.user import User @@ -13,5 +12,15 @@ class UsersRepository(BaseRepository[ModelType, TCreate, TUpdate], ABC): - def get_by_email(self, db: Session, email: str) -> User | None: - return db.query(self.model).filter(User.email == email).first() + def get_by_email( + self, db: Session, email: str, joined_loads: List[str] | None = None + ) -> User | None: + query = db.query(self.model).filter(User.email == email) + + if joined_loads: + for relation in joined_loads: + query = query.options( + joinedload(getattr(self.model, relation)) + ) + + return query.first() diff --git a/app/users/schemas/patient_schema.py b/app/users/schemas/patient_schema.py index 6253918..a067f8d 100644 --- a/app/users/schemas/patient_schema.py +++ b/app/users/schemas/patient_schema.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, EmailStr, StringConstraints +from app.users.schemas.provider_schema import ProviderInDB from app.users.schemas.user_schema import ( UserBase, UserCreate, @@ -25,6 +26,7 @@ class PatientUpdate(UserUpdate): class PatientInDB(UserInDB, PatientBase): provider_id: UUID + provider: ProviderInDB class PatientResponse(PatientInDB): diff --git a/app/users/services/patients_service.py b/app/users/services/patients_service.py index 7aaf32c..0c9fe35 100644 --- a/app/users/services/patients_service.py +++ b/app/users/services/patients_service.py @@ -30,6 +30,7 @@ class PatientsService( def __init__(self, session: Session, repository: PatientsRepository): self.session = session self.repository = repository + self.joined_loads = ["provider"] def create(self, create_data: PatientCreate) -> PatientInDB: created_patient = self.repository.create(self.session, create_data) @@ -46,7 +47,9 @@ def update(self, user_id: UUID, update_data: PatientUpdate) -> PatientInDB: return PatientInDB.model_validate(created_patient) def get_by_email(self, email: str) -> PatientInDB | None: - user = self.repository.get_by_email(self.session, email) + user = self.repository.get_by_email( + self.session, email, joined_loads=self.joined_loads + ) if not user: return None return PatientInDB.model_validate(user) diff --git a/app/users/use_cases/create_patient_use_case.py b/app/users/use_cases/create_patient_use_case.py index f2ba9c7..d44c788 100644 --- a/app/users/use_cases/create_patient_use_case.py +++ b/app/users/use_cases/create_patient_use_case.py @@ -22,7 +22,7 @@ def __init__(self, session: Session): def execute( self, create_patient_request: CreatePatientRequest ) -> PatientResponse: - # from app.celery.tasks.emails import send_welcome_email + from app.celery.tasks.emails import send_welcome_email patients_service = PatientsService(self.session, patients_repository) providers_service = ProvidersService( @@ -57,6 +57,6 @@ def execute( ) ) - # send_welcome_email.delay(created_user.id) # type: ignore + send_welcome_email.delay(created_patient.id) # type: ignore return PatientResponse(**created_patient.model_dump()) diff --git a/app/users/use_cases/get_patient_use_case.py b/app/users/use_cases/get_patient_use_case.py new file mode 100644 index 0000000..e5c67fb --- /dev/null +++ b/app/users/use_cases/get_patient_use_case.py @@ -0,0 +1,26 @@ +from uuid import UUID +from sqlalchemy.orm import Session + +from app.common.exceptions.model_not_found_exception import ( + ModelNotFoundException, +) +from app.users.repositories.patients_repository import patients_repository +from app.users.schemas.patient_schema import ( + PatientResponse, +) +from app.users.services.patients_service import PatientsService + + +class GetPatientUseCase: + def __init__(self, session: Session): + self.session = session + + def execute(self, patient_id: UUID) -> PatientResponse: + patient = PatientsService(self.session, patients_repository).get_by_id( + patient_id + ) + + if patient is None: + raise ModelNotFoundException("Patient not found.") + + return PatientResponse(**patient.model_dump()) From 5b9a0b192179ccccb92ee15920465f609086d4d7 Mon Sep 17 00:00:00 2001 From: Joaquin Sarro Date: Fri, 28 Mar 2025 17:41:39 -0300 Subject: [PATCH 3/6] fix eager loading problem not breaking repository pattern --- app/common/repositories/base_repository.py | 12 +++++++----- app/db/session.py | 4 +--- app/users/repositories/patients_repository.py | 2 +- app/users/repositories/users_repository.py | 14 +++----------- app/users/services/patients_service.py | 5 +---- 5 files changed, 13 insertions(+), 24 deletions(-) diff --git a/app/common/repositories/base_repository.py b/app/common/repositories/base_repository.py index bdde5ed..c48ed60 100644 --- a/app/common/repositories/base_repository.py +++ b/app/common/repositories/base_repository.py @@ -17,7 +17,9 @@ class BaseRepository(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): - def __init__(self, model: Type[ModelType]): + def __init__( + self, model: Type[ModelType], joined_loads: List[str] | None = None + ): """ CRUD object with default methods to Create, Read, Update, Delete (CRUD). @@ -27,17 +29,17 @@ def __init__(self, model: Type[ModelType]): * `schema`: A Pydantic model (schema) class """ self.model = model + self.joined_loads = joined_loads def get( self, db: Session, model_id: UUID, - joined_loads: List[str] | None = None, ) -> ModelType | None: query = db.query(self.model).filter(self.model.id == model_id) - if joined_loads: - for relation in joined_loads: + if self.joined_loads is not None: + for relation in self.joined_loads: query = query.options( joinedload(getattr(self.model, relation)) ) @@ -81,7 +83,7 @@ def update( self, db: Session, db_obj: ModelType, obj_in: UpdateSchemaType ) -> ModelType: obj_data = jsonable_encoder(db_obj) - update_data = obj_in.dict(exclude_unset=True) + update_data = obj_in.model_dump(exclude_unset=True) for field in obj_data: if field in update_data: setattr(db_obj, field, update_data[field]) diff --git a/app/db/session.py b/app/db/session.py index ce11293..2dcc76b 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -4,7 +4,5 @@ from app.core.config import get_settings settings = get_settings() -engine = create_engine( - settings.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True, echo=True -) +engine = create_engine(settings.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/app/users/repositories/patients_repository.py b/app/users/repositories/patients_repository.py index 118e4bb..36f808f 100644 --- a/app/users/repositories/patients_repository.py +++ b/app/users/repositories/patients_repository.py @@ -9,4 +9,4 @@ class PatientsRepository( pass -patients_repository = PatientsRepository(Patient) +patients_repository = PatientsRepository(Patient, joined_loads=["provider"]) diff --git a/app/users/repositories/users_repository.py b/app/users/repositories/users_repository.py index 58b2026..6266fb4 100644 --- a/app/users/repositories/users_repository.py +++ b/app/users/repositories/users_repository.py @@ -1,7 +1,7 @@ from abc import ABC -from typing import Any, TypeVar, List +from typing import Any, TypeVar from pydantic import BaseModel -from sqlalchemy.orm import Session, joinedload +from sqlalchemy.orm import Session from app.common.repositories.base_repository import BaseRepository from app.users.models.user import User @@ -12,15 +12,7 @@ class UsersRepository(BaseRepository[ModelType, TCreate, TUpdate], ABC): - def get_by_email( - self, db: Session, email: str, joined_loads: List[str] | None = None - ) -> User | None: + def get_by_email(self, db: Session, email: str) -> User | None: query = db.query(self.model).filter(User.email == email) - if joined_loads: - for relation in joined_loads: - query = query.options( - joinedload(getattr(self.model, relation)) - ) - return query.first() diff --git a/app/users/services/patients_service.py b/app/users/services/patients_service.py index 0c9fe35..7aaf32c 100644 --- a/app/users/services/patients_service.py +++ b/app/users/services/patients_service.py @@ -30,7 +30,6 @@ class PatientsService( def __init__(self, session: Session, repository: PatientsRepository): self.session = session self.repository = repository - self.joined_loads = ["provider"] def create(self, create_data: PatientCreate) -> PatientInDB: created_patient = self.repository.create(self.session, create_data) @@ -47,9 +46,7 @@ def update(self, user_id: UUID, update_data: PatientUpdate) -> PatientInDB: return PatientInDB.model_validate(created_patient) def get_by_email(self, email: str) -> PatientInDB | None: - user = self.repository.get_by_email( - self.session, email, joined_loads=self.joined_loads - ) + user = self.repository.get_by_email(self.session, email) if not user: return None return PatientInDB.model_validate(user) From 44f66cd925e1d00449fe640566775dedf8918cb0 Mon Sep 17 00:00:00 2001 From: Joaquin Sarro Date: Mon, 31 Mar 2025 17:03:03 -0300 Subject: [PATCH 4/6] feature: adjus list in base repository. Add seeder --- .vscode/settings.json | 2 +- app/celery/tasks/emails.py | 2 +- app/commands/__init__.py | 0 app/commands/seed_users.py | 24 +++++++++++ app/common/repositories/base_repository.py | 17 ++------ app/common/schemas/pagination_schema.py | 10 ++--- app/db/seeders/__init__.py | 0 app/db/seeders/db_seeder.py | 11 +++++ app/db/seeders/users_seeder.py | 44 ++++++++++++++++++++ app/setup.py | 9 ++++ app/users/api/providers.py | 24 +++++++---- app/users/services/patients_service.py | 7 +++- app/users/services/providers_service.py | 9 +++- app/users/services/users_service.py | 9 ++-- app/users/use_cases/list_patient_use_case.py | 24 +++++++++++ poetry.lock | 40 +++++++++++++++++- pyproject.toml | 2 + scripts/seed-db.sh | 4 ++ tests/auth/api/endpoints/test_auth.py | 16 +++---- tests/utils/create_provider.py | 21 ++++++++++ tests/utils/create_user.py | 16 ------- 21 files changed, 232 insertions(+), 59 deletions(-) create mode 100644 app/commands/__init__.py create mode 100644 app/commands/seed_users.py create mode 100644 app/db/seeders/__init__.py create mode 100644 app/db/seeders/db_seeder.py create mode 100644 app/db/seeders/users_seeder.py create mode 100644 app/setup.py create mode 100644 app/users/use_cases/list_patient_use_case.py create mode 100755 scripts/seed-db.sh create mode 100644 tests/utils/create_provider.py delete mode 100644 tests/utils/create_user.py diff --git a/.vscode/settings.json b/.vscode/settings.json index e68edec..811bd21 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "cSpell.words": ["joinedload", "pydantic", "RABBITMQ"] + "cSpell.words": ["conint", "joinedload", "pydantic", "RABBITMQ"] } diff --git a/app/celery/tasks/emails.py b/app/celery/tasks/emails.py index 9893b5c..f699136 100644 --- a/app/celery/tasks/emails.py +++ b/app/celery/tasks/emails.py @@ -23,7 +23,7 @@ def send_reminder_email() -> None: patients = PatientsService(session, patients_repository).list( ListFilter(page=1, page_size=100) ) - for patient in patients.data: + for patient in patients: EmailService(ExampleEmailClient()).send_user_remind_email( UserInDB.model_validate(patient) ) diff --git a/app/commands/__init__.py b/app/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/commands/seed_users.py b/app/commands/seed_users.py new file mode 100644 index 0000000..14f837d --- /dev/null +++ b/app/commands/seed_users.py @@ -0,0 +1,24 @@ +from setuptools import Command + +from app.db.seeders.db_seeder import DBSeeder +from app.db.seeders.users_seeder import UsersSeeder +from app.db.session import SessionLocal + + +class SeedUsersCommand(Command): + description = "Seed patients" + user_options = [] # type: ignore + + def run(self) -> None: + """Run the command.""" + session = SessionLocal() + db_seeder = DBSeeder(session) + db_seeder.run_seeder(UsersSeeder) + + session.close() + + def initialize_options(self) -> None: + """Set default values for options.""" + + def finalize_options(self) -> None: + """Finalize options.""" diff --git a/app/common/repositories/base_repository.py b/app/common/repositories/base_repository.py index c48ed60..c98e6fc 100644 --- a/app/common/repositories/base_repository.py +++ b/app/common/repositories/base_repository.py @@ -1,4 +1,3 @@ -from math import ceil from typing import Any, Generic, List, Type, TypeVar from uuid import UUID @@ -8,7 +7,7 @@ from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm.query import Query -from app.common.schemas.pagination_schema import ListFilter, ListResponse +from app.common.schemas.pagination_schema import ListFilter ModelType = TypeVar("ModelType", bound=Any) @@ -48,12 +47,10 @@ def get( def list( self, db: Session, list_options: ListFilter, query: Query | None = None - ) -> ListResponse: + ) -> List[ModelType]: if not query: query = db.query(self.model) - total = query.count() - if list_options.order_by: column = list_options.order_by direction = list_options.order @@ -62,15 +59,9 @@ def list( query = query.order_by(by(column)) query = query.offset(list_options.page_size * (list_options.page - 1)) - query = query.limit(list_options.page_size) - return ListResponse( - data=query.all(), - page=list_options.page, - page_size=list_options.page_size, - total=total, - total_pages=ceil(total / list_options.page_size), - ) + + return query.all() def create(self, db: Session, obj_in: CreateSchemaType) -> ModelType: obj_in_data = jsonable_encoder(obj_in) diff --git a/app/common/schemas/pagination_schema.py b/app/common/schemas/pagination_schema.py index 058bb90..06b6b45 100644 --- a/app/common/schemas/pagination_schema.py +++ b/app/common/schemas/pagination_schema.py @@ -1,11 +1,11 @@ -from typing import Generic, List, Literal, TypeVar +from typing import Annotated, Generic, List, Literal, TypeVar from pydantic import BaseModel, conint class ListFilter(BaseModel): - page: conint(ge=1) = 1 - page_size: conint(ge=1, le=100) = 10 + page: Annotated[int, conint(ge=1)] = 1 + page_size: Annotated[int, conint(ge=1, le=100)] = 10 name: str | None = None order: Literal["asc", "desc"] | None = None order_by: str | None = None @@ -16,7 +16,7 @@ class ListFilter(BaseModel): class ListResponse(BaseModel, Generic[T]): data: List[T] - page_size: conint(ge=1, le=100) - page: conint(ge=1) + page_size: Annotated[int, conint(ge=1, le=100)] + page: Annotated[int, conint(ge=1)] total: int total_pages: int diff --git a/app/db/seeders/__init__.py b/app/db/seeders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/seeders/db_seeder.py b/app/db/seeders/db_seeder.py new file mode 100644 index 0000000..b54349d --- /dev/null +++ b/app/db/seeders/db_seeder.py @@ -0,0 +1,11 @@ +from typing import Any + +from sqlalchemy.orm import Session + + +class DBSeeder: + def __init__(self, db: Session): + self.db = db + + def run_seeder(self, seeder: Any) -> None: + seeder.run(self.db) diff --git a/app/db/seeders/users_seeder.py b/app/db/seeders/users_seeder.py new file mode 100644 index 0000000..027b1e0 --- /dev/null +++ b/app/db/seeders/users_seeder.py @@ -0,0 +1,44 @@ +from sqlalchemy.orm import Session + + +from app.auth.utils import security +from app.users.enums.user_type_enum import UserTypeEnum +from app.users.repositories.patients_repository import patients_repository +from app.users.repositories.providers_repository import providers_repository +from app.users.schemas.patient_schema import PatientCreate +from app.users.schemas.provider_schema import ProviderCreate +from app.users.services.providers_service import ProvidersService +from app.users.services.patients_service import PatientsService + +from faker import Faker + +fake = Faker() + + +class UsersSeeder: + @staticmethod + def run(db: Session) -> None: + hashed_password = security.get_password_hash("password") + provider = ProvidersService(db, providers_repository).create( + ProviderCreate( + email="test0@provider.com", + hashed_password=hashed_password, + first_name="Provider", + last_name="Test", + type=UserTypeEnum.PROVIDER, + ) + ) + + patients_service = PatientsService(db, patients_repository) + for _ in range(1, 10): + patients_service.create( + PatientCreate( + email=fake.email(), + hashed_password=hashed_password, + first_name=fake.first_name(), + last_name=fake.last_name(), + type=UserTypeEnum.PATIENT, + provider_id=provider.id, + ) + ) + db.commit() diff --git a/app/setup.py b/app/setup.py new file mode 100644 index 0000000..c2b0e93 --- /dev/null +++ b/app/setup.py @@ -0,0 +1,9 @@ +import setuptools + +from app.commands.seed_users import SeedUsersCommand + +setuptools.setup( + name="API", + packages=["app"], + cmdclass={"seed_users": SeedUsersCommand}, +) diff --git a/app/users/api/providers.py b/app/users/api/providers.py index 2461cf7..1d3969a 100644 --- a/app/users/api/providers.py +++ b/app/users/api/providers.py @@ -1,10 +1,11 @@ from uuid import UUID -from fastapi import APIRouter, status +from fastapi import APIRouter, status, Depends from fastapi.exceptions import HTTPException from app.common.exceptions.model_not_found_exception import ( ModelNotFoundException, ) +from app.common.schemas.pagination_schema import ListFilter, ListResponse from app.users.schemas.patient_schema import ( CreatePatientRequest, PatientResponse, @@ -14,6 +15,7 @@ from app.users.schemas.provider_schema import ProviderResponse from app.users.use_cases.create_patient_use_case import CreatePatientUseCase from app.users.use_cases.get_patient_use_case import GetPatientUseCase +from app.users.use_cases.list_patient_use_case import ListPatientUseCase router = APIRouter() @@ -25,15 +27,15 @@ def get_current_provider( return ProviderResponse.model_validate(current_provider) -@router.post("", status_code=status.HTTP_201_CREATED) -def create_patient( +@router.get("/patients", status_code=status.HTTP_200_OK) +def list_patient( session: SessionDependency, - create_patient_request: CreatePatientRequest, -) -> PatientResponse: - return CreatePatientUseCase(session).execute(create_patient_request) + list_filter: ListFilter = Depends(), +) -> ListResponse: + return ListPatientUseCase(session).execute(list_filter) -@router.get("/{patient_id}", status_code=status.HTTP_200_OK) +@router.get("/patients/{patient_id}", status_code=status.HTTP_200_OK) def get_patient( session: SessionDependency, patient_id: UUID, @@ -44,3 +46,11 @@ def get_patient( raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=e.message ) + + +@router.post("/patients", status_code=status.HTTP_201_CREATED) +def create_patient( + session: SessionDependency, + create_patient_request: CreatePatientRequest, +) -> PatientResponse: + return CreatePatientUseCase(session).execute(create_patient_request) diff --git a/app/users/services/patients_service.py b/app/users/services/patients_service.py index 7aaf32c..9c9026b 100644 --- a/app/users/services/patients_service.py +++ b/app/users/services/patients_service.py @@ -1,4 +1,4 @@ -from typing import TypeVar +from typing import List, TypeVar from uuid import UUID from sqlalchemy.orm import Session @@ -6,6 +6,7 @@ from app.common.exceptions.model_not_found_exception import ( ModelNotFoundException, ) +from app.common.schemas.pagination_schema import ListFilter from app.users.repositories.patients_repository import PatientsRepository from app.users.schemas.patient_schema import ( PatientInDB, @@ -56,3 +57,7 @@ def get_by_id(self, user_id: UUID) -> PatientInDB | None: if not user: return None return PatientInDB.model_validate(user) + + def list(self, list_options: ListFilter) -> List[PatientInDB]: + patients = self.repository.list(self.session, list_options) + return [PatientInDB.model_validate(patient) for patient in patients] diff --git a/app/users/services/providers_service.py b/app/users/services/providers_service.py index d20c4bc..1de1383 100644 --- a/app/users/services/providers_service.py +++ b/app/users/services/providers_service.py @@ -1,4 +1,4 @@ -from typing import TypeVar +from typing import List, TypeVar from uuid import UUID from sqlalchemy.orm import Session @@ -6,6 +6,7 @@ from app.common.exceptions.model_not_found_exception import ( ModelNotFoundException, ) +from app.common.schemas.pagination_schema import ListFilter from app.users.repositories.providers_repository import ProvidersRepository from app.users.schemas.provider_schema import ( ProviderUpdate, @@ -58,3 +59,9 @@ def get_by_id(self, user_id: UUID) -> ProviderInDB | None: if not user: return None return ProviderInDB.model_validate(user) + + def list(self, list_options: ListFilter) -> List[ProviderInDB]: + providers = self.repository.list(self.session, list_options) + return [ + ProviderInDB.model_validate(provider) for provider in providers + ] diff --git a/app/users/services/users_service.py b/app/users/services/users_service.py index bffedbe..b1f5d08 100644 --- a/app/users/services/users_service.py +++ b/app/users/services/users_service.py @@ -1,10 +1,10 @@ from abc import ABC, abstractmethod -from typing import Generic, TypeVar +from typing import Generic, List, TypeVar from uuid import UUID from sqlalchemy.orm import Session -from app.common.schemas.pagination_schema import ListFilter, ListResponse +from app.common.schemas.pagination_schema import ListFilter from app.users.repositories.users_repository import UsersRepository TInDB = TypeVar("TInDB") @@ -34,5 +34,6 @@ def get_by_email(self, email: str) -> TInDB | None: def get_by_id(self, user_id: UUID) -> TInDB | None: pass - def list(self, list_options: ListFilter) -> ListResponse: - return self.repository.list(self.session, list_options) + @abstractmethod + def list(self, list_options: ListFilter) -> List[TInDB]: + pass diff --git a/app/users/use_cases/list_patient_use_case.py b/app/users/use_cases/list_patient_use_case.py new file mode 100644 index 0000000..5b5dbce --- /dev/null +++ b/app/users/use_cases/list_patient_use_case.py @@ -0,0 +1,24 @@ +from math import ceil +from sqlalchemy.orm import Session + +from app.common.schemas.pagination_schema import ListFilter, ListResponse +from app.users.repositories.patients_repository import patients_repository +from app.users.services.patients_service import PatientsService + + +class ListPatientUseCase: + def __init__(self, session: Session): + self.session = session + + def execute(self, list_filter: ListFilter) -> ListResponse: + patients = PatientsService(self.session, patients_repository).list( + list_options=list_filter + ) + + return ListResponse( + data=patients, + page=list_filter.page, + page_size=list_filter.page_size, + total=len(patients), + total_pages=ceil(len(patients) / list_filter.page_size), + ) diff --git a/poetry.lock b/poetry.lock index a8928fe..7c5d4f1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -469,6 +469,21 @@ files = [ dnspython = ">=2.0.0" idna = ">=2.0.0" +[[package]] +name = "faker" +version = "37.1.0" +description = "Faker is a Python package that generates fake data for you." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "faker-37.1.0-py3-none-any.whl", hash = "sha256:dc2f730be71cb770e9c715b13374d80dbcee879675121ab51f9683d262ae9a1c"}, + {file = "faker-37.1.0.tar.gz", hash = "sha256:ad9dc66a3b84888b837ca729e85299a96b58fdaef0323ed0baace93c9614af06"}, +] + +[package.dependencies] +tzdata = "*" + [[package]] name = "fastapi" version = "0.115.11" @@ -1444,6 +1459,27 @@ statsig = ["statsig (>=0.55.3)"] tornado = ["tornado (>=6)"] unleash = ["UnleashClient (>=6.0.1)"] +[[package]] +name = "setuptools" +version = "78.1.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "setuptools-78.1.0-py3-none-any.whl", hash = "sha256:3e386e96793c8702ae83d17b853fb93d3e09ef82ec62722e61da5cd22376dcd8"}, + {file = "setuptools-78.1.0.tar.gz", hash = "sha256:18fd474d4a82a5f83dac888df697af65afa82dec7323d09c3e37d1f14288da54"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] + [[package]] name = "six" version = "1.17.0" @@ -1668,7 +1704,7 @@ version = "2025.1" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "tzdata-2025.1-py2.py3-none-any.whl", hash = "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639"}, {file = "tzdata-2025.1.tar.gz", hash = "sha256:24894909e88cdb28bd1636c6887801df64cb485bd593f2fd83ef29075a81d694"}, @@ -1848,4 +1884,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "f62d866bad42f3f9d38fc46781034eb8f025eb7dbc486f3186a0c851edb7f56e" +content-hash = "beb8c1bc8e6575e34c2fbcf428571e09b4f59da40b65f8856e243edb22a72aaf" diff --git a/pyproject.toml b/pyproject.toml index 0d0a1d4..ed203de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,8 @@ mypy = "^1.15.0" pytest = "^8.3.3" pytest-cov = "^5.0.0" ruff = "^0.9.9" +faker = "^37.1.0" +setuptools = "^78.1.0" [build-system] requires = ["poetry-core"] diff --git a/scripts/seed-db.sh b/scripts/seed-db.sh new file mode 100755 index 0000000..e8f86f4 --- /dev/null +++ b/scripts/seed-db.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +# Seed providers and patients +docker compose exec api python3 -m app.setup seed_users diff --git a/tests/auth/api/endpoints/test_auth.py b/tests/auth/api/endpoints/test_auth.py index 7428560..7734fe1 100644 --- a/tests/auth/api/endpoints/test_auth.py +++ b/tests/auth/api/endpoints/test_auth.py @@ -2,14 +2,14 @@ from fastapi.testclient import TestClient -from tests.utils.create_user import create_user +from tests.utils.create_provider import create_provider -login_path = "api/v1/auth/login" +login_path = "api/v1/auth/providers/login" class TestLogin: - def test_login(self, client: TestClient, session: Session): - created_user = create_user(session) + def test_login(self, client: TestClient, session: Session) -> None: + created_user = create_provider(session) response = client.post( login_path, @@ -23,8 +23,8 @@ def test_login(self, client: TestClient, session: Session): def test_login_incorrect_password( self, client: TestClient, session: Session - ): - created_user = create_user(session) + ) -> None: + created_user = create_provider(session) response = client.post( login_path, @@ -38,8 +38,8 @@ def test_login_incorrect_password( def test_login_non_existent_email( self, client: TestClient, session: Session - ): - create_user(session) + ) -> None: + create_provider(session) response = client.post( login_path, diff --git a/tests/utils/create_provider.py b/tests/utils/create_provider.py new file mode 100644 index 0000000..25976b7 --- /dev/null +++ b/tests/utils/create_provider.py @@ -0,0 +1,21 @@ +from pydantic import EmailStr +from sqlalchemy.orm import Session + +from app.auth.utils import security +from app.users.enums.user_type_enum import UserTypeEnum +from app.users.repositories.providers_repository import providers_repository +from app.users.schemas.provider_schema import ProviderCreate +from app.users.schemas.user_schema import UserInDB +from app.users.services.providers_service import ProvidersService + + +def create_provider(session: Session, email: EmailStr) -> UserInDB: + hashed_password = security.get_password_hash("password") + new_user = ProviderCreate( + email=email, + hashed_password=hashed_password, + first_name="Provider", + last_name="Test", + type=UserTypeEnum.PROVIDER, + ) + return ProvidersService(session, providers_repository).create(new_user) diff --git a/tests/utils/create_user.py b/tests/utils/create_user.py deleted file mode 100644 index af4033f..0000000 --- a/tests/utils/create_user.py +++ /dev/null @@ -1,16 +0,0 @@ -from sqlalchemy.orm import Session - -from app.auth.utils import security -from app.users.repositories.users_repository import users_repository -from app.users.schemas.user_schema import UserCreate, UserInDB -from app.users.services.users_service import UsersService - - -def create_user( - session: Session, -) -> UserInDB: - hashed_password = security.get_password_hash("password") - new_user = UserCreate( - email="test@user.com", hashed_password=hashed_password - ) - return UsersService(session, users_repository).create_user(new_user) From 62f9106231b06c53ecf63f56daed8e9240f595c0 Mon Sep 17 00:00:00 2001 From: Joaquin Sarro Date: Mon, 31 Mar 2025 17:03:55 -0300 Subject: [PATCH 5/6] fix: tests --- tests/utils/create_provider.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/utils/create_provider.py b/tests/utils/create_provider.py index 25976b7..74032ed 100644 --- a/tests/utils/create_provider.py +++ b/tests/utils/create_provider.py @@ -1,4 +1,3 @@ -from pydantic import EmailStr from sqlalchemy.orm import Session from app.auth.utils import security @@ -9,10 +8,10 @@ from app.users.services.providers_service import ProvidersService -def create_provider(session: Session, email: EmailStr) -> UserInDB: +def create_provider(session: Session) -> UserInDB: hashed_password = security.get_password_hash("password") new_user = ProviderCreate( - email=email, + email="test0@provider.com", hashed_password=hashed_password, first_name="Provider", last_name="Test", From 7afd9b66dee2f35c878a7d41af301464b077023e Mon Sep 17 00:00:00 2001 From: Joaquin Sarro Date: Wed, 2 Apr 2025 09:27:36 -0300 Subject: [PATCH 6/6] feature: adjust readme --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 2a892cb..b1bddbd 100644 --- a/README.md +++ b/README.md @@ -28,3 +28,7 @@ We use ruff as linter, to run them on pre-commit please run `pre-commit install` - You can run tests locally by running `./tests-start.sh` (this will use your local database). - Tests can also run in docker container with the following command: `docker-compose exec api ./tests-start.sh`. + +### Seed db + +Run `./scripts/seed-db.sh`