T02: companies + contacts + import/export + N:M + soft-delete + GDPR + FTS

- Company CRUD with soft-delete, FTS search (tsvector + GIN), filter, pagination
- Contact CRUD with N:M company linking via company_contacts
- CSV/XLSX export, CSV import with dry-run preview
- GDPR hard-delete with deletion_log
- Audit log on all mutations
- 27 new tests (24 ACs), 56 total tests pass
- Migration 0002: contacts, company_contacts, FTS search_tsv
- Fixed T01 tests: POST→201, PATCH→PUT compatibility
This commit is contained in:
leocrm-bot
2026-06-29 00:44:34 +02:00
parent 3ab4925783
commit dd16940bb2
21 changed files with 2518 additions and 125 deletions
+147 -110
View File
@@ -1,42 +1,44 @@
"""Company routes — minimal for cross-tenant and RBAC tests."""
"""Company routes — full CRUD, FTS search, filter, pagination, export, N:M, emails."""
from __future__ import annotations
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.core.auth import check_permission
from app.core.db import get_db
from app.core.auth import check_permission, filter_fields_by_permission
from app.deps import get_current_user, require_admin
from app.models.company import Company
from app.models.role import Role
from app.schemas.company import CompanyCreate
from app.deps import get_current_user
from app.schemas.company import CompanyCreate, CompanyUpdate
from app.services import company_service
router = APIRouter(prefix="/api/v1/companies", tags=["companies"])
@router.get("")
async def list_companies(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
search: str | None = Query(None),
industry: str | None = Query(None),
sort_by: str = Query("name"),
sort_order: str = Query("asc", pattern="^(asc|desc)$"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List companies in current tenant."""
"""List companies with pagination, FTS search, industry filter, and sorting."""
tenant_id = uuid.UUID(current_user["tenant_id"])
q = select(Company).where(
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None),
result = await company_service.list_companies(
db, tenant_id,
page=page, page_size=page_size,
search=search, industry=industry,
sort_by=sort_by, sort_order=sort_order,
)
result = await db.execute(q)
companies = result.scalars().all()
return {"items": [_company_to_dict(c) for c in companies]}
return result
@router.post("")
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_company(
body: CompanyCreate,
db: AsyncSession = Depends(get_db),
@@ -47,34 +49,46 @@ async def create_company(
user_id = uuid.UUID(current_user["user_id"])
role = current_user.get("role", "viewer")
# RBAC check
if not check_permission(role, "companies", "create"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"detail": "Insufficient permissions", "code": "forbidden"},
)
company = Company(
tenant_id=tenant_id,
name=body.name,
industry=body.industry,
phone=body.phone,
email=body.email,
website=body.website,
description=body.description,
created_by=user_id,
updated_by=user_id,
)
db.add(company)
await db.flush()
data = body.model_dump()
return await company_service.create_company(db, tenant_id, user_id, data)
# Audit log
await log_audit(
db, tenant_id, user_id, "create", "company", company.id,
changes={"name": body.name},
)
return _company_to_dict(company)
@router.get("/export")
async def export_companies(
format: str = Query("csv", pattern="^(csv|xlsx)$"),
industry: str | None = Query(None),
search: str | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Export companies as CSV or XLSX."""
tenant_id = uuid.UUID(current_user["tenant_id"])
if format == "csv":
csv_data = await company_service.export_companies_csv(
db, tenant_id, industry=industry, search=search,
)
return Response(
content=csv_data,
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=companies.csv"},
)
elif format == "xlsx":
xlsx_data = await company_service.export_companies_xlsx(
db, tenant_id, industry=industry, search=search,
)
return Response(
content=xlsx_data,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=companies.xlsx"},
)
raise HTTPException(400, detail={"detail": "Invalid format", "code": "invalid_format"})
@router.get("/{company_id}")
@@ -83,46 +97,27 @@ async def get_company(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get a single company. Cross-tenant access returns 404."""
"""Get a single company with contacts array. Cross-tenant returns 404."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
cid = uuid.UUID(company_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid company_id", "code": "invalid_id"})
q = select(Company).where(
Company.id == cid,
Company.tenant_id == tenant_id, # Tenant filter = cross-tenant returns 404
Company.deleted_at.is_(None),
)
result = await db.execute(q)
company = result.scalar_one_or_none()
if company is None:
data = await company_service.get_company_detail(db, tenant_id, cid)
if data is None:
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
# Field-level permissions
data = _company_to_dict(company)
# Check if user's role has field permissions defined
role = current_user.get("role", "viewer")
if role != "admin":
# Check for custom role field permissions
role_q = select(Role).where(Role.tenant_id == tenant_id, Role.name == role)
role_result = await db.execute(role_q)
custom_role = role_result.scalar_one_or_none()
if custom_role and custom_role.field_permissions:
data = filter_fields_by_permission(data, custom_role.field_permissions, role)
return data
@router.patch("/{company_id}")
@router.put("/{company_id}")
async def update_company(
company_id: str,
body: CompanyCreate,
body: CompanyUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update a company."""
"""Update a company (full PUT)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
role = current_user.get("role", "viewer")
@@ -135,42 +130,21 @@ async def update_company(
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid company_id", "code": "invalid_id"})
q = select(Company).where(Company.id == cid, Company.tenant_id == tenant_id, Company.deleted_at.is_(None))
result = await db.execute(q)
company = result.scalar_one_or_none()
if company is None:
data = body.model_dump(exclude_unset=True)
result = await company_service.update_company(db, tenant_id, user_id, cid, data)
if result is None:
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
changes: dict[str, Any] = {}
if body.name is not None:
changes["name"] = {"old": company.name, "new": body.name}
company.name = body.name
if body.industry is not None:
company.industry = body.industry
if body.phone is not None:
company.phone = body.phone
if body.email is not None:
company.email = body.email
if body.website is not None:
company.website = body.website
if body.description is not None:
company.description = body.description
company.updated_by = user_id
await db.flush()
await log_audit(db, tenant_id, user_id, "update", "company", cid, changes=changes)
return _company_to_dict(company)
return result
@router.delete("/{company_id}")
async def delete_company(
company_id: str,
cascade: bool = Query(False),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Soft-delete a company."""
from datetime import datetime, timezone
"""Soft-delete a company. Use cascade=true to also delete N:M links."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
role = current_user.get("role", "viewer")
@@ -183,28 +157,91 @@ async def delete_company(
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid company_id", "code": "invalid_id"})
q = select(Company).where(Company.id == cid, Company.tenant_id == tenant_id, Company.deleted_at.is_(None))
result = await db.execute(q)
company = result.scalar_one_or_none()
if company is None:
deleted = await company_service.soft_delete_company(db, tenant_id, user_id, cid, cascade=cascade)
if not deleted:
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
company.deleted_at = datetime.now(timezone.utc)
company.updated_by = user_id
await log_audit(db, tenant_id, user_id, "delete", "company", cid, changes={"name": company.name})
from fastapi import Response
return Response(status_code=status.HTTP_204_NO_CONTENT)
def _company_to_dict(c: Company) -> dict[str, Any]:
"""Convert company to response dict."""
return {
"id": str(c.id),
"name": c.name,
"industry": c.industry,
"phone": c.phone,
"email": c.email,
"annual_revenue": None, # Minimal model — would be from DB column
}
@router.post("/{company_id}/contacts/{contact_id}")
async def link_company_contact(
company_id: str,
contact_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Link a contact to a company (N:M)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
role = current_user.get("role", "viewer")
if not check_permission(role, "companies", "update"):
raise HTTPException(403, detail={"detail": "Insufficient permissions", "code": "forbidden"})
try:
comp_id = uuid.UUID(company_id)
cont_id = uuid.UUID(contact_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"})
result = await company_service.link_contact(db, tenant_id, user_id, comp_id, cont_id)
if result is None:
raise HTTPException(404, detail={"detail": "Company or contact not found", "code": "not_found"})
return result
@router.delete("/{company_id}/contacts/{contact_id}")
async def unlink_company_contact(
company_id: str,
contact_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Unlink a contact from a company (N:M)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
role = current_user.get("role", "viewer")
if not check_permission(role, "companies", "update"):
raise HTTPException(403, detail={"detail": "Insufficient permissions", "code": "forbidden"})
try:
comp_id = uuid.UUID(company_id)
cont_id = uuid.UUID(contact_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"})
unlinked = await company_service.unlink_contact(db, tenant_id, user_id, comp_id, cont_id)
if not unlinked:
raise HTTPException(404, detail={"detail": "Link not found", "code": "not_found"})
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/{company_id}/emails")
async def get_company_emails(
company_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get emails for a company (mail plugin inactive — returns empty array)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
cid = uuid.UUID(company_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid company_id", "code": "invalid_id"})
# Verify company exists
from sqlalchemy import select
from app.models.company import Company
q = select(Company).where(
Company.id == cid,
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None),
)
result = await db.execute(q)
if result.scalar_one_or_none() is None:
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
return []