221 lines
7.0 KiB
Python
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, selectinload
|
||
|
|
|
||
|
|
from app.api.deps import get_current_user, require_permission
|
||
|
|
from app.db.session import get_async_session
|
||
|
|
from app.models import User, Contact, Tag
|
||
|
|
from app.models.contact import contact_tags
|
||
|
|
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
|