mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 11:04:27 +08:00
refactor: pass notification language explicitly (#41770)
This commit is contained in:
parent
df0460f876
commit
8e2058a962
@ -2,7 +2,7 @@ from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import model_validate
|
||||
@ -17,6 +17,10 @@ class DismissNotificationPayload(BaseModel):
|
||||
notification_id: str = Field(...)
|
||||
|
||||
|
||||
class NotificationQuery(BaseModel):
|
||||
language: str = Field(default="en-US", description="Notification language")
|
||||
|
||||
|
||||
class NotificationItemResponse(ResponseModel):
|
||||
notification_id: str | None = None
|
||||
frequency: str | None = None
|
||||
@ -32,17 +36,19 @@ class NotificationResponse(ResponseModel):
|
||||
notifications: list[NotificationItemResponse]
|
||||
|
||||
|
||||
register_schema_models(console_ns, DismissNotificationPayload)
|
||||
register_schema_models(console_ns, DismissNotificationPayload, NotificationQuery)
|
||||
register_response_schema_models(console_ns, SimpleResultResponse, NotificationResponse)
|
||||
|
||||
|
||||
@console_ns.route("/notification")
|
||||
class NotificationApi(Resource):
|
||||
@console_ns.doc("get_notification")
|
||||
@console_ns.doc(params=query_params_from_model(NotificationQuery))
|
||||
@console_ns.doc(
|
||||
description=(
|
||||
"Return the active in-product notification for the current user "
|
||||
"in their interface language (falls back to English if unavailable). "
|
||||
"in the requested language (defaults to English when omitted). "
|
||||
"Unavailable translations fall back to English, then the first available content. "
|
||||
"The notification is NOT marked as seen here; call POST /notification/dismiss "
|
||||
"when the user explicitly closes the modal."
|
||||
),
|
||||
@ -53,8 +59,9 @@ class NotificationApi(Resource):
|
||||
)
|
||||
@console_ns.response(200, "Success", console_ns.models[NotificationResponse.__name__])
|
||||
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
|
||||
def get(self, request_context: RequestContext):
|
||||
result = application_services().notifications.get_active(request_context)
|
||||
@model_validate(NotificationQuery)
|
||||
def get(self, query: NotificationQuery, request_context: RequestContext):
|
||||
result = application_services().notifications.get_active(request_context, query.language)
|
||||
return dump_response(NotificationResponse, result), 200
|
||||
|
||||
|
||||
|
||||
@ -582,7 +582,6 @@ def build_application_services(
|
||||
expected_password=initialization_password,
|
||||
),
|
||||
notifications=NotificationService(
|
||||
accounts=accounts,
|
||||
notifications=BillingNotificationGateway(),
|
||||
),
|
||||
step_by_step_tour=StepByStepTourService(
|
||||
|
||||
@ -7196,7 +7196,13 @@ Get instruction generation template
|
||||
| 302 | Redirect to OAuth callback page |
|
||||
|
||||
### [GET] /notification
|
||||
Return the active in-product notification for the current user in their interface language (falls back to English if unavailable). The notification is NOT marked as seen here; call POST /notification/dismiss when the user explicitly closes the modal.
|
||||
Return the active in-product notification for the current user in the requested language (defaults to English when omitted). Unavailable translations fall back to English, then the first available content. The notification is NOT marked as seen here; call POST /notification/dismiss when the user explicitly closes the modal.
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| language | query | Notification language | No | string, <br>**Default:** en-US |
|
||||
|
||||
#### Responses
|
||||
|
||||
@ -19670,6 +19676,12 @@ Coarse node-level status used by Inspector to pick a banner.
|
||||
| title | string | | Yes |
|
||||
| title_pic_url | string | | Yes |
|
||||
|
||||
#### NotificationQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| language | string, <br>**Default:** en-US | Notification language | No |
|
||||
|
||||
#### NotificationResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from constants.languages import languages
|
||||
from machinery.context import RequestContext
|
||||
from services.account_ports import AccountRepository
|
||||
from services.entities.notification_entities import (
|
||||
AccountNotification,
|
||||
AccountNotificationBatch,
|
||||
@ -22,20 +22,15 @@ class NotificationGateway(Protocol):
|
||||
|
||||
|
||||
class NotificationService:
|
||||
def __init__(self, *, accounts: AccountRepository, notifications: NotificationGateway) -> None:
|
||||
self._accounts = accounts
|
||||
def __init__(self, *, notifications: NotificationGateway) -> None:
|
||||
self._notifications = notifications
|
||||
|
||||
def get_active(self, context: RequestContext) -> NotificationResult:
|
||||
def get_active(self, context: RequestContext, language: str) -> NotificationResult:
|
||||
batch = self._notifications.get_active(context.account_id)
|
||||
if not batch.should_show:
|
||||
return NotificationResult(should_show=False, notifications=())
|
||||
|
||||
account = self._accounts.get(context.account_id)
|
||||
if account is None:
|
||||
raise RuntimeError("Console account admission resolved an unknown account")
|
||||
language = account.interface_language or _FALLBACK_LANGUAGE
|
||||
|
||||
language = language if language in languages else _FALLBACK_LANGUAGE
|
||||
notifications = tuple(self._localize(notification, language) for notification in batch.notifications)
|
||||
return NotificationResult(should_show=bool(notifications), notifications=notifications)
|
||||
|
||||
|
||||
@ -2,6 +2,9 @@ from inspect import unwrap
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.console.notification import (
|
||||
DismissNotificationPayload,
|
||||
NotificationApi,
|
||||
@ -20,7 +23,15 @@ def _request_context() -> RequestContext:
|
||||
)
|
||||
|
||||
|
||||
def test_get_notification_delegates_and_serializes_result() -> None:
|
||||
@pytest.mark.parametrize(
|
||||
("query_string", "expected_language"),
|
||||
[({}, "en-US"), ({"language": "zh-Hans"}, "zh-Hans")],
|
||||
)
|
||||
def test_get_notification_validates_language_query_and_serializes_result(
|
||||
app: Flask,
|
||||
query_string: dict[str, str],
|
||||
expected_language: str,
|
||||
) -> None:
|
||||
service = Mock()
|
||||
service.get_active.return_value = NotificationResult(
|
||||
should_show=True,
|
||||
@ -38,10 +49,13 @@ def test_get_notification_delegates_and_serializes_result() -> None:
|
||||
)
|
||||
services = SimpleNamespace(notifications=service)
|
||||
api = NotificationApi()
|
||||
method = unwrap(api.get)
|
||||
method = api.get.__wrapped__
|
||||
context = _request_context()
|
||||
|
||||
with patch("controllers.console.notification.application_services", return_value=services):
|
||||
with (
|
||||
app.test_request_context("/notification", query_string=query_string),
|
||||
patch("controllers.console.notification.application_services", return_value=services),
|
||||
):
|
||||
result, status = method(api, context)
|
||||
|
||||
assert status == 200
|
||||
@ -59,7 +73,7 @@ def test_get_notification_delegates_and_serializes_result() -> None:
|
||||
}
|
||||
],
|
||||
}
|
||||
service.get_active.assert_called_once_with(context)
|
||||
service.get_active.assert_called_once_with(context, expected_language)
|
||||
|
||||
|
||||
def test_dismiss_notification_delegates_with_stable_account_context() -> None:
|
||||
|
||||
@ -434,7 +434,6 @@ def test_build_application_services_wires_account_profile_repository(
|
||||
assert services.accounts.deletion._accounts is accounts
|
||||
assert services.accounts.authentication._accounts is accounts
|
||||
assert services.accounts.authentication._workspaces is services.workspace_queries._workspaces
|
||||
assert services.notifications._accounts is accounts
|
||||
assert services.step_by_step_tour._accounts is accounts
|
||||
assert services.accounts.deletion._memberships is services.workspace_queries._workspaces
|
||||
integrations = services.accounts.integrations._integrations
|
||||
|
||||
@ -1,11 +1,4 @@
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from machinery.context import RequestContext
|
||||
from services.account_ports import AccountRepository
|
||||
from services.entities.account_entities import AccountSnapshot
|
||||
from services.entities.notification_entities import (
|
||||
AccountNotification,
|
||||
AccountNotificationBatch,
|
||||
@ -39,30 +32,6 @@ class NotificationGatewayStub:
|
||||
self.dismissals.append((notification_id, account_id))
|
||||
|
||||
|
||||
def _account(language: str | None = "zh-Hans") -> AccountSnapshot:
|
||||
return AccountSnapshot(
|
||||
id="account-1",
|
||||
name="Account",
|
||||
email="account@example.com",
|
||||
avatar=None,
|
||||
is_password_set=False,
|
||||
interface_language=language,
|
||||
interface_theme="light",
|
||||
timezone="UTC",
|
||||
last_login_at=None,
|
||||
last_login_ip=None,
|
||||
status="active",
|
||||
initialized_at=None,
|
||||
created_at=datetime(2026, 1, 1),
|
||||
)
|
||||
|
||||
|
||||
def _accounts(account: AccountSnapshot | None) -> Mock:
|
||||
accounts = Mock(spec=AccountRepository)
|
||||
accounts.get.return_value = account
|
||||
return accounts
|
||||
|
||||
|
||||
def _notification(contents: dict[str, NotificationContent]) -> AccountNotification:
|
||||
return AccountNotification(
|
||||
notification_id="notification-1",
|
||||
@ -71,19 +40,18 @@ def _notification(contents: dict[str, NotificationContent]) -> AccountNotificati
|
||||
)
|
||||
|
||||
|
||||
def test_get_active_localizes_notification_for_account_language() -> None:
|
||||
def test_get_active_localizes_notification_for_requested_language() -> None:
|
||||
chinese = NotificationContent("zh-Hans", "标题", "副标题", "正文", "zh.png")
|
||||
english = NotificationContent("en-US", "Title", "Subtitle", "Body", "en.png")
|
||||
gateway = NotificationGatewayStub(
|
||||
AccountNotificationBatch(True, (_notification({"zh-Hans": chinese, "en-US": english}),))
|
||||
)
|
||||
service = NotificationService(accounts=_accounts(_account()), notifications=gateway)
|
||||
service = NotificationService(notifications=gateway)
|
||||
|
||||
result = service.get_active(_context())
|
||||
result = service.get_active(_context(), "zh-Hans")
|
||||
|
||||
assert result == NotificationResult(
|
||||
should_show=True,
|
||||
notifications=(NotificationItem("notification-1", "once", "zh-Hans", "标题", "副标题", "正文", "zh.png"),),
|
||||
assert result.notifications == (
|
||||
NotificationItem("notification-1", "once", "zh-Hans", "标题", "副标题", "正文", "zh.png"),
|
||||
)
|
||||
assert gateway.get_account_ids == ["account-1"]
|
||||
|
||||
@ -91,47 +59,48 @@ def test_get_active_localizes_notification_for_account_language() -> None:
|
||||
def test_get_active_falls_back_to_english() -> None:
|
||||
english = NotificationContent("en-US", "Title", "Subtitle", "Body", "en.png")
|
||||
gateway = NotificationGatewayStub(AccountNotificationBatch(True, (_notification({"en-US": english}),)))
|
||||
service = NotificationService(accounts=_accounts(_account("fr-FR")), notifications=gateway)
|
||||
service = NotificationService(notifications=gateway)
|
||||
|
||||
result = service.get_active(_context())
|
||||
result = service.get_active(_context(), "fr-FR")
|
||||
|
||||
assert result.notifications[0].lang == "en-US"
|
||||
assert result.notifications[0].title == "Title"
|
||||
|
||||
|
||||
def test_get_active_skips_account_query_when_gateway_says_not_to_show() -> None:
|
||||
accounts = _accounts(None)
|
||||
service = NotificationService(
|
||||
accounts=accounts,
|
||||
notifications=NotificationGatewayStub(AccountNotificationBatch(False, ())),
|
||||
def test_get_active_falls_back_to_english_for_unsupported_language() -> None:
|
||||
unsupported = NotificationContent("xx-YY", "Unknown", "Unknown", "Unknown", "unknown.png")
|
||||
english = NotificationContent("en-US", "Title", "Subtitle", "Body", "en.png")
|
||||
gateway = NotificationGatewayStub(
|
||||
AccountNotificationBatch(True, (_notification({"xx-YY": unsupported, "en-US": english}),))
|
||||
)
|
||||
service = NotificationService(notifications=gateway)
|
||||
|
||||
result = service.get_active(_context())
|
||||
result = service.get_active(_context(), "xx-YY")
|
||||
|
||||
assert result.notifications[0].lang == "en-US"
|
||||
assert result.notifications[0].title == "Title"
|
||||
|
||||
|
||||
def test_get_active_returns_empty_when_gateway_says_not_to_show() -> None:
|
||||
service = NotificationService(notifications=NotificationGatewayStub(AccountNotificationBatch(False, ())))
|
||||
|
||||
result = service.get_active(_context(), "zh-Hans")
|
||||
|
||||
assert result == NotificationResult(False, ())
|
||||
accounts.get.assert_not_called()
|
||||
|
||||
|
||||
def test_get_active_uses_empty_content_when_notification_has_no_translations() -> None:
|
||||
gateway = NotificationGatewayStub(AccountNotificationBatch(True, (_notification({}),)))
|
||||
service = NotificationService(accounts=_accounts(_account(None)), notifications=gateway)
|
||||
service = NotificationService(notifications=gateway)
|
||||
|
||||
result = service.get_active(_context())
|
||||
result = service.get_active(_context(), "")
|
||||
|
||||
assert result.notifications == (NotificationItem("notification-1", "once", "en-US", "", "", "", ""),)
|
||||
|
||||
|
||||
def test_get_active_rejects_unknown_admitted_account() -> None:
|
||||
gateway = NotificationGatewayStub(AccountNotificationBatch(True, (_notification({}),)))
|
||||
service = NotificationService(accounts=_accounts(None), notifications=gateway)
|
||||
|
||||
with pytest.raises(RuntimeError, match="unknown account"):
|
||||
service.get_active(_context())
|
||||
|
||||
|
||||
def test_dismiss_delegates_identifiers_to_gateway() -> None:
|
||||
gateway = NotificationGatewayStub(AccountNotificationBatch(False, ()))
|
||||
service = NotificationService(accounts=_accounts(_account()), notifications=gateway)
|
||||
service = NotificationService(notifications=gateway)
|
||||
|
||||
service.dismiss(_context(), "notification-1")
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { oc } from '@orpc/contract'
|
||||
import * as z from 'zod'
|
||||
import {
|
||||
zGetNotificationQuery,
|
||||
zGetNotificationResponse,
|
||||
zPostNotificationDismissBody,
|
||||
zPostNotificationDismissResponse,
|
||||
@ -28,18 +29,19 @@ export const dismiss = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the active in-product notification for the current user in their interface language (falls back to English if unavailable). The notification is NOT marked as seen here; call POST /notification/dismiss when the user explicitly closes the modal.
|
||||
* Return the active in-product notification for the current user in the requested language (defaults to English when omitted). Unavailable translations fall back to English, then the first available content. The notification is NOT marked as seen here; call POST /notification/dismiss when the user explicitly closes the modal.
|
||||
*/
|
||||
export const get = oc
|
||||
.route({
|
||||
description:
|
||||
'Return the active in-product notification for the current user in their interface language (falls back to English if unavailable). The notification is NOT marked as seen here; call POST /notification/dismiss when the user explicitly closes the modal.',
|
||||
'Return the active in-product notification for the current user in the requested language (defaults to English when omitted). Unavailable translations fall back to English, then the first available content. The notification is NOT marked as seen here; call POST /notification/dismiss when the user explicitly closes the modal.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getNotification',
|
||||
path: '/notification',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ query: zGetNotificationQuery.optional() }))
|
||||
.output(zGetNotificationResponse)
|
||||
|
||||
export const notification = {
|
||||
|
||||
@ -30,7 +30,9 @@ export type NotificationItemResponse = {
|
||||
export type GetNotificationData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
query?: {
|
||||
language?: string
|
||||
}
|
||||
url: '/notification'
|
||||
}
|
||||
|
||||
|
||||
@ -37,6 +37,10 @@ export const zNotificationResponse = z.object({
|
||||
should_show: z.boolean(),
|
||||
})
|
||||
|
||||
export const zGetNotificationQuery = z.object({
|
||||
language: z.string().optional().default('en-US'),
|
||||
})
|
||||
|
||||
/**
|
||||
* Success — inspect should_show to decide whether to render the modal
|
||||
*/
|
||||
|
||||
@ -3,14 +3,19 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import InSiteMessageNotification from '../notification'
|
||||
|
||||
const { mockEdition, mockNotification, mockNotificationDismiss } = vi.hoisted(() => ({
|
||||
const { mockEdition, mockLocale, mockNotification, mockNotificationDismiss } = vi.hoisted(() => ({
|
||||
mockEdition: {
|
||||
value: 'CLOUD' as 'COMMUNITY' | 'ENTERPRISE' | 'CLOUD' | null,
|
||||
},
|
||||
mockLocale: { value: 'en-US' },
|
||||
mockNotification: vi.fn(),
|
||||
mockNotificationDismiss: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => mockLocale.value,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
systemFeatures: {
|
||||
@ -25,9 +30,9 @@ vi.mock('@/service/client', () => ({
|
||||
},
|
||||
notification: {
|
||||
get: {
|
||||
queryOptions: (options?: Record<string, unknown>) => ({
|
||||
queryKey: ['console', 'notification', 'get'],
|
||||
queryFn: (...args: unknown[]) => mockNotification(...args),
|
||||
queryOptions: (options?: { enabled?: boolean; input?: unknown }) => ({
|
||||
queryKey: ['console', 'notification', 'get', options?.input],
|
||||
queryFn: () => mockNotification(options?.input),
|
||||
...options,
|
||||
}),
|
||||
},
|
||||
@ -70,6 +75,7 @@ describe('InSiteMessageNotification', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEdition.value = 'CLOUD'
|
||||
mockLocale.value = 'en-US'
|
||||
vi.stubGlobal('open', vi.fn())
|
||||
})
|
||||
|
||||
@ -96,10 +102,28 @@ describe('InSiteMessageNotification', () => {
|
||||
const { container } = render(<InSiteMessageNotification />, { wrapper: Wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotification).toHaveBeenCalledTimes(1)
|
||||
expect(mockNotification).toHaveBeenCalledWith({ query: { language: 'en-US' } })
|
||||
})
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should refetch notification when the interface language changes', async () => {
|
||||
mockNotification.mockResolvedValue({ notifications: [] })
|
||||
const Wrapper = createWrapper()
|
||||
const { rerender } = render(<InSiteMessageNotification />, { wrapper: Wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotification).toHaveBeenCalledWith({ query: { language: 'en-US' } })
|
||||
})
|
||||
|
||||
mockLocale.value = 'zh-Hans'
|
||||
rerender(<InSiteMessageNotification />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotification).toHaveBeenCalledWith({ query: { language: 'zh-Hans' } })
|
||||
})
|
||||
expect(mockNotification).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
// Validate parsed-body behavior and action handling.
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import type { InSiteMessageActionItem } from './index'
|
||||
import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import InSiteMessage from './index'
|
||||
@ -56,6 +57,7 @@ function parseNotificationBody(body: string): NotificationBodyPayload | null {
|
||||
|
||||
function InSiteMessageNotification() {
|
||||
const { t } = useTranslation()
|
||||
const locale = useLocale()
|
||||
const { data: deploymentEdition } = useSuspenseQuery({
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: ({ deployment_edition }) => deployment_edition,
|
||||
@ -67,6 +69,7 @@ function InSiteMessageNotification() {
|
||||
|
||||
const { data } = useQuery(
|
||||
consoleQuery.notification.get.queryOptions({
|
||||
input: { query: { language: locale } },
|
||||
enabled: isCloudEdition,
|
||||
}),
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user