feat: implement contact management features including repository, service, and API endpoints; add unit and integration tests
This commit is contained in:
@@ -11,12 +11,14 @@ from app.core.database import get_session
|
||||
from app.core.security import jwt_service, password_hasher
|
||||
from app.models.user import User
|
||||
from app.repositories.activity_repo import ActivityRepository
|
||||
from app.repositories.contact_repo import ContactRepository
|
||||
from app.repositories.deal_repo import DealRepository
|
||||
from app.repositories.org_repo import OrganizationRepository
|
||||
from app.repositories.task_repo import TaskRepository
|
||||
from app.repositories.user_repo import UserRepository
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.activity_service import ActivityService
|
||||
from app.services.contact_service import ContactService
|
||||
from app.services.deal_service import DealService
|
||||
from app.services.organization_service import (
|
||||
OrganizationAccessDeniedError,
|
||||
@@ -48,6 +50,10 @@ def get_deal_repository(session: AsyncSession = Depends(get_db_session)) -> Deal
|
||||
return DealRepository(session=session)
|
||||
|
||||
|
||||
def get_contact_repository(session: AsyncSession = Depends(get_db_session)) -> ContactRepository:
|
||||
return ContactRepository(session=session)
|
||||
|
||||
|
||||
def get_task_repository(session: AsyncSession = Depends(get_db_session)) -> TaskRepository:
|
||||
return TaskRepository(session=session)
|
||||
|
||||
@@ -86,6 +92,12 @@ def get_activity_service(
|
||||
return ActivityService(repository=repo)
|
||||
|
||||
|
||||
def get_contact_service(
|
||||
repo: ContactRepository = Depends(get_contact_repository),
|
||||
) -> ContactService:
|
||||
return ContactService(repository=repo)
|
||||
|
||||
|
||||
def get_task_service(
|
||||
task_repo: TaskRepository = Depends(get_task_repository),
|
||||
activity_repo: ActivityRepository = Depends(get_activity_repository),
|
||||
|
||||
+102
-19
@@ -1,10 +1,20 @@
|
||||
"""Contact API stubs and schemas."""
|
||||
"""Contact API endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
||||
|
||||
from app.api.deps import get_organization_context
|
||||
from app.api.deps import get_contact_service, get_organization_context
|
||||
from app.models.contact import ContactCreate, ContactRead
|
||||
from app.services.contact_service import (
|
||||
ContactDeletionError,
|
||||
ContactForbiddenError,
|
||||
ContactListFilters,
|
||||
ContactNotFoundError,
|
||||
ContactOrganizationError,
|
||||
ContactService,
|
||||
ContactUpdateData,
|
||||
)
|
||||
from app.services.organization_service import OrganizationContext
|
||||
|
||||
|
||||
@@ -12,33 +22,106 @@ class ContactCreatePayload(BaseModel):
|
||||
name: str
|
||||
email: EmailStr | None = None
|
||||
phone: str | None = None
|
||||
owner_id: int | None = None
|
||||
|
||||
def to_domain(self, *, organization_id: int, fallback_owner: int) -> ContactCreate:
|
||||
return ContactCreate(
|
||||
organization_id=organization_id,
|
||||
owner_id=self.owner_id or fallback_owner,
|
||||
name=self.name,
|
||||
email=self.email,
|
||||
phone=self.phone,
|
||||
)
|
||||
|
||||
|
||||
class ContactUpdatePayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = None
|
||||
email: EmailStr | None = None
|
||||
phone: str | None = None
|
||||
|
||||
def to_update_data(self) -> ContactUpdateData:
|
||||
dump = self.model_dump(exclude_unset=True)
|
||||
return ContactUpdateData(
|
||||
name=dump.get("name"),
|
||||
email=dump.get("email"),
|
||||
phone=dump.get("phone"),
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
||||
|
||||
|
||||
def _stub(endpoint: str) -> dict[str, str]:
|
||||
return {"detail": f"{endpoint} is not implemented yet"}
|
||||
|
||||
|
||||
@router.get("/", status_code=status.HTTP_501_NOT_IMPLEMENTED)
|
||||
@router.get("/", response_model=list[ContactRead])
|
||||
async def list_contacts(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
search: str | None = None,
|
||||
search: str | None = Query(default=None, min_length=1),
|
||||
owner_id: int | None = None,
|
||||
context: OrganizationContext = Depends(get_organization_context),
|
||||
) -> dict[str, str]:
|
||||
"""Placeholder list endpoint supporting the required filters."""
|
||||
_ = context
|
||||
return _stub("GET /contacts")
|
||||
service: ContactService = Depends(get_contact_service),
|
||||
) -> list[ContactRead]:
|
||||
filters = ContactListFilters(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search=search,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
try:
|
||||
contacts = await service.list_contacts(filters=filters, context=context)
|
||||
except ContactForbiddenError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
|
||||
return [ContactRead.model_validate(contact) for contact in contacts]
|
||||
|
||||
|
||||
@router.post("/", status_code=status.HTTP_501_NOT_IMPLEMENTED)
|
||||
@router.post("/", response_model=ContactRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_contact(
|
||||
payload: ContactCreatePayload,
|
||||
context: OrganizationContext = Depends(get_organization_context),
|
||||
) -> dict[str, str]:
|
||||
"""Placeholder for creating a contact within the current organization."""
|
||||
_ = (payload, context)
|
||||
return _stub("POST /contacts")
|
||||
service: ContactService = Depends(get_contact_service),
|
||||
) -> ContactRead:
|
||||
data = payload.to_domain(organization_id=context.organization_id, fallback_owner=context.user_id)
|
||||
try:
|
||||
contact = await service.create_contact(data, context=context)
|
||||
except ContactForbiddenError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
|
||||
except ContactOrganizationError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
return ContactRead.model_validate(contact)
|
||||
|
||||
|
||||
@router.patch("/{contact_id}", response_model=ContactRead)
|
||||
async def update_contact(
|
||||
contact_id: int,
|
||||
payload: ContactUpdatePayload,
|
||||
context: OrganizationContext = Depends(get_organization_context),
|
||||
service: ContactService = Depends(get_contact_service),
|
||||
) -> ContactRead:
|
||||
try:
|
||||
contact = await service.get_contact(contact_id, context=context)
|
||||
updated = await service.update_contact(contact, payload.to_update_data(), context=context)
|
||||
except ContactNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
except ContactForbiddenError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
|
||||
except ContactOrganizationError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
return ContactRead.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete("/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_contact(
|
||||
contact_id: int,
|
||||
context: OrganizationContext = Depends(get_organization_context),
|
||||
service: ContactService = Depends(get_contact_service),
|
||||
) -> None:
|
||||
try:
|
||||
contact = await service.get_contact(contact_id, context=context)
|
||||
await service.delete_contact(contact, context=context)
|
||||
except ContactNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
except ContactForbiddenError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
|
||||
except ContactDeletionError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||
|
||||
Reference in New Issue
Block a user