feat(core): add address fields to company service serialization and CRUD

This commit is contained in:
2026-07-04 00:18:45 +00:00
parent 59c787ba66
commit 11527c4c95
+36 -66
View File
@@ -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 []