diff --git a/sparkth/api/v1/auth.py b/sparkth/api/v1/auth.py index 29347fba..279c9249 100644 --- a/sparkth/api/v1/auth.py +++ b/sparkth/api/v1/auth.py @@ -25,6 +25,7 @@ from sparkth.lib.audit.context import AnonymousActor, UserActor from sparkth.lib.audit.events import AuditOutcome, AuditTarget, LoginAuditEvent from sparkth.lib.db import get_async_session +from sparkth.lib.i18n import _ from sparkth.schemas import ( GoogleAuthUrl, ResendVerificationRequest, @@ -54,23 +55,23 @@ async def register_user( session: AsyncSession = Depends(get_async_session), ) -> User: if not settings.REGISTRATION_ENABLED: - raise HTTPException(status_code=403, detail="Registration is currently disabled") + raise HTTPException(status_code=403, detail=_("Registration is currently disabled")) normalized_email = user.email.strip().lower() if not await WhitelistService.is_email_allowed(session, normalized_email): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="This email address is not authorized to register. Contact an administrator.", + detail=_("This email address is not authorized to register. Contact an administrator."), ) result = await session.exec(select(User).where(User.username == user.username)) db_user = result.one_or_none() if db_user: - raise HTTPException(status_code=400, detail="Username already registered") + raise HTTPException(status_code=400, detail=_("Username already registered")) result = await session.exec(select(User).where(User.email == normalized_email)) db_user_email = result.one_or_none() if db_user_email: - raise HTTPException(status_code=400, detail="Email already registered") + raise HTTPException(status_code=400, detail=_("Email already registered")) hashed_password = security.get_password_hash(user.password) db_user = User( @@ -140,7 +141,7 @@ async def login_for_access_token( ) raise HTTPException( status_code=401, - detail="Incorrect username or password", + detail=_("Incorrect username or password"), headers={"WWW-Authenticate": "Bearer"}, ) @@ -155,7 +156,7 @@ async def login_for_access_token( ) raise HTTPException( status_code=401, - detail="Incorrect username or password", + detail=_("Incorrect username or password"), headers={"WWW-Authenticate": "Bearer"}, ) diff --git a/sparkth/api/v1/file_parser.py b/sparkth/api/v1/file_parser.py index da600de5..ac063594 100644 --- a/sparkth/api/v1/file_parser.py +++ b/sparkth/api/v1/file_parser.py @@ -6,6 +6,7 @@ from sparkth.core.models.user import User from sparkth.lib.auth import get_current_user +from sparkth.lib.i18n import _ router: APIRouter = APIRouter() @@ -38,12 +39,12 @@ async def parse_pdf(content: bytes) -> str: @router.post("/upload") async def upload_text(current_user: User = Depends(get_current_user), file: UploadFile = File(...)) -> JSONResponse: if not current_user or not current_user.id: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not authenticated.") + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=_("User not authenticated.")) if file.content_type not in ALLOWED_TYPES: raise HTTPException( status_code=400, - detail="Only .txt and .pdf files are supported", + detail=_("Only .txt and .pdf files are supported"), ) content = await file.read() @@ -52,7 +53,7 @@ async def upload_text(current_user: User = Depends(get_current_user), file: Uplo if size > MAX_FILE_SIZE: raise HTTPException( status_code=400, - detail="File size exceeds 30MB limit", + detail=_("File size exceeds 30MB limit"), ) if file.content_type == "text/plain": @@ -62,7 +63,7 @@ async def upload_text(current_user: User = Depends(get_current_user), file: Uplo text = await parse_pdf(content) else: - raise HTTPException(status_code=400, detail="Unsupported file type") + raise HTTPException(status_code=400, detail=_("Unsupported file type")) return JSONResponse( { diff --git a/sparkth/api/v1/llm.py b/sparkth/api/v1/llm.py index f29faf80..69a84d02 100644 --- a/sparkth/api/v1/llm.py +++ b/sparkth/api/v1/llm.py @@ -7,6 +7,7 @@ from sparkth.core.models.user import User from sparkth.lib.auth import get_current_user from sparkth.lib.db import get_async_session +from sparkth.lib.i18n import _ from sparkth.lib.llm import ( DEFAULT_MODEL, DEFAULT_PROVIDER, @@ -93,7 +94,9 @@ async def update_llm_config( service: LLMConfigService = Depends(get_llm_service), ) -> LLMConfigResponse: if not body.name and not body.model: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Empty body. Nothing to update.") + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=_("Empty body. Nothing to update.") + ) try: config = await service.update( @@ -173,5 +176,5 @@ async def delete_llm_config( config_id=config_id, ) if not deleted: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="LLM config not found") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=_("LLM config not found")) await session.commit() diff --git a/sparkth/api/v1/user/routes.py b/sparkth/api/v1/user/routes.py index 541abc22..d51d8c52 100644 --- a/sparkth/api/v1/user/routes.py +++ b/sparkth/api/v1/user/routes.py @@ -20,6 +20,7 @@ from sparkth.core.models.user import User from sparkth.lib.auth import get_current_user from sparkth.lib.db import get_async_session +from sparkth.lib.i18n import _ from sparkth.lib.permissions import has_role from sparkth.lib.permissions.scopes import GLOBAL from sparkth.schemas import User as UserSchema @@ -76,7 +77,7 @@ async def update_user_language( # that dependency: an authenticated principal without a row is an # authentication-boundary condition, not a domain error to route through the # exception-handler registry. - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=_("User not found")) user.language = update.language # updated_at has a default_factory but no onupdate, so the bump is manual. diff --git a/sparkth/api/v1/user_plugins.py b/sparkth/api/v1/user_plugins.py index 31bb513c..2534694a 100644 --- a/sparkth/api/v1/user_plugins.py +++ b/sparkth/api/v1/user_plugins.py @@ -22,6 +22,7 @@ ) from sparkth.lib.auth import get_current_user from sparkth.lib.db import get_async_session +from sparkth.lib.i18n import _ from sparkth.lib.log import get_logger logger = get_logger(__name__) @@ -45,7 +46,9 @@ class UserPluginConfigRequest(BaseModel): async def get_plugin_or_404(plugin_service: PluginService, session: AsyncSession, name: str) -> Plugin: plugin = await plugin_service.get_by_name(session, name) if not plugin: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plugin '{name}' not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=_("Plugin '{name}' not found").format(name=name) + ) return plugin @@ -55,7 +58,9 @@ def validate_plugin(plugin: Plugin) -> None: status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Plugin '{plugin.name}' is not persisted." ) if not plugin.enabled: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Plugin '{plugin.name}' is not enabled") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=_("Plugin '{name}' is not enabled").format(name=plugin.name) + ) @router.get("/", response_model=list[UserPluginResponse]) @@ -115,7 +120,7 @@ async def create_user_plugin( ) -> UserPluginResponse: """Create a user plugin with validated configuration.""" if not current_user.id: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not authenticated.") + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=_("User not authenticated.")) plugin = await get_plugin_or_404(plugin_service, session, plugin_name) validate_plugin(plugin) @@ -123,7 +128,8 @@ async def create_user_plugin( user_plugin = await plugin_service.get_user_plugin(session, current_user.id, plugin.id) if user_plugin and len(user_plugin.config) > 0: raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail=f"Plugin '{plugin_name}' is already configured" + status_code=status.HTTP_409_CONFLICT, + detail=_("Plugin '{name}' is already configured").format(name=plugin_name), ) try: @@ -189,7 +195,7 @@ async def update_user_plugin( Enable or disable a plugin for the current user. """ if not current_user.id: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not authenticated.") + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=_("User not authenticated.")) plugin = await get_plugin_or_404(plugin_service, session, plugin_name) validate_plugin(plugin) @@ -219,7 +225,7 @@ async def update_user_plugin_config( This allows users to customize plugin-specific settings. """ if not current_user.id: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not authenticated.") + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=_("User not authenticated.")) plugin = await get_plugin_or_404(plugin_service, session, plugin_name) validate_plugin(plugin) diff --git a/sparkth/core/google_auth.py b/sparkth/core/google_auth.py index 56231e45..771424d9 100644 --- a/sparkth/core/google_auth.py +++ b/sparkth/core/google_auth.py @@ -6,6 +6,7 @@ import aiohttp from sparkth.core.config import get_settings +from sparkth.lib.i18n import _ # Google OAuth endpoints GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" @@ -37,7 +38,7 @@ def generate_google_login_url() -> str: Returns: The authorization URL to redirect the user to. """ - client_id, _, redirect_uri = get_google_credentials() + client_id, _client_secret, redirect_uri = get_google_credentials() params = { "client_id": client_id, @@ -100,6 +101,6 @@ async def get_google_user_info(access_token: str) -> dict[str, Any]: headers = {"Authorization": f"Bearer {access_token}"} async with session.get(GOOGLE_USERINFO_URL, headers=headers) as response: if response.status != 200: - raise ValueError("Failed to get user info from Google") + raise ValueError(_("Failed to get user info from Google")) result: dict[str, Any] = await response.json() return result diff --git a/sparkth/core/permissions/__init__.py b/sparkth/core/permissions/__init__.py index 4b013930..5da5fdb8 100644 --- a/sparkth/core/permissions/__init__.py +++ b/sparkth/core/permissions/__init__.py @@ -39,6 +39,7 @@ from sparkth.lib.auth import get_current_user from sparkth.lib.db import get_async_session from sparkth.lib.hooks import SingleNamedItemHook +from sparkth.lib.i18n import _ from sparkth.lib.log import get_logger logger = get_logger(__name__) @@ -89,7 +90,7 @@ async def dependency( raise RuntimeError("Permission scope is misconfigured") scope_object_id = request.path_params.get(scope_param) if scope_param else None if not await can(current_user, self, permission_scope, scope_object_id, session): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=_("Permission denied")) return current_user return dependency diff --git a/sparkth/core/permissions/exceptions.py b/sparkth/core/permissions/exceptions.py index 4d1239e7..54791e26 100644 --- a/sparkth/core/permissions/exceptions.py +++ b/sparkth/core/permissions/exceptions.py @@ -1,8 +1,11 @@ +from sparkth.lib.i18n import _ + + class RoleNotFound(Exception): """Raised when a role referenced by name does not exist.""" def __init__(self, role_name: str) -> None: - super().__init__(f"Role not found: {role_name}") + super().__init__(_("Role not found: {role_name}").format(role_name=role_name)) self.role_name = role_name @@ -10,7 +13,7 @@ class PermissionNotFound(Exception): """Raised when a permission referenced by name is not registered.""" def __init__(self, permission: str) -> None: - super().__init__(f"Permission not found: {permission}") + super().__init__(_("Permission not found: {permission}").format(permission=permission)) self.permission = permission @@ -18,7 +21,7 @@ class PermissionScopeNotFound(Exception): """Raised when a permission scope referenced by name is not registered.""" def __init__(self, name: str) -> None: - super().__init__(f"Permission scope not found: {name}") + super().__init__(_("Permission scope not found: {name}").format(name=name)) self.name = name @@ -26,7 +29,7 @@ class RoleAlreadyExists(Exception): """Raised when creating or renaming a role to a name that is already taken.""" def __init__(self, name: str) -> None: - super().__init__(f"Role already exists: {name}") + super().__init__(_("Role already exists: {name}").format(name=name)) self.name = name @@ -34,7 +37,7 @@ class RoleInUse(Exception): """Raised when deleting a role that still has active assignments.""" def __init__(self, role_id: int) -> None: - super().__init__(f"Role is still assigned and cannot be deleted: {role_id}") + super().__init__(_("Role is still assigned and cannot be deleted: {role_id}").format(role_id=role_id)) self.role_id = role_id @@ -42,7 +45,7 @@ class GroupNotFound(Exception): """Raised when a group referenced by id or name does not exist.""" def __init__(self, name: str) -> None: - super().__init__(f"Group not found: {name}") + super().__init__(_("Group not found: {name}").format(name=name)) self.name = name @@ -50,7 +53,7 @@ class GroupAlreadyExists(Exception): """Raised when creating or renaming a group to a name that is already taken.""" def __init__(self, name: str) -> None: - super().__init__(f"Group already exists: {name}") + super().__init__(_("Group already exists: {name}").format(name=name)) self.name = name @@ -58,7 +61,9 @@ class GroupInUse(Exception): """Raised when deleting a group that still has active role assignments.""" def __init__(self, group_id: int) -> None: - super().__init__(f"Group still has active role assignments and cannot be deleted: {group_id}") + super().__init__( + _("Group still has active role assignments and cannot be deleted: {group_id}").format(group_id=group_id) + ) self.group_id = group_id @@ -72,6 +77,10 @@ class InvalidScopeObjectId(Exception): """ def __init__(self, scope: str, scope_object_id: str | None) -> None: - super().__init__(f"Invalid scope/object-id pairing for scope {scope!r}: {scope_object_id!r}") + super().__init__( + _("Invalid scope/object-id pairing for scope {scope!r}: {scope_object_id!r}").format( + scope=scope, scope_object_id=scope_object_id + ) + ) self.scope = scope self.scope_object_id = scope_object_id diff --git a/sparkth/core/plugins/middleware.py b/sparkth/core/plugins/middleware.py index 41d8e462..855d89bb 100644 --- a/sparkth/core/plugins/middleware.py +++ b/sparkth/core/plugins/middleware.py @@ -15,6 +15,7 @@ from sparkth.core.routes import get_route_plugin_name from sparkth.lib.auth import decode_token_username, get_user_by_username from sparkth.lib.db import session_scope +from sparkth.lib.i18n import _ from sparkth.lib.log import get_logger logger = get_logger(__name__) @@ -96,8 +97,10 @@ async def dispatch(self, request: Request, call_next: Callable[[Request], Awaita return JSONResponse( status_code=status.HTTP_403_FORBIDDEN, content={ - "detail": f"Access to plugin '{plugin_name}' is disabled for your account. " - f"Please enable the plugin in your settings." + "detail": _( + "Access to plugin '{name}' is disabled for your account. " + "Please enable the plugin in your settings." + ).format(name=plugin_name) }, ) diff --git a/sparkth/core/plugins/service.py b/sparkth/core/plugins/service.py index 9d41f8a2..a1ec6b81 100644 --- a/sparkth/core/plugins/service.py +++ b/sparkth/core/plugins/service.py @@ -12,6 +12,8 @@ from sparkth.lib.db import session_scope from sparkth.lib.frontend import get_plugin_display_info, get_plugin_sidebar_entry, plugin_has_frontend from sparkth.lib.frontend.hooks import DisplayInfo, SidebarEntry +from sparkth.lib.i18n import _ +from sparkth.lib.i18n import gettext as translate from sparkth.lib.log import get_logger logger = get_logger(__name__) @@ -48,14 +50,25 @@ class UserPluginResponse(pydantic.BaseModel): @classmethod def for_plugin(cls, plugin_name: str, enabled: bool, config: dict[str, Any], is_core: bool) -> "UserPluginResponse": - """Build a response carrying the frontend metadata declared for ``plugin_name``.""" + """Build a response carrying the frontend metadata declared for ``plugin_name``. + + The declared display and sidebar strings are ``gettext_noop``-marked + source messages; this is their rendering boundary, so they are + translated into the request locale here. + """ + display = get_plugin_display_info(plugin_name) + if display is not None: + display = DisplayInfo(translate(display.display_name), translate(display.description), display.icon) + sidebar = get_plugin_sidebar_entry(plugin_name) + if sidebar is not None: + sidebar = SidebarEntry(translate(sidebar.label), sidebar.icon, sidebar.order) return cls( plugin_name=plugin_name, enabled=enabled, config=config, is_core=is_core, - display=get_plugin_display_info(plugin_name), - sidebar=get_plugin_sidebar_entry(plugin_name), + display=display, + sidebar=sidebar, has_frontend=plugin_has_frontend(plugin_name), ) @@ -92,13 +105,13 @@ def validate_user_config(plugin: Plugin, user_config: dict[str, Any]) -> dict[st config_class = get_plugin_config_schema(plugin.name) if not config_class: logger.error(f"Plugin '{plugin.name}' config class is missing or invalid") - raise InternalServerError(f"Plugin '{plugin.name}' cannot be configured at this time.") + raise InternalServerError(_("Plugin '{name}' cannot be configured at this time.").format(name=plugin.name)) if not issubclass(config_class, PluginConfig): logger.error( f"'{plugin.name.title()}Config' must inherit from sparkth.core.plugins.config_base.PluginConfig" ) - raise InternalServerError(f"Plugin '{plugin.name}' cannot be configured at this time.") + raise InternalServerError(_("Plugin '{name}' cannot be configured at this time.").format(name=plugin.name)) try: validated_config = config_class(**user_config) @@ -301,7 +314,7 @@ async def update_user_plugin_config( if user_plugin: if not user_plugin.enabled: - raise PluginDisabledError("Cannot update plugin configuration while the plugin is disabled") + raise PluginDisabledError(_("Cannot update plugin configuration while the plugin is disabled")) merged_config = {**user_plugin.config, **user_config} else: merged_config = user_config diff --git a/sparkth/lib/auth.py b/sparkth/lib/auth.py index f4691d15..07793103 100644 --- a/sparkth/lib/auth.py +++ b/sparkth/lib/auth.py @@ -22,6 +22,7 @@ from sparkth.core import security from sparkth.core.models.user import User from sparkth.lib.db import get_async_session +from sparkth.lib.i18n import _ from sparkth.lib.log import get_logger logger = get_logger(__name__) @@ -86,7 +87,7 @@ async def get_current_user( if username is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", + detail=_("Could not validate credentials"), headers={"WWW-Authenticate": "Bearer"}, ) @@ -94,7 +95,7 @@ async def get_current_user( if user is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="User not found", + detail=_("User not found"), headers={"WWW-Authenticate": "Bearer"}, ) diff --git a/sparkth/llm/adapter.py b/sparkth/llm/adapter.py index c3b11b3c..408830f4 100644 --- a/sparkth/llm/adapter.py +++ b/sparkth/llm/adapter.py @@ -6,6 +6,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession from sparkth.core.models.llm import LLMConfig +from sparkth.lib.i18n import _ from sparkth.llm.exceptions import LLMConfigValidationError from sparkth.llm.providers import get_models_for_provider @@ -66,14 +67,15 @@ async def preprocess_config( if llm is None: raise ValueError(f"llm_config_id {config_id} not found or does not belong to this user.") if not llm.is_active: - raise ValueError("This record is deactivated. Reactivate it in AI Keys or select a different config.") + raise ValueError(_("This record is deactivated. Reactivate it in AI Keys or select a different config.")) if model_override is not None: allowed = get_models_for_provider(llm.provider) if model_override not in allowed: raise LLMConfigValidationError( - f"Model '{model_override}' not available for provider '{llm.provider}'. " - f"Allowed: {', '.join(allowed)}" + _("Model '{model}' not available for provider '{provider}'. Allowed: {allowed}").format( + model=model_override, provider=llm.provider, allowed=", ".join(allowed) + ) ) return {**incoming_config, "llm_config_id": config_id} diff --git a/sparkth/llm/exceptions.py b/sparkth/llm/exceptions.py index 6822dd08..71618b5d 100644 --- a/sparkth/llm/exceptions.py +++ b/sparkth/llm/exceptions.py @@ -1,8 +1,13 @@ +from sparkth.lib.i18n import _ + + class LLMConfigNotFoundError(ValueError): """Raised when an LLM config is not found.""" def __init__(self, config_id: int, user_id: int) -> None: - super().__init__(f"LLMConfig {config_id} not found for user {user_id}") + super().__init__( + _("LLMConfig {config_id} not found for user {user_id}").format(config_id=config_id, user_id=user_id) + ) class LLMConfigModelNotSetError(ValueError): @@ -18,8 +23,10 @@ class LLMConfigInactiveError(ValueError): def __init__(self) -> None: super().__init__( - "The selected AI configuration is deactivated. " - "Go to AI Keys to reactivate it, or choose a different configuration in the chat settings." + _( + "The selected AI configuration is deactivated. " + "Go to AI Keys to reactivate it, or choose a different configuration in the chat settings." + ) ) @@ -27,4 +34,4 @@ class LLMConfigDuplicateNameError(ValueError): """Raised when an LLM config with the same name already exists for the user.""" def __init__(self, name: str) -> None: - super().__init__(f"An LLM config with name '{name}' already exists for this user.") + super().__init__(_("An LLM config with name '{name}' already exists for this user.").format(name=name)) diff --git a/sparkth/llm/service.py b/sparkth/llm/service.py index 26f1129c..26872d08 100644 --- a/sparkth/llm/service.py +++ b/sparkth/llm/service.py @@ -7,6 +7,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession from sparkth.core.models.llm import LLMConfig +from sparkth.lib.i18n import _ from sparkth.lib.log import get_logger from sparkth.llm.exceptions import ( LLMConfigDuplicateNameError, @@ -123,7 +124,9 @@ async def update( allowed = get_models_for_provider(config.provider) if model not in allowed: raise LLMConfigValidationError( - f"Model '{model}' not available for provider '{config.provider}'. Allowed: {', '.join(allowed)}" + _("Model '{model}' not available for provider '{provider}'. Allowed: {allowed}").format( + model=model, provider=config.provider, allowed=", ".join(allowed) + ) ) config.model = model config.update_timestamp() diff --git a/sparkth/services/email_verification.py b/sparkth/services/email_verification.py index f167ba2e..53b4ec83 100644 --- a/sparkth/services/email_verification.py +++ b/sparkth/services/email_verification.py @@ -11,6 +11,7 @@ from sparkth.core.email import send_email from sparkth.core.models.email_verification import EmailVerificationToken from sparkth.core.models.user import User +from sparkth.lib.i18n import _ from sparkth.lib.log import get_logger settings = get_settings() @@ -118,19 +119,18 @@ def _build_verify_url(raw_token: str) -> str: def _render_email(name: str, raw_token: str) -> tuple[str, str]: url = _build_verify_url(raw_token) ttl = settings.EMAIL_VERIFICATION_TOKEN_TTL_HOURS - text = ( - f"Hi {name},\n\n" - "Click the link below to confirm your email address:\n\n" - f"{url}\n\n" - f"This link expires in {ttl} hours.\n\n" - "If you didn't sign up for Sparkth, you can ignore this email.\n" - ) + greeting = _("Hi {name},").format(name=name) + instruction = _("Click the link below to confirm your email address:") + expiry = _("This link expires in {ttl} hours.").format(ttl=ttl) + footer = _("If you didn't sign up for Sparkth, you can ignore this email.") + link_label = _("Confirm my email") + text = f"{greeting}\n\n{instruction}\n\n{url}\n\n{expiry}\n\n{footer}\n" html = ( - f"

Hi {html_escape(name)},

" - "

Click the link below to confirm your email address:

" - f'

Confirm my email

' - f"

This link expires in {ttl} hours.

" - "

If you didn't sign up for Sparkth, you can ignore this email.

" + f"

{html_escape(greeting)}

" + f"

{html_escape(instruction)}

" + f'

{html_escape(link_label)}

' + f"

{html_escape(expiry)}

" + f"

{html_escape(footer)}

" ) return text, html @@ -143,7 +143,7 @@ async def send_verification_email(*, to: str, name: str, raw_token: str) -> None try: await send_email( to=to, - subject="Confirm your Sparkth account", + subject=_("Confirm your Sparkth account"), html_body=html_body, text_body=text_body, ) diff --git a/sparkth/services/whitelist/service.py b/sparkth/services/whitelist/service.py index 270c4c72..b8b02966 100644 --- a/sparkth/services/whitelist/service.py +++ b/sparkth/services/whitelist/service.py @@ -5,6 +5,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession from sparkth.core.models.whitelist import WhitelistedEmail +from sparkth.lib.i18n import _ from sparkth.lib.log import get_logger from sparkth.services.whitelist.exceptions import ( InvalidWhitelistValue, @@ -39,13 +40,13 @@ async def add_entry( or domain_part.endswith(".") or ".." in domain_part ): - raise InvalidWhitelistValue(f"Invalid domain format: {value}") + raise InvalidWhitelistValue(_("Invalid domain format: {value}").format(value=value)) entry_type = "domain" else: try: _EmailValidator(email=normalized) except ValidationError as exc: - raise InvalidWhitelistValue(f"Invalid email format: {value}") from exc + raise InvalidWhitelistValue(_("Invalid email format: {value}").format(value=value)) from exc entry_type = "email" # No pre-check SELECT: the unique index on ``value`` is the single, atomic guard @@ -68,7 +69,7 @@ async def add_entry( existing = await session.exec(select(WhitelistedEmail).where(WhitelistedEmail.value == normalized)) if existing.one_or_none() is not None: logger.warning("Whitelist insert conflict for value %s: %s", normalized, exc) - raise WhitelistEntryAlreadyExists(f"Entry already exists: {normalized}") from exc + raise WhitelistEntryAlreadyExists(_("Entry already exists: {value}").format(value=normalized)) from exc logger.exception("Unexpected integrity error inserting whitelist value %s", normalized) raise await session.refresh(entry) @@ -78,7 +79,7 @@ async def add_entry( async def remove_entry(session: AsyncSession, *, entry_id: int) -> None: entry = await session.get(WhitelistedEmail, entry_id) if entry is None: - raise WhitelistEntryNotFound(f"Whitelist entry not found: {entry_id}") + raise WhitelistEntryNotFound(_("Whitelist entry not found: {entry_id}").format(entry_id=entry_id)) await session.delete(entry) await session.commit() diff --git a/tests/api/v1/test_auth.py b/tests/api/v1/test_auth.py index 0f7fa14b..94ec85b0 100644 --- a/tests/api/v1/test_auth.py +++ b/tests/api/v1/test_auth.py @@ -7,6 +7,7 @@ from sparkth.core.models.user import User from sparkth.core.security import get_password_hash +from sparkth.lib.testing import AddTranslation from sparkth.services.whitelist import WhitelistService @@ -156,6 +157,20 @@ async def test_login_non_existent_user(client: AsyncClient) -> None: assert response.json() == {"detail": "Incorrect username or password"} +async def test_login_failure_detail_follows_the_request_locale( + client: AsyncClient, translation_catalog: AddTranslation +) -> None: + translation_catalog("Incorrect username or password", "Usuario o contraseña incorrectos") + + response = await client.post( + "/api/v1/auth/login", + json={"username": _uniq("nonexistent"), "password": "Sup3rSecret!"}, + headers={"Accept-Language": "es"}, + ) + assert response.status_code == 401 + assert response.json() == {"detail": "Usuario o contraseña incorrectos"} + + class TestPasswordComplexity: """Server-side enforcement of registration password rules.""" diff --git a/tests/api/v1/test_list_user_plugins.py b/tests/api/v1/test_list_user_plugins.py index 865df358..5136f4b1 100644 --- a/tests/api/v1/test_list_user_plugins.py +++ b/tests/api/v1/test_list_user_plugins.py @@ -17,6 +17,7 @@ SidebarEntry, ) from sparkth.lib.plugins import SparkthPlugin +from sparkth.lib.testing import AddTranslation def _plugin_entry(plugin_name: str, enabled: bool, config: dict[str, str], is_core: bool) -> dict[str, object]: @@ -72,6 +73,31 @@ async def test_list_user_plugins_carries_declared_frontend_metadata( assert entry["has_frontend"] is True +async def test_list_user_plugins_translates_frontend_metadata( + client: AsyncClient, current_user: User, session: AsyncSession, translation_catalog: AddTranslation +) -> None: + session.add(Plugin(name="translated", is_core=True, enabled=True)) + await session.commit() + + plugin = SparkthPlugin("translated") + DISPLAY_INFO.add_item(plugin, DisplayInfo("With Frontend", "A plugin that ships a page", icon="sparkles")) + SIDEBAR_ENTRIES.add_item(plugin, SidebarEntry("With Frontend", icon="sparkles", order=2)) + + translation_catalog("With Frontend", "Con interfaz") + translation_catalog("A plugin that ships a page", "Un plugin que incluye una página") + + response = await client.get("/api/v1/user-plugins/", headers={"Accept-Language": "es"}) + assert response.status_code == 200 + + entry = next(item for item in response.json() if item["plugin_name"] == "translated") + assert entry["display"] == { + "display_name": "Con interfaz", + "description": "Un plugin que incluye una página", + "icon": "sparkles", + } + assert entry["sidebar"] == {"label": "Con interfaz", "icon": "sparkles", "order": 2} + + async def test_list_user_plugins_empty(client: AsyncClient, session: AsyncSession) -> None: user = User(name="Test User", username="noplugins", email="empty@example.com", hashed_password="fakehashedpassword") session.add(user) diff --git a/tests/permissions/test_exception_messages.py b/tests/permissions/test_exception_messages.py new file mode 100644 index 00000000..40d50f98 --- /dev/null +++ b/tests/permissions/test_exception_messages.py @@ -0,0 +1,26 @@ +"""Permission domain-exception messages are translated into the active locale. + +The exceptions are raised during request handling, so the message template is +translated at raise time; all classes share the same marking pattern, exercised +here through one formatted and one already-covered plain representative. +""" + +from sparkth.core.i18n import locale_context +from sparkth.core.permissions.exceptions import RoleAlreadyExists, RoleNotFound +from sparkth.lib.testing import AddTranslation + + +def test_role_not_found_translates_the_message_template(translation_catalog: AddTranslation) -> None: + translation_catalog("Role not found: {role_name}", "Rol no encontrado: {role_name}") + with locale_context("es"): + assert str(RoleNotFound("editor")) == "Rol no encontrado: editor" + + +def test_role_already_exists_translates_the_message_template(translation_catalog: AddTranslation) -> None: + translation_catalog("Role already exists: {name}", "El rol ya existe: {name}") + with locale_context("es"): + assert str(RoleAlreadyExists("editor")) == "El rol ya existe: editor" + + +def test_messages_fall_back_to_english_outside_a_request_locale() -> None: + assert str(RoleNotFound("editor")) == "Role not found: editor" diff --git a/tests/services/test_email_verification.py b/tests/services/test_email_verification.py index 5c40a05f..ef2fae03 100644 --- a/tests/services/test_email_verification.py +++ b/tests/services/test_email_verification.py @@ -8,8 +8,10 @@ from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession +from sparkth.core.i18n import locale_context from sparkth.core.models.email_verification import EmailVerificationToken from sparkth.core.models.user import User +from sparkth.lib.testing import AddTranslation from sparkth.services import email_verification as svc from sparkth.services.email_verification import ( EmailVerificationService, @@ -154,6 +156,24 @@ async def test_calls_send_email_with_link_and_name(self, monkeypatch: pytest.Mon assert "https://app.test/verify-email/?token=abc123" in kwargs["html_body"] assert "24" in kwargs["text_body"] + async def test_email_content_follows_the_active_locale( + self, monkeypatch: pytest.MonkeyPatch, translation_catalog: AddTranslation + ) -> None: + monkeypatch.setattr(svc.settings, "FRONTEND_BASE_URL", "https://app.test") + mock = AsyncMock() + monkeypatch.setattr(svc, "send_email", mock) + translation_catalog("Confirm your Sparkth account", "Confirma tu cuenta de Sparkth") + translation_catalog("Click the link below to confirm your email address:", "Haz clic para confirmar tu correo:") + + with locale_context("es"): + await svc.send_verification_email(to="alice@example.com", name="Alice", raw_token="abc123") + + assert mock.await_args is not None + kwargs = mock.await_args.kwargs + assert kwargs["subject"] == "Confirma tu cuenta de Sparkth" + assert "Haz clic para confirmar tu correo:" in kwargs["text_body"] + assert "Haz clic para confirmar tu correo:" in kwargs["html_body"] + async def test_strips_trailing_slash_in_frontend_base_url(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(svc.settings, "FRONTEND_BASE_URL", "https://app.test/") mock = AsyncMock()