T10: Monitoring, Performance, Documentation & Environment Config — 38 tests, ruff clean, docs OK

- Extended health endpoint: DB+Redis+Storage+Worker checks with degraded status
- Prometheus metrics endpoint: admin-only, text/plain format
- Metrics: http_requests_total, db_pool_connections, arq_jobs_total
- Structured JSON logging (structlog): timestamp, level, method, path, status, duration_ms, tenant_id
- Performance: page_size max 100 enforced (422), streaming CSV export (StreamingResponse)
- Scripts: seed_perf_data.py, check_indexes.py
- Docs: admin-guide.md (Deploy, Backup, Restore, Env-Vars, Troubleshooting), api-overview.md
- README updated: prod setup, API section, env profiles, admin-guide link
- .env.example: added SECRET_KEY, STORAGE_PATH, SMTP_* vars
- 38 new tests, full regression 564/564 pass (0 failures)
- Ruff: all checks passed
This commit is contained in:
leocrm-bot
2026-07-01 23:15:35 +02:00
parent 0070fb3aea
commit 69e91fd5d0
24 changed files with 2249 additions and 266 deletions
+107 -2
View File
@@ -1,15 +1,21 @@
"""Contact routes — CRUD, N:M, soft-delete, GDPR hard-delete."""
"""Contact routes — CRUD, N:M, soft-delete, GDPR hard-delete, streaming CSV export."""
from __future__ import annotations
import asyncio
import csv
import io
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import check_permission
from app.core.db import get_db
from app.deps import get_current_user
from app.models.contact import Contact
from app.schemas.contact import ContactCreate, ContactUpdate
from app.services import contact_service
@@ -26,7 +32,10 @@ async def list_contacts(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List contacts with pagination and optional search."""
"""List contacts with pagination and optional search.
page_size is capped at 100 — values >100 return 422.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
result = await contact_service.list_contacts(
db,
@@ -40,6 +49,102 @@ async def list_contacts(
return result
@router.get("/export")
async def export_contacts(
format: str = Query("csv", pattern="^(csv)$"),
search: str | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Stream contacts as CSV (not buffered — uses StreamingResponse).
For large exports (>1000 records), an ARQ background job should be started
with a notification on completion. This endpoint streams directly for
immediate download.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
from app.core.db import get_session_factory
async def generate_csv():
"""Async generator yielding CSV rows one at a time (streaming).
Creates its own DB session to avoid using the request-scoped session
which gets closed after the endpoint function returns.
"""
output = io.StringIO()
writer = csv.writer(output)
# Write header row
header = [
"id",
"first_name",
"last_name",
"email",
"phone",
"mobile",
"position",
"department",
"linkedin_url",
"notes",
"created_at",
"updated_at",
]
writer.writerow(header)
yield output.getvalue()
output.seek(0)
output.truncate(0)
# Stream rows in batches to avoid loading all into memory
batch_size = 500
offset = 0
factory = get_session_factory()
async with factory() as session:
while True:
base = select(Contact).where(
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
if search:
pattern = f"%{search}%"
base = base.where(
(Contact.first_name.ilike(pattern))
| (Contact.last_name.ilike(pattern))
| (Contact.email.ilike(pattern))
)
base = base.order_by(Contact.last_name.asc()).offset(offset).limit(batch_size)
result = await session.execute(base)
contacts = result.scalars().all()
if not contacts:
break
for c in contacts:
writer.writerow(
[
str(c.id),
c.first_name,
c.last_name,
c.email or "",
c.phone or "",
c.mobile or "",
c.position or "",
c.department or "",
c.linkedin_url or "",
c.notes or "",
c.created_at.isoformat() if c.created_at else "",
c.updated_at.isoformat() if c.updated_at else "",
]
)
yield output.getvalue()
output.seek(0)
output.truncate(0)
offset += batch_size
return StreamingResponse(
generate_csv(),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=contacts.csv"},
)
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_contact(
body: ContactCreate,