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:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)",
|
||||
|
||||
Reference in New Issue
Block a user