diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 5450752..3b18269 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -1,4 +1,4 @@ -"""API глобальных настроек (ТЗ: валюта бюджетов — общая, выбирается один раз).""" +"""API глобальных настроек (ТЗ: валюта бюджетов и язык UI — общие, выбираются один раз).""" from typing import cast @@ -6,28 +6,49 @@ from app.dependencies import DbDep, UserDep from app.models import AppSetting -from app.schemas import CURRENCIES, AppSettingsOut, AppSettingsUpdate +from app.schemas import CURRENCIES, LANGUAGES, AppSettingsOut, AppSettingsUpdate router = APIRouter(prefix="/api/settings", tags=["settings"]) CURRENCY_KEY = "currency" +LANGUAGE_KEY = "language" DEFAULT_CURRENCY = "UAH" +DEFAULT_LANGUAGE = "" # '' = нет переопределения, следуем SSO locale + + +def _read_value(db: DbDep, key: str, valid: tuple[str, ...], default: str) -> str: + row = cast(AppSetting | None, db.get(AppSetting, key)) + if row is not None and row.value in valid: + return row.value + return default + + +def _upsert(db: DbDep, key: str, value: str) -> None: + row = cast(AppSetting | None, db.get(AppSetting, key)) + if row is None: + db.add(AppSetting(key=key, value=value)) + else: + row.value = value + db.flush() @router.get("", response_model=AppSettingsOut) async def get_settings(db: DbDep, user: UserDep) -> AppSettingsOut: - row = cast(AppSetting | None, db.get(AppSetting, CURRENCY_KEY)) - currency = row.value if row is not None and row.value in CURRENCIES else DEFAULT_CURRENCY - return AppSettingsOut(currency=currency) + return AppSettingsOut( + currency=_read_value(db, CURRENCY_KEY, CURRENCIES, DEFAULT_CURRENCY), + language=_read_value(db, LANGUAGE_KEY, LANGUAGES + ("",), DEFAULT_LANGUAGE), + ) @router.put("", response_model=AppSettingsOut) async def update_settings(schema: AppSettingsUpdate, db: DbDep, user: UserDep) -> AppSettingsOut: - row = cast(AppSetting | None, db.get(AppSetting, CURRENCY_KEY)) - if row is None: - row = AppSetting(key=CURRENCY_KEY, value=schema.currency) - db.add(row) - else: - row.value = schema.currency + # Частичный PUT: обновляем только переданные поля (model_fields_set) + if schema.currency is not None: + _upsert(db, CURRENCY_KEY, schema.currency) + if schema.language is not None: + _upsert(db, LANGUAGE_KEY, schema.language) db.flush() - return AppSettingsOut(currency=schema.currency) + return AppSettingsOut( + currency=_read_value(db, CURRENCY_KEY, CURRENCIES, DEFAULT_CURRENCY), + language=_read_value(db, LANGUAGE_KEY, LANGUAGES + ("",), DEFAULT_LANGUAGE), + ) diff --git a/backend/app/auth/routes.py b/backend/app/auth/routes.py index ce23f9b..509158b 100644 --- a/backend/app/auth/routes.py +++ b/backend/app/auth/routes.py @@ -46,6 +46,8 @@ "user_id": user.user_id, "email": user.email, "avatar_url": user.avatar_url, + # Язык UI: SSO отдаёт profile.locale (default 'ru'); '' в поле — нет значения + "locale": user.profile.get("locale") or "", } return_to = request.session.pop("return_to", "/") diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 8021158..c2836d0 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -135,11 +135,16 @@ CURRENCIES = ("UAH", "USD", "EUR", "GBP", "PLN") +# Языки UI: пустое language = нет переопределения (следуем SSO locale) +LANGUAGES = ("ru", "en", "uk") class AppSettingsOut(BaseModel): currency: str = "UAH" + language: str = "" class AppSettingsUpdate(BaseModel): - currency: str = Field(pattern="^(UAH|USD|EUR|GBP|PLN)$") + # Частичный PUT: None = поле не передано, не затираем + currency: str | None = Field(None, pattern="^(UAH|USD|EUR|GBP|PLN)$") + language: str | None = Field(None, pattern="^(|ru|en|uk)$") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a808b7d..3726c32 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -49,7 +49,7 @@ app.dependency_overrides[get_db] = _override_get_db -AUTH_USER: dict[str, str] = {"user_id": "1", "email": "test@example.com"} +AUTH_USER: dict[str, str] = {"user_id": "1", "email": "test@example.com", "locale": "ru"} app.dependency_overrides[require_user] = lambda: AUTH_USER diff --git a/backend/tests/test_predict.py b/backend/tests/test_predict.py index 394286d..8a19e22 100644 --- a/backend/tests/test_predict.py +++ b/backend/tests/test_predict.py @@ -112,10 +112,8 @@ def test_global_currency_settings(client: TestClient) -> None: - # по умолчанию UAH - assert client.get("/api/settings").json() == {"currency": "UAH"} - # смена валюты - assert client.put("/api/settings", json={"currency": "EUR"}).json() == {"currency": "EUR"} - assert client.get("/api/settings").json() == {"currency": "EUR"} + # смена валюты частичным PUT + assert client.put("/api/settings", json={"currency": "EUR"}).json()["currency"] == "EUR" + assert client.get("/api/settings").json()["currency"] == "EUR" # недопустимая валюта — 422 assert client.put("/api/settings", json={"currency": "RUB"}).status_code == 422 diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py new file mode 100644 index 0000000..003e5d7 --- /dev/null +++ b/backend/tests/test_settings.py @@ -0,0 +1,34 @@ +"""Тесты глобальных настроек: валюта и язык UI.""" + +from fastapi.testclient import TestClient + + +def test_default_settings(client: TestClient) -> None: + # по умолчанию UAH, язык не переопределён (следует SSO locale) + assert client.get("/api/settings").json() == {"currency": "UAH", "language": ""} + + +def test_update_currency_and_language(client: TestClient) -> None: + body = client.put("/api/settings", json={"currency": "EUR", "language": "en"}).json() + assert body == {"currency": "EUR", "language": "en"} + assert client.get("/api/settings").json() == {"currency": "EUR", "language": "en"} + + +def test_partial_put_keeps_other_fields(client: TestClient) -> None: + client.put("/api/settings", json={"currency": "USD", "language": "uk"}) + # PUT только языка не затирает валюту + body = client.put("/api/settings", json={"language": "ru"}).json() + assert body == {"currency": "USD", "language": "ru"} + # PUT только валюты не затирает язык + body = client.put("/api/settings", json={"currency": "GBP"}).json() + assert body == {"currency": "GBP", "language": "ru"} + + +def test_clear_language_override(client: TestClient) -> None: + # '' сбрасывает переопределение → снова «как в аккаунте» + client.put("/api/settings", json={"language": "en"}) + assert client.put("/api/settings", json={"language": ""}).json()["language"] == "" + + +def test_invalid_language_422(client: TestClient) -> None: + assert client.put("/api/settings", json={"language": "xx"}).status_code == 422 diff --git a/docs/TZ.md b/docs/TZ.md index c8de2fa..3d09f96 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.4 (черновик) | +| Версия ТЗ | 0.5 (черновик) | | Дата | 2026-09-19 | | Статус | На обсуждении | @@ -139,6 +139,15 @@ - Все вызовы LLM — на собственном сервере, данные не покидают VPS. - Спекулярные LLM-сценарии вне метаданных — не в скоупе: большинство ИИ-задач выполняют внешние агенты через MCP (3.9). +### 3.11. Мультиязычность + +- UI поддерживает три языка: **ru** (базовый/fallback), **en**, **uk**. +- Язык по умолчанию берётся из gnexus auth (`profile.locale` из SSO-профиля, scope `profile`). +- В «Настройках» возможно ручное переопределение языка; значение хранится в глобальных + настройках приложения (`app_settings`, ключ `language`), пустое значение = следовать SSO. +- Даты, суммы и названия дней недели форматируются через Intl по активной локали. +- Переводится только собственный UI: gnexus-ui-kit текстов не содержит (тексты через props). + ## 4. Нефункциональные требования | Требование | Значение | diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5a68be0..aa82a9d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "gnexus-ui-kit": "git+https://git.gnexus.space/git/root/gnexus-ui-kit.git", "marked": "^18.0.13", "vue": "^3.5.0", + "vue-i18n": "^11.4.12", "vue-router": "^4.5.0" }, "devDependencies": { @@ -2019,6 +2020,67 @@ "node": ">=18" } }, + "node_modules/@intlify/core-base": { + "version": "11.4.12", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.12.tgz", + "integrity": "sha512-tvYUWgMJmk+UvLFa7ck8uRQ9m19tTI9B2e01KCAjBV7+HN2zFLnnORvMO85hM1P8DIw4MTvot1ZSBUdoR+F6jA==", + "license": "MIT", + "dependencies": { + "@intlify/devtools-types": "11.4.12", + "@intlify/message-compiler": "11.4.12", + "@intlify/shared": "11.4.12" + }, + "engines": { + "node": ">= 22" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/devtools-types": { + "version": "11.4.12", + "resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.12.tgz", + "integrity": "sha512-Ctkcs8DQItaEL9HHdkyasFFcZFP59iBvZmrjiA7b72JFq/JlI6c6GPQHN7oEwbYTo8rndPMqNY7SX71aPm4MkA==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "11.4.12", + "@intlify/shared": "11.4.12" + }, + "engines": { + "node": ">= 22" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/message-compiler": { + "version": "11.4.12", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.12.tgz", + "integrity": "sha512-kBczk9r44fdBYnkC5tXRWa/UaA+e9NglXE3cX7jPnDd2nHzoFHYrKGfIbn8JFZpMA9omexZZo2WxOpTvlHdjsw==", + "license": "MIT", + "dependencies": { + "@intlify/shared": "11.4.12", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 22" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/shared": { + "version": "11.4.12", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.12.tgz", + "integrity": "sha512-ojfehaXMFrGFcSnK3cJPaMxlrSXT8FwZUG9H4i+WKX4OFD4Yl1WKdTmfwoC0REnwIfTrnt1QhRtd8kwi/te6bQ==", + "license": "MIT", + "engines": { + "node": ">= 22" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, "node_modules/@isaacs/cliui": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", @@ -6010,6 +6072,27 @@ } } }, + "node_modules/vue-i18n": { + "version": "11.4.12", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.12.tgz", + "integrity": "sha512-yvArvvo3ErLKFNlQrZUGmRkxL/ctM5xFYtE9FxTKiE/PalWP1bnTopBgbSRGLluYEyYSNl1Pt8l2J7zVavbXww==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "11.4.12", + "@intlify/devtools-types": "11.4.12", + "@intlify/shared": "11.4.12", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 22" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, "node_modules/vue-router": { "version": "4.6.4", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index e30c6f5..4d917c2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "gnexus-ui-kit": "git+https://git.gnexus.space/git/root/gnexus-ui-kit.git", "marked": "^18.0.13", "vue": "^3.5.0", + "vue-i18n": "^11.4.12", "vue-router": "^4.5.0" }, "devDependencies": { diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 82c182c..7e091ce 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,24 +1,27 @@ @@ -43,14 +49,14 @@ brand="gntodo" :items="navItems" :current="currentLabel" - title="Разделы" - subtitle="gntodo — личные задачи" + :title="t('nav.sections')" + :subtitle="t('nav.subtitle')" >