Files
leocrm/app/services/address_service.py
T
Agent Zero 7fbbe420bd
Check Cross-Plugin Imports / check (push) Has been cancelled
fix: comprehensive system audit fixes (55+ issues)
CRITICAL:
- Fix SQL injection in prestart.sh (parameterized query)
- Fix secret key validation (always validate, not just production)
- Fix workspace model partial index bug (func.text -> text)
- Fix HealthResponse schema (add checks field)
- Fix Tenant import in permissions.py (NameError on every auth request)
- Fix README tech stack (React instead of Alpine.js)
- Delete broken test_cross_tenant_security_v2.py
- Add fail-closed RLS migration 0084 (48 tenant tables)

HIGH:
- Add GeneralRateLimitMiddleware for all API routes
- Add file type blocklist for DMS and attachment uploads
- Fix guest auth: Pydantic schema, tenant_slug required, CSRF bypass
- Fix CSRF bypass path matching (in -> endswith)
- Add worker healthcheck in docker-compose.yml
- Add ARQ max_tries=3 for job retries
- Fix 28 bare pass in mail services (-> logger.debug)
- Fix print() -> logger in main.py and ai_assistant
- Fix duplicate email handling (catch IntegrityError -> 409)
- Add session revocation (invalidate_all_user_sessions)
- Add resource limits to all containers
- Fix CORS default (localhost -> production domain)
- Fix SameSite=Lax -> Strict
- Fix Redis password visibility in healthcheck
- Fix npm vulnerabilities (19 -> 9)
- Fix Sidebar OOM (wildcard lucide import -> curated ICON_MAP)

MEDIUM:
- Localize ErrorBoundary to German
- Wire Mail.tsx save/delete filter to API
- Document system_notif plugin (no routes needed)
- Fix datetime.utcnow() -> datetime.now(UTC)
- Pin litellm version (>=1.0,<2.0)
- Move CSRF token from sessionStorage to in-memory
- Fix restore_backup error handling and transaction
- Fix Dms.tsx useEffect cleanup
- Add skip-to-content link for accessibility
- Add selectinload imports to 3 services
- Add .env.example missing variables
- Fix AppShell/TopBar/Sidebar test mocks

NEW TESTS:
- test_guest_auth.py (6 tests)
- test_user_service.py (8 tests)
- test_backup_service.py (5 tests)

NEW SCHEMAS:
- saved_filter, saved_view, user_preference, workspace, entity_policy

Tests: 22/22 PASSED
2026-07-31 00:58:05 +02:00

298 lines
9.4 KiB
Python

"""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