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:
+147
-110
@@ -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 []
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Contact routes — CRUD, N:M, soft-delete, GDPR hard-delete."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.auth import check_permission
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.schemas.contact import ContactCreate, ContactUpdate
|
||||
from app.services import contact_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/contacts", tags=["contacts"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_contacts(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
search: str | None = Query(None),
|
||||
sort_by: str = Query("last_name"),
|
||||
sort_order: str = Query("asc", pattern="^(asc|desc)$"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List contacts with pagination and optional search."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
result = await contact_service.list_contacts(
|
||||
db, tenant_id,
|
||||
page=page, page_size=page_size,
|
||||
search=search, sort_by=sort_by, sort_order=sort_order,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_contact(
|
||||
body: ContactCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Create a contact. Optionally link to companies via company_ids array."""
|
||||
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, "contacts", "create"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"detail": "Insufficient permissions", "code": "forbidden"},
|
||||
)
|
||||
|
||||
data = body.model_dump()
|
||||
return await contact_service.create_contact(db, tenant_id, user_id, data)
|
||||
|
||||
|
||||
@router.get("/{contact_id}")
|
||||
async def get_contact(
|
||||
contact_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get a single contact with companies array. Cross-tenant returns 404."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
cid = uuid.UUID(contact_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"})
|
||||
|
||||
data = await contact_service.get_contact_detail(db, tenant_id, cid)
|
||||
if data is None:
|
||||
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
|
||||
return data
|
||||
|
||||
|
||||
@router.put("/{contact_id}")
|
||||
async def update_contact(
|
||||
contact_id: str,
|
||||
body: ContactUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Update a contact."""
|
||||
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, "contacts", "update"):
|
||||
raise HTTPException(403, detail={"detail": "Insufficient permissions", "code": "forbidden"})
|
||||
|
||||
try:
|
||||
cid = uuid.UUID(contact_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"})
|
||||
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
result = await contact_service.update_contact(db, tenant_id, user_id, cid, data)
|
||||
if result is None:
|
||||
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{contact_id}")
|
||||
async def delete_contact(
|
||||
contact_id: str,
|
||||
gdpr: bool = Query(False),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a contact. Default: soft-delete. Use gdpr=true for hard-delete with deletion_log."""
|
||||
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, "contacts", "delete"):
|
||||
raise HTTPException(403, detail={"detail": "Insufficient permissions", "code": "forbidden"})
|
||||
|
||||
try:
|
||||
cid = uuid.UUID(contact_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"})
|
||||
|
||||
if gdpr:
|
||||
deleted = await contact_service.gdpr_hard_delete_contact(db, tenant_id, user_id, cid)
|
||||
else:
|
||||
deleted = await contact_service.soft_delete_contact(db, tenant_id, user_id, cid)
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Import/export routes — CSV import, dry-run preview, CSV/XLSX export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.auth import check_permission
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.services import import_export_service
|
||||
from app.services import company_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["import_export"])
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
async def import_csv(
|
||||
file: UploadFile = File(...),
|
||||
entity_type: str = Form("companies"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Import companies or contacts from CSV file.
|
||||
entity_type: 'companies' or 'contacts'.
|
||||
"""
|
||||
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", "create"):
|
||||
raise HTTPException(403, detail={"detail": "Insufficient permissions", "code": "forbidden"})
|
||||
|
||||
content = await file.read()
|
||||
csv_content = content.decode("utf-8")
|
||||
|
||||
result = await import_export_service.import_csv(
|
||||
db, tenant_id, user_id, csv_content, entity_type=entity_type, dry_run=False,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/import/preview")
|
||||
async def import_csv_preview(
|
||||
file: UploadFile = File(...),
|
||||
entity_type: str = Form("companies"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Preview CSV import (dry-run — no DB changes)."""
|
||||
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", "read"):
|
||||
raise HTTPException(403, detail={"detail": "Insufficient permissions", "code": "forbidden"})
|
||||
|
||||
content = await file.read()
|
||||
csv_content = content.decode("utf-8")
|
||||
|
||||
result = await import_export_service.import_csv(
|
||||
db, tenant_id, user_id, csv_content, entity_type=entity_type, dry_run=True,
|
||||
)
|
||||
return result
|
||||
Reference in New Issue
Block a user