feat: implement contact management features including repository, service, and API endpoints; add unit and integration tests
Test / test (push) Successful in 13s
Test / test (pull_request) Successful in 14s

This commit is contained in:
k1nq
2025-11-28 13:23:33 +05:00
parent 193fa73c78
commit ed2cbd5061
6 changed files with 804 additions and 19 deletions
+102 -19
View File
@@ -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