Initial commit: Rentman Clone - Phase 0-6 (T001-T023)
Completed: - Phase 0: Project Setup (T001-T003) - Docker Compose, FastAPI skeleton, React SPA - Phase 1: Auth System (T004-T008) - DB models, JWT auth, RBAC middleware, user management - Phase 2: Contacts & Tags (T009-T011) - CRUD API + UI - Phase 3: Equipment Catalog (T012-T014) - Models, API, UI with barcode/QR - Phase 4: Crew Management (T015-T017) - Models, availability, UI - Phase 5: Vehicle Fleet (T018-T020) - Models, assignments, UI - Phase 6: Projects (T021-T023) - Project hierarchy models, CRUD API, list/detail UI
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user