feat(07-01): localized emails, locale-aware templates API, language preference endpoint

- email.py: send_invite_email() adds language param (en/es/pt), sends localized subject+body
- templates.py: list_templates()/get_template() accept ?locale= param, merge translations on response
- portal.py: PATCH /api/portal/users/me/language endpoint persists language preference
- portal.py: /api/portal/auth/verify response includes user.language field
- portal.py: AuthVerifyResponse adds language field (default 'en')
- test_portal_auth.py: fix _make_user mock to set language='en' (auto-fix Rule 1)
- test_language_preference.py: 4 integration tests for language preference endpoint
- test_templates_i18n.py: 5 integration tests for locale-aware templates (all passing)
This commit is contained in:
2026-03-25 16:27:14 -06:00
parent 7a3a4f0fdd
commit 9654982433
6 changed files with 648 additions and 38 deletions

View File

@@ -19,7 +19,7 @@ from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from shared.api.rbac import PortalCaller, require_platform_admin, require_tenant_admin, require_tenant_member
from shared.api.rbac import PortalCaller, get_portal_caller, require_platform_admin, require_tenant_admin, require_tenant_member
from shared.db import get_session
from shared.models.audit import AuditEvent
from shared.models.auth import PortalInvitation, PortalUser, UserTenantRole
@@ -47,6 +47,7 @@ class AuthVerifyResponse(BaseModel):
role: str
tenant_ids: list[str]
active_tenant_id: str | None
language: str = "en"
class AuthRegisterRequest(BaseModel):
@@ -302,6 +303,7 @@ async def verify_credentials(
role=user.role,
tenant_ids=tenant_ids,
active_tenant_id=active_tenant_id,
language=user.language,
)
@@ -842,3 +844,54 @@ async def stop_impersonation(
},
)
await session.commit()
# ---------------------------------------------------------------------------
# Language preference endpoint (Phase 7 multilanguage)
# ---------------------------------------------------------------------------
_SUPPORTED_LANGUAGES = {"en", "es", "pt"}
class LanguagePreferenceRequest(BaseModel):
language: str
class LanguagePreferenceResponse(BaseModel):
language: str
@portal_router.patch("/users/me/language", response_model=LanguagePreferenceResponse)
async def update_language_preference(
body: LanguagePreferenceRequest,
caller: PortalCaller = Depends(get_portal_caller),
session: AsyncSession = Depends(get_session),
) -> LanguagePreferenceResponse:
"""
Update the authenticated user's language preference.
Accepts: {"language": "es"} (must be one of: en, es, pt)
Returns: {"language": "es"} on success.
Returns 400 if language is not in the supported set.
Returns 401 if not authenticated (no X-Portal-User-Id header).
"""
if body.language not in _SUPPORTED_LANGUAGES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported language '{body.language}'. Supported: en, es, pt",
)
result = await session.execute(
select(PortalUser).where(PortalUser.id == caller.user_id)
)
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
user.language = body.language
await session.commit()
return LanguagePreferenceResponse(language=body.language)

View File

