Files
rentman-clone/backend/app/api/v1/contacts.py
T

221 lines
7.0 KiB
Python

"""Contacts CRUD endpoints."""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from app.api.deps import require_permission
from app.db.session import get_async_session
from app.models import User, Contact, Tag
from app.schemas.contact import (
ContactCreateRequest,
ContactUpdateRequest,
ContactResponse,
ContactListResponse,
)
router = APIRouter(prefix="/contacts", tags=["contacts"])
@router.get("", response_model=ContactListResponse)
async def list_contacts(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
search: str | None = Query(None, description="Search in name, email, phone"),
type: str | None = Query(
None, pattern="^(company|person)$", description="Filter by contact type"
),
current_user: User = Depends(require_permission("contacts:read")),
session: AsyncSession = Depends(get_async_session),
):
"""List contacts with search, filter by type, pagination."""
account_id = current_user.account_id
base_q = select(Contact).where(Contact.account_id == account_id)
# Search across name fields, email, phone
if search:
search_pattern = f"%{search}%"
base_q = base_q.where(
or_(
Contact.company_name.ilike(search_pattern),
Contact.first_name.ilike(search_pattern),
Contact.last_name.ilike(search_pattern),
Contact.email.ilike(search_pattern),
Contact.phone.ilike(search_pattern),
)
)
# Filter by type
if type:
base_q = base_q.where(Contact.type == type)
# Count total
count_q = select(func.count()).select_from(base_q.subquery())
total = (await session.execute(count_q)).scalar() or 0
# Fetch page with tags eager-loaded
q = (
base_q.options(joinedload(Contact.tags))
.order_by(Contact.updated_at.desc())
.offset((page - 1) * size)
.limit(size)
)
result = await session.execute(q)
contacts = result.unique().scalars().all()
return ContactListResponse(
items=[ContactResponse.model_validate(c) for c in contacts],
total=total,
page=page,
size=size,
)
@router.post("", response_model=ContactResponse, status_code=status.HTTP_201_CREATED)
async def create_contact(
body: ContactCreateRequest,
current_user: User = Depends(require_permission("contacts:write")),
session: AsyncSession = Depends(get_async_session),
):
"""Create a new contact."""
account_id = current_user.account_id
contact = Contact(
account_id=account_id,
type=body.type,
company_name=body.company_name,
first_name=body.first_name,
last_name=body.last_name,
email=body.email,
phone=body.phone,
mobile=body.mobile,
website=body.website,
billing_street=body.billing_street,
billing_number=body.billing_number,
billing_postalcode=body.billing_postalcode,
billing_city=body.billing_city,
billing_country=body.billing_country,
shipping_street=body.shipping_street,
shipping_number=body.shipping_number,
shipping_postalcode=body.shipping_postalcode,
shipping_city=body.shipping_city,
shipping_country=body.shipping_country,
tax_number=body.tax_number,
note=body.note,
)
if body.tag_ids:
# Fetch tags that belong to this tenant (or could be global? We'll enforce tenant scope)
tag_result = await session.execute(
select(Tag).where(
Tag.id.in_(body.tag_ids),
Tag.account_id == account_id,
)
)
contact.tags = tag_result.scalars().all()
session.add(contact)
await session.commit()
await session.refresh(contact)
# Reload with relationships
await session.execute(
select(Contact)
.options(joinedload(Contact.tags))
.where(Contact.id == contact.id)
)
await session.refresh(contact)
return ContactResponse.model_validate(contact)
@router.get("/{contact_id}", response_model=ContactResponse)
async def get_contact(
contact_id: str,
current_user: User = Depends(require_permission("contacts:read")),
session: AsyncSession = Depends(get_async_session),
):
"""Get a specific contact with tags."""
account_id = current_user.account_id
result = await session.execute(
select(Contact)
.options(joinedload(Contact.tags))
.where(Contact.id == contact_id, Contact.account_id == account_id)
)
contact = result.unique().scalars().first()
if not contact:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Contact not found",
)
return ContactResponse.model_validate(contact)
@router.put("/{contact_id}", response_model=ContactResponse)
async def update_contact(
contact_id: str,
body: ContactUpdateRequest,
current_user: User = Depends(require_permission("contacts:write")),
session: AsyncSession = Depends(get_async_session),
):
"""Update an existing contact."""
account_id = current_user.account_id
result = await session.execute(
select(Contact)
.options(joinedload(Contact.tags))
.where(Contact.id == contact_id, Contact.account_id == account_id)
)
contact = result.unique().scalars().first()
if not contact:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Contact not found",
)
# Update fields if provided
update_data = body.model_dump(exclude_unset=True, exclude={"tag_ids"})
for field, value in update_data.items():
setattr(contact, field, value)
# Handle tag_ids separately: if provided, replace the tag associations
if body.tag_ids is not None:
tag_result = await session.execute(
select(Tag).where(
Tag.id.in_(body.tag_ids),
Tag.account_id == account_id,
)
)
contact.tags = tag_result.scalars().all()
await session.commit()
await session.refresh(contact)
return ContactResponse.model_validate(contact)
@router.delete("/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_contact(
contact_id: str,
current_user: User = Depends(require_permission("contacts:delete")),
session: AsyncSession = Depends(get_async_session),
):
"""Hard-delete a contact."""
account_id = current_user.account_id
result = await session.execute(
select(Contact).where(
Contact.id == contact_id, Contact.account_id == account_id
)
)
contact = result.scalars().first()
if not contact:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Contact not found",
)
await session.delete(contact)
await session.commit()
return None