Files

273 lines
8.5 KiB
Python
Raw Permalink Normal View History

"""Address service — CRUD with tenant isolation, set_default logic."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.models.address import Address
VALID_ENTITY_TYPES = {"company", "contact"}
VALID_ADDRESS_TYPES = {"billing", "shipping", "headquarters", "branch", "private", "other"}
def _address_to_dict(a: Address) -> dict[str, Any]:
"""Serialize an Address ORM object to dict."""
return {
"id": str(a.id),
"entity_type": a.entity_type,
"entity_id": str(a.entity_id),
"label": a.label,
"address_type": a.address_type,
"street": a.street,
"street_number": a.street_number,
"city": a.city,
"zip": a.zip,
"state": a.state,
"country": a.country,
"is_default": a.is_default,
"created_at": a.created_at.isoformat() if a.created_at else None,
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
}
async def list_addresses(
db: AsyncSession,
tenant_id: uuid.UUID,
entity_type: str,
entity_id: uuid.UUID,
) -> dict[str, Any]:
"""List all addresses for a given entity within a tenant."""
q = (
select(Address)
.where(
Address.tenant_id == tenant_id,
Address.entity_type == entity_type,
Address.entity_id == entity_id,
Address.deleted_at.is_(None),
)
.order_by(Address.is_default.desc(), Address.label.asc())
)
result = await db.execute(q)
addresses = result.scalars().all()
return {
"items": [_address_to_dict(a) for a in addresses],
"total": len(addresses),
}
async def create_address(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
data: dict[str, Any],
) -> dict[str, Any]:
"""Create a new address. If is_default=True, unset other defaults of same type first."""
entity_type = data["entity_type"]
address_type = data["address_type"]
if entity_type not in VALID_ENTITY_TYPES:
raise ValueError(f"Invalid entity_type: {entity_type}")
if address_type not in VALID_ADDRESS_TYPES:
raise ValueError(f"Invalid address_type: {address_type}")
if data.get("is_default"):
await db.execute(
update(Address)
.where(
Address.tenant_id == tenant_id,
Address.entity_type == entity_type,
Address.entity_id == uuid.UUID(data["entity_id"]),
Address.address_type == address_type,
Address.is_default.is_(True),
Address.deleted_at.is_(None),
)
.values(is_default=False)
)
address = Address(
tenant_id=tenant_id,
entity_type=entity_type,
entity_id=uuid.UUID(data["entity_id"]),
label=data["label"],
address_type=address_type,
street=data.get("street"),
street_number=data.get("street_number"),
city=data.get("city"),
zip=data.get("zip"),
state=data.get("state"),
country=data.get("country"),
is_default=data.get("is_default", False),
)
db.add(address)
await db.flush()
await db.refresh(address)
await log_audit(
db, tenant_id, user_id, "create", "address", address.id,
changes={"label": address.label, "entity_type": entity_type},
)
return _address_to_dict(address)
async def update_address(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
address_id: uuid.UUID,
data: dict[str, Any],
) -> dict[str, Any] | None:
"""Update an address. If setting is_default=True, unset other defaults of same type first."""
q = select(Address).where(
Address.id == address_id,
Address.tenant_id == tenant_id,
Address.deleted_at.is_(None),
)
result = await db.execute(q)
address = result.scalar_one_or_none()
if address is None:
return None
if data.get("is_default") is True and not address.is_default:
await db.execute(
update(Address)
.where(
Address.tenant_id == tenant_id,
Address.entity_type == address.entity_type,
Address.entity_id == address.entity_id,
Address.address_type == address.address_type,
Address.is_default.is_(True),
Address.id != address_id,
Address.deleted_at.is_(None),
)
.values(is_default=False)
)
changes: dict[str, Any] = {}
for field in ("label", "address_type", "street", "street_number", "city", "zip", "state", "country", "is_default"):
if field in data and data[field] is not None:
old_val = getattr(address, field)
changes[field] = {"old": old_val, "new": data[field]}
setattr(address, field, data[field])
await db.flush()
await db.refresh(address)
await log_audit(db, tenant_id, user_id, "update", "address", address_id, changes=changes)
return _address_to_dict(address)
async def delete_address(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
address_id: uuid.UUID,
) -> bool:
"""Soft-delete an address."""
q = select(Address).where(
Address.id == address_id,
Address.tenant_id == tenant_id,
Address.deleted_at.is_(None),
)
result = await db.execute(q)
address = result.scalar_one_or_none()
if address is None:
return False
address.deleted_at = datetime.now(UTC)
await db.flush()
await log_audit(
db, tenant_id, user_id, "delete", "address", address_id,
changes={"label": address.label},
)
return True
async def migrate_existing_addresses(db: AsyncSession) -> int:
"""Migrate existing address fields from companies/contacts to Address records.
Called during migration 0013. Returns count of created addresses.
"""
from app.models.contact import Contact as Company
from app.models.contact import Contact
count = 0
# Migrate company addresses
companies_q = select(Company).where(
Company.address_street.is_not(None),
Company.deleted_at.is_(None),
)
companies_result = await db.execute(companies_q)
for company in companies_result.scalars():
existing = await db.execute(
select(Address).where(
Address.tenant_id == company.tenant_id,
Address.entity_type == "company",
Address.entity_id == company.id,
Address.address_type == "headquarters",
Address.deleted_at.is_(None),
)
)
if existing.scalar_one_or_none() is not None:
continue
addr = Address(
tenant_id=company.tenant_id,
entity_type="company",
entity_id=company.id,
label="Hauptsitz",
address_type="headquarters",
street=company.address_street,
city=company.address_city,
zip=company.address_zip,
country=company.address_country,
state=company.address_state,
is_default=True,
)
db.add(addr)
count += 1
# Migrate contact addresses
contacts_q = select(Contact).where(
Contact.address_street.is_not(None),
Contact.deleted_at.is_(None),
)
contacts_result = await db.execute(contacts_q)
for contact in contacts_result.scalars():
existing = await db.execute(
select(Address).where(
Address.tenant_id == contact.tenant_id,
Address.entity_type == "contact",
Address.entity_id == contact.id,
Address.address_type == "private",
Address.deleted_at.is_(None),
)
)
if existing.scalar_one_or_none() is not None:
continue
addr = Address(
tenant_id=contact.tenant_id,
entity_type="contact",
entity_id=contact.id,
label="Privat",
address_type="private",
street=contact.address_street,
city=contact.address_city,
zip=contact.address_zip,
country=contact.address_country,
state=contact.address_state,
is_default=True,
)
db.add(addr)
count += 1
if count > 0:
await db.flush()
return count