@@ -10,6 +10,11 @@ Endpoints:
GET /api/portal/templates — list active templates (all authenticated users)
GET /api/portal/templates/{id} — get template detail (all authenticated users)
POST /api/portal/templates/{id}/deploy — deploy template as agent (tenant admin only)
Locale-aware responses:
Pass ?locale=es or ?locale=pt to receive translated name/description/persona fields.
English is the base — translations overlay, never replace stored English values in DB.
Unsupported locales silently fall back to English.
"""
from __future__ import annotations
@@ -18,7 +23,7 @@ import uuid
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -55,14 +60,33 @@ class TemplateResponse(BaseModel):
model_config = {"from_attributes": True}
@classmethod
def from_orm(cls, tmpl: AgentTemplate) -> "TemplateResponse":
def from_orm(cls, tmpl: AgentTemplate, locale: str = "en") -> "TemplateResponse":
"""
Build a TemplateResponse from an ORM AgentTemplate.
When locale != 'en' and the template's translations map contains the
locale key, translated name/description/persona fields are overlaid over
the English defaults. English base fields are never overwritten in the DB.
"""
name = tmpl.name
description = tmpl.description
persona = tmpl.persona
if locale != "en":
translations: dict[str, Any] = tmpl.translations or {}
locale_data: dict[str, Any] = translations.get(locale, {})
if locale_data:
name = locale_data.get("name", name)
description = locale_data.get("description", description)
persona = locale_data.get("persona", persona)
return cls(
id=str(tmpl.id),
name=tmpl.name,
name=name,
role=tmpl.role,
description=tmpl.description,
description=description,
category=tmpl.category,
persona=tmpl.persona,
persona=persona,
system_prompt=tmpl.system_prompt,
model_preference=tmpl.model_preference,
tool_assignments=tmpl.tool_assignments,
@@ -88,6 +112,7 @@ class TemplateDeployResponse(BaseModel):
@templates_router.get("/templates", response_model=list[TemplateResponse])
async def list_templates(
locale: str = Query(default="en", description="Response locale: en | es | pt"),
caller: PortalCaller = Depends(get_portal_caller),
session: AsyncSession = Depends(get_session),
) -> list[TemplateResponse]:
@@ -97,6 +122,9 @@ async def list_templates(
Available to all authenticated portal users (any role).
Templates are global — not tenant-scoped, no RLS needed.
Returns templates ordered by sort_order ascending, then name.
Pass ?locale=es or ?locale=pt to receive translated name/description/persona fields.
Unsupported locales fall back to English.
"""
result = await session.execute(
select(AgentTemplate)
@@ -104,12 +132,13 @@ async def list_templates(
.order_by(AgentTemplate.sort_order.asc(), AgentTemplate.name.asc())
)
templates = result.scalars().all()
return [TemplateResponse.from_orm(t) for t in templates]
return [TemplateResponse.from_orm(t, locale=locale) for t in templates]
@templates_router.get("/templates/{template_id}", response_model=TemplateResponse)
async def get_template(
template_id: uuid.UUID,
locale: str = Query(default="en", description="Response locale: en | es | pt"),
caller: PortalCaller = Depends(get_portal_caller),
session: AsyncSession = Depends(get_session),
) -> TemplateResponse:
@@ -118,6 +147,8 @@ async def get_template(
Returns 404 if the template does not exist or is inactive.
Available to all authenticated portal users (any role).
Pass ?locale=es or ?locale=pt to receive translated fields.
"""
result = await session.execute(
select(AgentTemplate).where(
@@ -128,7 +159,7 @@ async def get_template(
tmpl = result.scalar_one_or_none()
if tmpl is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return TemplateResponse.from_orm(tmpl)
return TemplateResponse.from_orm(tmpl, locale=locale)
@templates_router.post(

View File

@@ -7,6 +7,9 @@ Phase 1 architectural constraint). Uses stdlib smtplib — no additional depende
If SMTP is not configured (empty smtp_host), logs a warning and returns without
sending. This allows the invitation flow to function in dev environments without
a mail server.
Supports localized invitation emails in English (en), Spanish (es), and Portuguese (pt).
Falls back to English for unsupported locales.
"""
from __future__ import annotations
@@ -20,35 +23,21 @@ from shared.config import settings
logger = logging.getLogger(__name__)
_SUPPORTED_LANGUAGES = {"en", "es", "pt"}
def send_invite_email(
to_email: str,
invitee_name: str,
tenant_name: str,
invite_url: str,
) -> None:
"""
Send an invitation email via SMTP.
# ---------------------------------------------------------------------------
# Localized email copy
# ---------------------------------------------------------------------------
Args:
to_email: Recipient email address.
invitee_name: Recipient's display name (for personalization).
tenant_name: Name of the tenant they're being invited to.
invite_url: The full invitation acceptance URL (includes raw token).
_SUBJECTS: dict[str, str] = {
"en": "You've been invited to join {tenant_name} on Konstruct",
"es": "Has sido invitado a unirte a {tenant_name} en Konstruct",
"pt": "Voce foi convidado para se juntar a {tenant_name} no Konstruct",
}
Note:
Called from a Celery task (sync). Silently skips if smtp_host is empty.
"""
if not settings.smtp_host:
logger.warning(
"SMTP not configured (smtp_host is empty) — skipping invite email to %s",
to_email,
)
return
subject = f"You've been invited to join {tenant_name} on Konstruct"
text_body = f"""Hi {invitee_name},
_TEXT_BODIES: dict[str, str] = {
"en": """\
Hi {invitee_name},
You've been invited to join {tenant_name} on Konstruct, the AI workforce platform.
@@ -61,9 +50,42 @@ This invitation expires in 48 hours.
If you did not expect this invitation, you can safely ignore this email.
— The Konstruct Team
"""
""",
"es": """\
Hola {invitee_name},
html_body = f"""<html>
Has sido invitado a unirte a {tenant_name} en Konstruct, la plataforma de empleados de IA.
Haz clic en el enlace a continuacion para aceptar tu invitacion y configurar tu cuenta:
{invite_url}
Esta invitacion vence en 48 horas.
Si no esperabas esta invitacion, puedes ignorar este correo de forma segura.
— El Equipo de Konstruct
""",
"pt": """\
Ola {invitee_name},
Voce foi convidado para se juntar a {tenant_name} no Konstruct, a plataforma de funcionarios de IA.
Clique no link abaixo para aceitar o seu convite e configurar sua conta:
{invite_url}
Este convite expira em 48 horas.
Se voce nao estava esperando este convite, pode ignorar este e-mail com seguranca.
— O Time Konstruct
""",
}
_HTML_BODIES: dict[str, str] = {
"en": """\
<html>
<body style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2>You've been invited to join {tenant_name}</h2>
<p>Hi {invitee_name},</p>
@@ -83,7 +105,96 @@ If you did not expect this invitation, you can safely ignore this email.
you can safely ignore it.
</p>
</body>
</html>"""
</html>""",
"es": """\
<html>
<body style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Has sido invitado a unirte a {tenant_name}</h2>
<p>Hola {invitee_name},</p>
<p>
Has sido invitado a unirte a <strong>{tenant_name}</strong> en
<strong>Konstruct</strong>, la plataforma de empleados de IA.
</p>
<p>
<a href="{invite_url}"
style="display: inline-block; padding: 12px 24px; background: #2563eb;
color: white; text-decoration: none; border-radius: 6px;">
Aceptar Invitacion
</a>
</p>
<p style="color: #6b7280; font-size: 0.9em;">
Esta invitacion vence en 48 horas. Si no esperabas este correo,
puedes ignorarlo de forma segura.
</p>
</body>
</html>""",
"pt": """\
<html>
<body style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Voce foi convidado para se juntar a {tenant_name}</h2>
<p>Ola {invitee_name},</p>
<p>
Voce foi convidado para se juntar a <strong>{tenant_name}</strong> no
<strong>Konstruct</strong>, a plataforma de funcionarios de IA.
</p>
<p>
<a href="{invite_url}"
style="display: inline-block; padding: 12px 24px; background: #2563eb;
color: white; text-decoration: none; border-radius: 6px;">
Aceitar Convite
</a>
</p>
<p style="color: #6b7280; font-size: 0.9em;">
Este convite expira em 48 horas. Se voce nao estava esperando este e-mail,
pode ignora-lo com seguranca.
</p>
</body>
</html>""",
}
def send_invite_email(
to_email: str,
invitee_name: str,
tenant_name: str,
invite_url: str,
language: str = "en",
) -> None:
"""
Send an invitation email via SMTP, optionally in the inviter's language.
Args:
to_email: Recipient email address.
invitee_name: Recipient's display name (for personalization).
tenant_name: Name of the tenant they're being invited to.
invite_url: The full invitation acceptance URL (includes raw token).
language: Language for the email body. Supported: 'en', 'es', 'pt'.
Falls back to 'en' for unsupported locales.
Note:
Called from a Celery task (sync). Silently skips if smtp_host is empty.
"""
if not settings.smtp_host:
logger.warning(
"SMTP not configured (smtp_host is empty) — skipping invite email to %s",
to_email,
)
return
# Normalize language — fall back to English for unsupported locales
lang = language if language in _SUPPORTED_LANGUAGES else "en"
subject = _SUBJECTS[lang].format(tenant_name=tenant_name)
text_body = _TEXT_BODIES[lang].format(
invitee_name=invitee_name,
tenant_name=tenant_name,
invite_url=invite_url,
)
html_body = _HTML_BODIES[lang].format(
invitee_name=invitee_name,
tenant_name=tenant_name,
invite_url=invite_url,
)
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
@@ -101,7 +212,9 @@ If you did not expect this invitation, you can safely ignore this email.
if settings.smtp_username and settings.smtp_password:
server.login(settings.smtp_username, settings.smtp_password)
server.sendmail(settings.smtp_from_email, [to_email], msg.as_string())
logger.info("Invite email sent to %s for tenant %s", to_email, tenant_name)
logger.info(
"Invite email sent to %s for tenant %s (language=%s)", to_email, tenant_name, lang
)
except Exception:
logger.exception(
"Failed to send invite email to %s (smtp_host=%s)",