feat(core): add address fields to company service serialization and CRUD
This commit is contained in:
@@ -16,6 +16,9 @@ from app.models.company import Company
|
|||||||
from app.models.contact import CompanyContact, Contact
|
from app.models.contact import CompanyContact, Contact
|
||||||
|
|
||||||
|
|
||||||
|
ADDRESS_FIELDS = ("address_street", "address_city", "address_zip", "address_country", "address_state")
|
||||||
|
|
||||||
|
|
||||||
def _company_to_dict(c: Company, include_contacts: bool = False) -> dict[str, Any]:
|
def _company_to_dict(c: Company, include_contacts: bool = False) -> dict[str, Any]:
|
||||||
"""Serialize a Company ORM object to dict."""
|
"""Serialize a Company ORM object to dict."""
|
||||||
data: dict[str, Any] = {
|
data: dict[str, Any] = {
|
||||||
@@ -27,11 +30,16 @@ def _company_to_dict(c: Company, include_contacts: bool = False) -> dict[str, An
|
|||||||
"email": c.email,
|
"email": c.email,
|
||||||
"website": c.website,
|
"website": c.website,
|
||||||
"description": c.description,
|
"description": c.description,
|
||||||
|
"address_street": c.address_street,
|
||||||
|
"address_city": c.address_city,
|
||||||
|
"address_zip": c.address_zip,
|
||||||
|
"address_country": c.address_country,
|
||||||
|
"address_state": c.address_state,
|
||||||
"created_at": c.created_at.isoformat() if c.created_at else None,
|
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||||
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
|
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
|
||||||
}
|
}
|
||||||
if include_contacts:
|
if include_contacts:
|
||||||
data["contacts"] = [] # populated by caller if needed
|
data["contacts"] = []
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -45,10 +53,7 @@ async def list_companies(
|
|||||||
sort_by: str = "name",
|
sort_by: str = "name",
|
||||||
sort_order: str = "asc",
|
sort_order: str = "asc",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""List companies with pagination, FTS search, industry filter, and sorting.
|
"""List companies with pagination, FTS search, industry filter, and sorting."""
|
||||||
|
|
||||||
Returns {items, total, page, page_size}.
|
|
||||||
"""
|
|
||||||
page = max(1, page)
|
page = max(1, page)
|
||||||
page_size = max(1, min(100, page_size))
|
page_size = max(1, min(100, page_size))
|
||||||
|
|
||||||
@@ -57,11 +62,9 @@ async def list_companies(
|
|||||||
Company.deleted_at.is_(None),
|
Company.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Industry filter
|
|
||||||
if industry:
|
if industry:
|
||||||
base = base.where(Company.industry == industry)
|
base = base.where(Company.industry == industry)
|
||||||
|
|
||||||
# FTS search using PostgreSQL tsvector @@ plainto_tsquery, with ILIKE fallback
|
|
||||||
if search:
|
if search:
|
||||||
pattern = f"%{search}%"
|
pattern = f"%{search}%"
|
||||||
base = base.where(
|
base = base.where(
|
||||||
@@ -73,19 +76,16 @@ async def list_companies(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sorting
|
|
||||||
sort_col = getattr(Company, sort_by, Company.name)
|
sort_col = getattr(Company, sort_by, Company.name)
|
||||||
if sort_order == "desc":
|
if sort_order == "desc":
|
||||||
base = base.order_by(desc(sort_col))
|
base = base.order_by(desc(sort_col))
|
||||||
else:
|
else:
|
||||||
base = base.order_by(asc(sort_col))
|
base = base.order_by(asc(sort_col))
|
||||||
|
|
||||||
# Count total (without pagination)
|
|
||||||
count_q = select(func.count()).select_from(base.subquery())
|
count_q = select(func.count()).select_from(base.subquery())
|
||||||
total_result = await db.execute(count_q)
|
total_result = await db.execute(count_q)
|
||||||
total = total_result.scalar_one()
|
total = total_result.scalar_one()
|
||||||
|
|
||||||
# Paginate
|
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
paginated = base.offset(offset).limit(page_size)
|
paginated = base.offset(offset).limit(page_size)
|
||||||
result = await db.execute(paginated)
|
result = await db.execute(paginated)
|
||||||
@@ -116,7 +116,6 @@ async def get_company_detail(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
data = _company_to_dict(company, include_contacts=True)
|
data = _company_to_dict(company, include_contacts=True)
|
||||||
# Fetch linked contacts
|
|
||||||
contacts_q = (
|
contacts_q = (
|
||||||
select(Contact, CompanyContact)
|
select(Contact, CompanyContact)
|
||||||
.join(CompanyContact, CompanyContact.contact_id == Contact.id)
|
.join(CompanyContact, CompanyContact.contact_id == Contact.id)
|
||||||
@@ -161,6 +160,11 @@ async def create_company(
|
|||||||
email=data.get("email"),
|
email=data.get("email"),
|
||||||
website=data.get("website"),
|
website=data.get("website"),
|
||||||
description=data.get("description"),
|
description=data.get("description"),
|
||||||
|
address_street=data.get("address_street"),
|
||||||
|
address_city=data.get("address_city"),
|
||||||
|
address_zip=data.get("address_zip"),
|
||||||
|
address_country=data.get("address_country"),
|
||||||
|
address_state=data.get("address_state"),
|
||||||
created_by=user_id,
|
created_by=user_id,
|
||||||
updated_by=user_id,
|
updated_by=user_id,
|
||||||
)
|
)
|
||||||
@@ -198,7 +202,11 @@ async def update_company(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
changes: dict[str, Any] = {}
|
changes: dict[str, Any] = {}
|
||||||
for field in ("name", "account_number", "industry", "phone", "email", "website", "description"):
|
all_fields = (
|
||||||
|
"name", "account_number", "industry", "phone", "email",
|
||||||
|
"website", "description",
|
||||||
|
) + ADDRESS_FIELDS
|
||||||
|
for field in all_fields:
|
||||||
if field in data and data[field] is not None:
|
if field in data and data[field] is not None:
|
||||||
old_val = getattr(company, field)
|
old_val = getattr(company, field)
|
||||||
changes[field] = {"old": old_val, "new": data[field]}
|
changes[field] = {"old": old_val, "new": data[field]}
|
||||||
@@ -233,7 +241,6 @@ async def soft_delete_company(
|
|||||||
company.updated_by = user_id
|
company.updated_by = user_id
|
||||||
|
|
||||||
if cascade:
|
if cascade:
|
||||||
# Delete N:M links for this company
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
delete(CompanyContact).where(
|
delete(CompanyContact).where(
|
||||||
CompanyContact.company_id == company_id,
|
CompanyContact.company_id == company_id,
|
||||||
@@ -264,7 +271,6 @@ async def link_contact(
|
|||||||
is_primary: bool = False,
|
is_primary: bool = False,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Link a contact to a company (N:M). Returns link data or None if either not found."""
|
"""Link a contact to a company (N:M). Returns link data or None if either not found."""
|
||||||
# Verify company exists and is in tenant
|
|
||||||
comp_q = select(Company).where(
|
comp_q = select(Company).where(
|
||||||
Company.id == company_id,
|
Company.id == company_id,
|
||||||
Company.tenant_id == tenant_id,
|
Company.tenant_id == tenant_id,
|
||||||
@@ -274,7 +280,6 @@ async def link_contact(
|
|||||||
if comp_result.scalar_one_or_none() is None:
|
if comp_result.scalar_one_or_none() is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Verify contact exists and is in tenant
|
|
||||||
cont_q = select(Contact).where(
|
cont_q = select(Contact).where(
|
||||||
Contact.id == contact_id,
|
Contact.id == contact_id,
|
||||||
Contact.tenant_id == tenant_id,
|
Contact.tenant_id == tenant_id,
|
||||||
@@ -284,7 +289,6 @@ async def link_contact(
|
|||||||
if cont_result.scalar_one_or_none() is None:
|
if cont_result.scalar_one_or_none() is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Check if link already exists
|
|
||||||
existing_q = select(CompanyContact).where(
|
existing_q = select(CompanyContact).where(
|
||||||
CompanyContact.company_id == company_id,
|
CompanyContact.company_id == company_id,
|
||||||
CompanyContact.contact_id == contact_id,
|
CompanyContact.contact_id == contact_id,
|
||||||
@@ -293,17 +297,11 @@ async def link_contact(
|
|||||||
existing_result = await db.execute(existing_q)
|
existing_result = await db.execute(existing_q)
|
||||||
existing = existing_result.scalar_one_or_none()
|
existing = existing_result.scalar_one_or_none()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
# Update existing link
|
|
||||||
existing.role_at_company = role_at_company
|
existing.role_at_company = role_at_company
|
||||||
existing.is_primary = is_primary
|
existing.is_primary = is_primary
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await log_audit(
|
await log_audit(
|
||||||
db,
|
db, tenant_id, user_id, "link", "company_contact", existing.id,
|
||||||
tenant_id,
|
|
||||||
user_id,
|
|
||||||
"link",
|
|
||||||
"company_contact",
|
|
||||||
existing.id,
|
|
||||||
changes={"company_id": str(company_id), "contact_id": str(contact_id)},
|
changes={"company_id": str(company_id), "contact_id": str(contact_id)},
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
@@ -324,12 +322,7 @@ async def link_contact(
|
|||||||
db.add(link)
|
db.add(link)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await log_audit(
|
await log_audit(
|
||||||
db,
|
db, tenant_id, user_id, "link", "company_contact", link.id,
|
||||||
tenant_id,
|
|
||||||
user_id,
|
|
||||||
"link",
|
|
||||||
"company_contact",
|
|
||||||
link.id,
|
|
||||||
changes={"company_id": str(company_id), "contact_id": str(contact_id)},
|
changes={"company_id": str(company_id), "contact_id": str(contact_id)},
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
@@ -362,12 +355,7 @@ async def unlink_contact(
|
|||||||
await db.delete(link)
|
await db.delete(link)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await log_audit(
|
await log_audit(
|
||||||
db,
|
db, tenant_id, user_id, "unlink", "company_contact", link.id,
|
||||||
tenant_id,
|
|
||||||
user_id,
|
|
||||||
"unlink",
|
|
||||||
"company_contact",
|
|
||||||
link.id,
|
|
||||||
changes={"company_id": str(company_id), "contact_id": str(contact_id)},
|
changes={"company_id": str(company_id), "contact_id": str(contact_id)},
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
@@ -403,20 +391,15 @@ async def export_companies_csv(
|
|||||||
output = io.StringIO()
|
output = io.StringIO()
|
||||||
writer = csv.writer(output)
|
writer = csv.writer(output)
|
||||||
writer.writerow(
|
writer.writerow(
|
||||||
["id", "name", "account_number", "industry", "phone", "email", "website", "description"]
|
["id", "name", "account_number", "industry", "phone", "email", "website", "description",
|
||||||
|
"address_street", "address_city", "address_zip", "address_country", "address_state"]
|
||||||
)
|
)
|
||||||
for c in companies:
|
for c in companies:
|
||||||
writer.writerow(
|
writer.writerow(
|
||||||
[
|
[str(c.id), c.name, c.account_number or "", c.industry or "",
|
||||||
str(c.id),
|
c.phone or "", c.email or "", c.website or "", c.description or "",
|
||||||
c.name,
|
c.address_street or "", c.address_city or "", c.address_zip or "",
|
||||||
c.account_number or "",
|
c.address_country or "", c.address_state or ""]
|
||||||
c.industry or "",
|
|
||||||
c.phone or "",
|
|
||||||
c.email or "",
|
|
||||||
c.website or "",
|
|
||||||
c.description or "",
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
return output.getvalue()
|
return output.getvalue()
|
||||||
|
|
||||||
@@ -454,28 +437,16 @@ async def export_companies_xlsx(
|
|||||||
ws = wb.active
|
ws = wb.active
|
||||||
ws.title = "Companies"
|
ws.title = "Companies"
|
||||||
headers = [
|
headers = [
|
||||||
"id",
|
"id", "name", "account_number", "industry", "phone", "email", "website", "description",
|
||||||
"name",
|
"address_street", "address_city", "address_zip", "address_country", "address_state",
|
||||||
"account_number",
|
|
||||||
"industry",
|
|
||||||
"phone",
|
|
||||||
"email",
|
|
||||||
"website",
|
|
||||||
"description",
|
|
||||||
]
|
]
|
||||||
ws.append(headers)
|
ws.append(headers)
|
||||||
for c in companies:
|
for c in companies:
|
||||||
ws.append(
|
ws.append(
|
||||||
[
|
[str(c.id), c.name, c.account_number or "", c.industry or "",
|
||||||
str(c.id),
|
c.phone or "", c.email or "", c.website or "", c.description or "",
|
||||||
c.name,
|
c.address_street or "", c.address_city or "", c.address_zip or "",
|
||||||
c.account_number or "",
|
c.address_country or "", c.address_state or ""]
|
||||||
c.industry or "",
|
|
||||||
c.phone or "",
|
|
||||||
c.email or "",
|
|
||||||
c.website or "",
|
|
||||||
c.description or "",
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
@@ -489,7 +460,6 @@ async def get_company_emails(
|
|||||||
company_id: uuid.UUID,
|
company_id: uuid.UUID,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Get emails for a company (mail plugin inactive — returns empty array)."""
|
"""Get emails for a company (mail plugin inactive — returns empty array)."""
|
||||||
# Verify company exists
|
|
||||||
q = select(Company).where(
|
q = select(Company).where(
|
||||||
Company.id == company_id,
|
Company.id == company_id,
|
||||||
Company.tenant_id == tenant_id,
|
Company.tenant_id == tenant_id,
|
||||||
@@ -497,5 +467,5 @@ async def get_company_emails(
|
|||||||
)
|
)
|
||||||
result = await db.execute(q)
|
result = await db.execute(q)
|
||||||
if result.scalar_one_or_none() is None:
|
if result.scalar_one_or_none() is None:
|
||||||
return [] # Caller should check existence separately
|
return []
|
||||||
return []
|
return []
|
||||||
|
|||||||
Reference in New Issue
Block a user