211 lines
6.9 KiB
Python
211 lines
6.9 KiB
Python
|
|
"""Company routes — minimal for cross-tenant and RBAC tests."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import uuid
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||
|
|
from sqlalchemy import select
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
|
|
||
|
|
from app.core.audit import log_audit
|
||
|
|
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
|
||
|
|
|
||
|
|
router = APIRouter(prefix="/api/v1/companies", tags=["companies"])
|
||
|
|
|
||
|
|
|
||
|
|
@router.get("")
|
||
|
|
async def list_companies(
|
||
|
|
db: AsyncSession = Depends(get_db),
|
||
|
|
current_user: dict = Depends(get_current_user),
|
||
|
|
):
|
||
|
|
"""List companies in current tenant."""
|
||
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||
|
|
q = select(Company).where(
|
||
|
|
Company.tenant_id == tenant_id,
|
||
|
|
Company.deleted_at.is_(None),
|
||
|
|
)
|
||
|
|
result = await db.execute(q)
|
||
|
|
companies = result.scalars().all()
|
||
|
|
return {"items": [_company_to_dict(c) for c in companies]}
|
||
|
|
|
||
|
|
|
||
|
|
@router.post("")
|
||
|
|
async def create_company(
|
||
|
|
body: CompanyCreate,
|
||
|
|
db: AsyncSession = Depends(get_db),
|
||
|
|
current_user: dict = Depends(get_current_user),
|
||
|
|
):
|
||
|
|
"""Create a company. Requires write permission."""
|
||
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||
|
|
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()
|
||
|
|
|
||
|
|
# 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("/{company_id}")
|
||
|
|
async def get_company(
|
||
|
|
company_id: str,
|
||
|
|
db: AsyncSession = Depends(get_db),
|
||
|
|
current_user: dict = Depends(get_current_user),
|
||
|
|
):
|
||
|
|
"""Get a single company. Cross-tenant access 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:
|
||
|
|
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}")
|
||
|
|
async def update_company(
|
||
|
|
company_id: str,
|
||
|
|
body: CompanyCreate,
|
||
|
|
db: AsyncSession = Depends(get_db),
|
||
|
|
current_user: dict = Depends(get_current_user),
|
||
|
|
):
|
||
|
|
"""Update a company."""
|
||
|
|
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:
|
||
|
|
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, Company.deleted_at.is_(None))
|
||
|
|
result = await db.execute(q)
|
||
|
|
company = result.scalar_one_or_none()
|
||
|
|
if company 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)
|
||
|
|
|
||
|
|
|
||
|
|
@router.delete("/{company_id}")
|
||
|
|
async def delete_company(
|
||
|
|
company_id: str,
|
||
|
|
db: AsyncSession = Depends(get_db),
|
||
|
|
current_user: dict = Depends(get_current_user),
|
||
|
|
):
|
||
|
|
"""Soft-delete a company."""
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
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", "delete"):
|
||
|
|
raise HTTPException(403, detail={"detail": "Insufficient permissions", "code": "forbidden"})
|
||
|
|
|
||
|
|
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, Company.deleted_at.is_(None))
|
||
|
|
result = await db.execute(q)
|
||
|
|
company = result.scalar_one_or_none()
|
||
|
|
if company is None:
|
||
|
|
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
|
||
|
|
}
|