"""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 sqlalchemy.orm import selectinload from app.core.audit import log_audit from app.models.address import Address from app.core.visibility import apply_visibility_filter, check_single_entity_access VALID_ENTITY_TYPES = {"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, user_id: uuid.UUID | None = None, is_system_admin: bool = False, ) -> 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()) ) if user_id and not is_system_admin: q = await apply_visibility_filter( db, q, "address", Address, user_id, tenant_id, is_system_admin ) 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), owner_id=user_id, ) 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], is_system_admin: bool = False, ) -> 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 not is_system_admin: has_access = await check_single_entity_access( db, "address", address.id, user_id, tenant_id, "write", is_system_admin ) if not has_access: raise PermissionError("No access") 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, is_system_admin: bool = False, ) -> 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 if not is_system_admin: has_access = await check_single_entity_access( db, "address", address.id, user_id, tenant_id, "admin", is_system_admin ) if not has_access: raise PermissionError("No access") 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 == "contact", 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="contact", 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