refactor(api): add account profile application service (#40436)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Byron.wang
2026-08-20 05:03:08 +00:00
committed by GitHub
parent 11f830a17b
commit b4e13ec83e
19 changed files with 793 additions and 63 deletions
+18
View File
@@ -93,6 +93,24 @@ forbidden_modules =
sqlalchemy
werkzeug
[importlinter:contract:account-application-boundary]
name = Account application services and contracts are framework and persistence neutral
type = forbidden
source_modules =
services.account_errors
services.account_ports
services.account_profile_service
services.entities.account_entities
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:app-definition-query-service-boundary]
name = App definition query application service is framework and persistence neutral
type = forbidden
+102 -48
View File
@@ -2,12 +2,13 @@ from __future__ import annotations
from datetime import datetime
from http import HTTPStatus
from typing import Literal
from typing import Annotated, Literal
import pytz
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic.json_schema import SkipJsonSchema
from sqlalchemy import select
from werkzeug.exceptions import NotFound
@@ -29,6 +30,7 @@ from controllers.console.auth.error import (
InvalidTokenError,
)
from controllers.console.error import AccountInFreezeError, AccountNotFound, EmailSendIpLimitError
from controllers.console.flask_admission import console_account_admission
from controllers.console.workspace.error import (
AccountAlreadyInitedError,
CurrentPasswordIncorrectError,
@@ -46,6 +48,7 @@ from controllers.console.wraps import (
with_current_user,
)
from enums import DeploymentEdition
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from fields.base import ResponseModel
from fields.member_fields import AccountResponse
@@ -53,12 +56,15 @@ from graphon.file import helpers as file_helpers
from libs.datetime_utils import naive_utc_now
from libs.helper import EmailStr, dump_response, extract_remote_ip, timezone, to_timestamp
from libs.login import login_required
from machinery.context import RequestContext
from models import Account, AccountIntegrate, InvitationCode
from models.account import AccountStatus, InvitationCodeStatus
from models.enums import CreatorUserRole
from models.model import UploadFile
from services import account_errors
from services.account_service import AccountService
from services.billing_service import BillingService
from services.entities.account_entities import AccountProfileChanges
from services.entities.auth_entities import (
ChangeEmailNewEmailToken,
ChangeEmailNewEmailVerifiedToken,
@@ -118,6 +124,42 @@ class AccountTimezonePayload(BaseModel):
return timezone(value)
class AccountProfilePatchPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
name: Annotated[str, Field(min_length=3, max_length=30)] | SkipJsonSchema[None] = None
avatar: str | SkipJsonSchema[None] = None
interface_language: str | SkipJsonSchema[None] = None
interface_theme: Literal["light", "dark"] | SkipJsonSchema[None] = None
timezone: str | SkipJsonSchema[None] = None
@field_validator("*", mode="before")
@classmethod
def reject_null(cls, value: object) -> object:
if value is None:
raise ValueError("Account profile fields cannot be null")
return value
@field_validator("interface_language")
@classmethod
def validate_language(cls, value: str) -> str:
return supported_language(value)
@field_validator("timezone")
@classmethod
def validate_timezone(cls, value: str) -> str:
return timezone(value)
def to_changes(self) -> AccountProfileChanges:
return AccountProfileChanges(
name=self.name,
avatar=self.avatar,
interface_language=self.interface_language,
interface_theme=self.interface_theme,
timezone=self.timezone,
)
class AccountPasswordPayload(BaseModel):
password: str | None = None
new_password: str
@@ -183,6 +225,7 @@ register_schema_models(
AccountInterfaceLanguagePayload,
AccountInterfaceThemePayload,
AccountTimezonePayload,
AccountProfilePatchPayload,
AccountPasswordPayload,
AccountDeletePayload,
AccountDeletionFeedbackPayload,
@@ -248,6 +291,14 @@ register_response_schema_models(
)
def _update_account_profile(request_context: RequestContext, changes: AccountProfileChanges) -> dict[str, object]:
try:
account = application_services().accounts.profile.update(request_context, changes)
except account_errors.AccountNotFoundError as error:
raise AccountNotFound() from error
return dump_response(AccountResponse, account)
@console_ns.route("/account/init")
class AccountInitApi(Resource):
@console_ns.expect(console_ns.models[AccountInitPayload.__name__])
@@ -305,21 +356,28 @@ class AccountProfileApi(Resource):
def get(self, current_user: Account):
return dump_response(AccountResponse, current_user)
@console_ns.expect(console_ns.models[AccountProfilePatchPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@console_account_admission()
@model_validate(AccountProfilePatchPayload)
def patch(self, args: AccountProfilePatchPayload, request_context: RequestContext):
return _update_account_profile(request_context, args.to_changes())
@console_ns.route("/account/name")
class AccountNameApi(Resource):
"""Deprecated compatibility route; use PATCH /account/profile."""
@console_ns.doc("update_account_name_deprecated")
@console_ns.doc(deprecated=True)
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
@console_ns.expect(console_ns.models[AccountNamePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
@console_account_admission()
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountNamePayload.model_validate(payload)
updated_account = AccountService.update_account(current_user, session=db.session(), name=args.name)
return dump_response(AccountResponse, updated_account)
return _update_account_profile(request_context, AccountProfileChanges(name=args.name))
@console_ns.route("/account/avatar")
@@ -350,73 +408,69 @@ class AccountAvatarApi(Resource):
return AvatarUrlResponse(avatar_url=avatar_url).model_dump(mode="json")
@console_ns.expect(console_ns.models[AccountAvatarPayload.__name__])
@setup_required
@login_required
@account_initialization_required
@console_ns.doc("update_account_avatar_deprecated")
@console_ns.doc(deprecated=True)
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
@console_account_admission()
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountAvatarPayload.model_validate(payload)
updated_account = AccountService.update_account(current_user, session=db.session(), avatar=args.avatar)
return dump_response(AccountResponse, updated_account)
return _update_account_profile(request_context, AccountProfileChanges(avatar=args.avatar))
@console_ns.route("/account/interface-language")
class AccountInterfaceLanguageApi(Resource):
"""Deprecated compatibility route; use PATCH /account/profile."""
@console_ns.doc("update_account_interface_language_deprecated")
@console_ns.doc(deprecated=True)
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
@console_ns.expect(console_ns.models[AccountInterfaceLanguagePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
@console_account_admission()
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountInterfaceLanguagePayload.model_validate(payload)
updated_account = AccountService.update_account(
current_user, session=db.session(), interface_language=args.interface_language
return _update_account_profile(
request_context,
AccountProfileChanges(interface_language=args.interface_language),
)
return dump_response(AccountResponse, updated_account)
@console_ns.route("/account/interface-theme")
class AccountInterfaceThemeApi(Resource):
"""Deprecated compatibility route; use PATCH /account/profile."""
@console_ns.doc("update_account_interface_theme_deprecated")
@console_ns.doc(deprecated=True)
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
@console_ns.expect(console_ns.models[AccountInterfaceThemePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
@console_account_admission()
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountInterfaceThemePayload.model_validate(payload)
updated_account = AccountService.update_account(
current_user, session=db.session(), interface_theme=args.interface_theme
return _update_account_profile(
request_context,
AccountProfileChanges(interface_theme=args.interface_theme),
)
return dump_response(AccountResponse, updated_account)
@console_ns.route("/account/timezone")
class AccountTimezoneApi(Resource):
"""Deprecated compatibility route; use PATCH /account/profile."""
@console_ns.doc("update_account_timezone_deprecated")
@console_ns.doc(deprecated=True)
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
@console_ns.expect(console_ns.models[AccountTimezonePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
@console_account_admission()
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountTimezonePayload.model_validate(payload)
updated_account = AccountService.update_account(current_user, session=db.session(), timezone=args.timezone)
return dump_response(AccountResponse, updated_account)
return _update_account_profile(request_context, AccountProfileChanges(timezone=args.timezone))
@console_ns.route("/account/password")
@@ -16,6 +16,7 @@ from core.schemas.schema_manager import SchemaManager
from enums import DeploymentEdition, WebAppAccessMode
from extensions.ext_redis import RedisClientWrapper, redis_client
from repositories.account_activation_repository import SQLAlchemyAccountActivationRepository
from repositories.account_repository import SQLAlchemyAccountRepository
from repositories.app_definition_query_repository import AppDefinitionQueryRepository
from repositories.data_source_api_key_auth_repository import SQLAlchemyDataSourceApiKeyAuthBindingRepository
from repositories.explore_banner_query_repository import ExploreBannerQueryRepository
@@ -30,6 +31,7 @@ from services.account_activation_adapters import (
RegisterServiceInvitationTokenStore,
)
from services.account_activation_service import AccountActivationService
from services.account_profile_service import AccountProfileService
from services.app_definition_query_service import AppDefinitionQueryService
from services.auth.data_source_api_key_auth_gateways import (
ProviderApiKeyAuthCredentialValidator,
@@ -76,8 +78,14 @@ def _is_user_allowed_to_access_webapp(user_id: str, app_id: str) -> bool:
raise WebAppAccessUnavailableError from e
@dataclass(frozen=True, slots=True)
class AccountServices:
profile: AccountProfileService
@dataclass(frozen=True, slots=True)
class ApplicationServices:
accounts: AccountServices
account_activation: AccountActivationService
app_definitions: AppDefinitionQueryService
data_source_api_key_auth: DataSourceApiKeyAuthService
@@ -101,6 +109,9 @@ def build_application_services(
installation_state = InstallationStateRepository(client=database_client)
data_source_api_key_auth_bindings = SQLAlchemyDataSourceApiKeyAuthBindingRepository(session_factory=database_client)
return ApplicationServices(
accounts=AccountServices(
profile=AccountProfileService(accounts=SQLAlchemyAccountRepository(database_client)),
),
account_activation=AccountActivationService(
tokens=RegisterServiceInvitationTokenStore(),
accounts=SQLAlchemyAccountActivationRepository(database_client),
+53 -5
View File
@@ -27,7 +27,12 @@ Get account avatar url
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [AvatarUrlResponse](#avatarurlresponse)<br> |
### [POST] /account/avatar
### ~~[POST] /account/avatar~~
***DEPRECATED***
Deprecated. Use PATCH /account/profile instead.
#### Request Body
| Required | Schema |
@@ -187,7 +192,12 @@ Get account avatar url
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [AccountIntegrateListResponse](#accountintegratelistresponse)<br> |
### [POST] /account/interface-language
### ~~[POST] /account/interface-language~~
***DEPRECATED***
Deprecated. Use PATCH /account/profile instead.
#### Request Body
| Required | Schema |
@@ -200,7 +210,12 @@ Get account avatar url
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [AccountResponse](#accountresponse)<br> |
### [POST] /account/interface-theme
### ~~[POST] /account/interface-theme~~
***DEPRECATED***
Deprecated. Use PATCH /account/profile instead.
#### Request Body
| Required | Schema |
@@ -213,7 +228,12 @@ Get account avatar url
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [AccountResponse](#accountresponse)<br> |
### [POST] /account/name
### ~~[POST] /account/name~~
***DEPRECATED***
Deprecated. Use PATCH /account/profile instead.
#### Request Body
| Required | Schema |
@@ -246,7 +266,25 @@ Get account avatar url
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [AccountResponse](#accountresponse)<br> |
### [POST] /account/timezone
### [PATCH] /account/profile
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [AccountProfilePatchPayload](#accountprofilepatchpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [AccountResponse](#accountresponse)<br> |
### ~~[POST] /account/timezone~~
***DEPRECATED***
Deprecated. Use PATCH /account/profile instead.
#### Request Body
| Required | Schema |
@@ -12771,6 +12809,16 @@ Model class for AI model.
| password | string | | No |
| repeat_new_password | string | | Yes |
#### AccountProfilePatchPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| avatar | string | | No |
| interface_language | string | | No |
| interface_theme | string, <br>**Available values:** "dark", "light" | *Enum:* `"dark"`, `"light"` | No |
| name | string | | No |
| timezone | string | | No |
#### AccountResponse
| Name | Type | Description | Required |
+59
View File
@@ -0,0 +1,59 @@
"""SQLAlchemy implementation of the account persistence port."""
from typing import override
from sqlalchemy.orm import Session, sessionmaker
from models.account import Account
from services.account_ports import AccountRepository
from services.entities.account_entities import AccountProfileChanges, AccountSnapshot
class SQLAlchemyAccountRepository(AccountRepository):
def __init__(self, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
@override
def get(self, account_id: str) -> AccountSnapshot | None:
with self._session_factory() as session:
account = session.get(Account, account_id)
return self._to_snapshot(account) if account is not None else None
@override
def update_profile(self, account_id: str, changes: AccountProfileChanges) -> AccountSnapshot | None:
with self._session_factory.begin() as session:
account = session.get(Account, account_id)
if account is None:
return None
if changes.name is not None:
account.name = changes.name
if changes.avatar is not None:
account.avatar = changes.avatar
if changes.interface_language is not None:
account.interface_language = changes.interface_language
if changes.interface_theme is not None:
account.interface_theme = changes.interface_theme
if changes.timezone is not None:
account.timezone = changes.timezone
session.flush()
return self._to_snapshot(account)
@staticmethod
def _to_snapshot(account: Account) -> AccountSnapshot:
return AccountSnapshot(
id=account.id,
name=account.name,
email=account.email,
avatar=account.avatar,
is_password_set=account.is_password_set,
interface_language=account.interface_language,
interface_theme=account.interface_theme,
timezone=account.timezone,
last_login_at=account.last_login_at,
last_login_ip=account.last_login_ip,
status=account.status.value,
initialized_at=account.initialized_at,
created_at=account.created_at,
)
+9
View File
@@ -0,0 +1,9 @@
"""Framework-neutral errors shared by account application services."""
class AccountApplicationError(Exception):
"""Base class for failures owned by account application services."""
class AccountNotFoundError(AccountApplicationError):
"""The admitted account no longer exists."""
+11
View File
@@ -0,0 +1,11 @@
"""Persistence ports used by account application services."""
from typing import Protocol
from services.entities.account_entities import AccountProfileChanges, AccountSnapshot
class AccountRepository(Protocol):
def get(self, account_id: str) -> AccountSnapshot | None: ...
def update_profile(self, account_id: str, changes: AccountProfileChanges) -> AccountSnapshot | None: ...
+26
View File
@@ -0,0 +1,26 @@
"""Application service for reading and updating the current account profile."""
from machinery.context import RequestContext
from services.account_errors import AccountNotFoundError
from services.account_ports import AccountRepository
from services.entities.account_entities import AccountProfileChanges, AccountSnapshot
class AccountProfileService:
def __init__(self, *, accounts: AccountRepository) -> None:
self._accounts = accounts
def get(self, context: RequestContext) -> AccountSnapshot:
account = self._accounts.get(context.account_id)
if account is None:
raise AccountNotFoundError
return account
def update(self, context: RequestContext, changes: AccountProfileChanges) -> AccountSnapshot:
if changes.has_changes():
account = self._accounts.update_profile(context.account_id, changes)
else:
account = self._accounts.get(context.account_id)
if account is None:
raise AccountNotFoundError
return account
+42
View File
@@ -0,0 +1,42 @@
"""Framework-neutral contracts for Console account use cases."""
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True, slots=True)
class AccountSnapshot:
id: str
name: str
email: str
avatar: str | None
is_password_set: bool
interface_language: str | None
interface_theme: str | None
timezone: str | None
last_login_at: datetime | None
last_login_ip: str | None
status: str
initialized_at: datetime | None
created_at: datetime
@dataclass(frozen=True, slots=True)
class AccountProfileChanges:
name: str | None = None
avatar: str | None = None
interface_language: str | None = None
interface_theme: str | None = None
timezone: str | None = None
def has_changes(self) -> bool:
return any(
value is not None
for value in (
self.name,
self.avatar,
self.interface_language,
self.interface_theme,
self.timezone,
)
)
@@ -1,10 +1,12 @@
import inspect
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import MagicMock, PropertyMock, patch
from uuid import NAMESPACE_URL, uuid5
import pytest
from flask import Flask
from jsonschema import Draft202012Validator
from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound
@@ -26,6 +28,7 @@ from controllers.console.workspace.account import (
AccountNameApi,
AccountPasswordApi,
AccountProfileApi,
AccountProfilePatchPayload,
AccountTimezoneApi,
ChangeEmailCheckApi,
ChangeEmailResetApi,
@@ -38,10 +41,12 @@ from controllers.console.workspace.error import (
)
from enums import DeploymentEdition
from extensions.storage.storage_type import StorageType
from machinery.context import RequestContext
from models import Account, AccountIntegrate, InvitationCode, Tenant, TenantAccountJoin
from models.account import AccountStatus, InvitationCodeStatus, TenantAccountRole
from models.enums import CreatorUserRole
from models.model import UploadFile
from services.entities.account_entities import AccountProfileChanges
from services.errors.account import CurrentPasswordIncorrectError as ServicePwdError
@@ -165,28 +170,146 @@ class TestAccountProfileApi:
class TestAccountUpdateApis:
@pytest.mark.parametrize(
("api_cls", "payload"),
("api_cls", "payload", "expected_changes"),
[
(AccountNameApi, {"name": "test"}),
(AccountAvatarApi, {"avatar": "img.png"}),
(AccountInterfaceLanguageApi, {"interface_language": "en-US"}),
(AccountInterfaceThemeApi, {"interface_theme": "dark"}),
(AccountTimezoneApi, {"timezone": "UTC"}),
(AccountNameApi, {"name": "test"}, AccountProfileChanges(name="test")),
(AccountAvatarApi, {"avatar": "img.png"}, AccountProfileChanges(avatar="img.png")),
(
AccountInterfaceLanguageApi,
{"interface_language": "en-US"},
AccountProfileChanges(interface_language="en-US"),
),
(
AccountInterfaceThemeApi,
{"interface_theme": "dark"},
AccountProfileChanges(interface_theme="dark"),
),
(AccountTimezoneApi, {"timezone": "UTC"}, AccountProfileChanges(timezone="UTC")),
],
)
def test_update_success(self, app: Flask, api_cls, payload):
def test_deprecated_update_routes_delegate_to_profile_service(
self, app: Flask, api_cls, payload, expected_changes: AccountProfileChanges
):
api = api_cls()
method = inspect.unwrap(api.post)
user = make_account()
request_context = RequestContext(
request_id="request-1",
trace_id=None,
account_id=user.id,
active_workspace_id=None,
)
profile = MagicMock()
profile.update.return_value = user
with (
app.test_request_context("/", json=payload),
patch("controllers.console.workspace.account.AccountService.update_account", return_value=user),
patch(
"controllers.console.workspace.account.application_services",
return_value=SimpleNamespace(accounts=SimpleNamespace(profile=profile)),
),
):
result = method(api, user)
result = method(api, request_context)
assert result["id"] == user.id
profile.update.assert_called_once_with(request_context, expected_changes)
def test_deprecated_update_routes_are_marked_deprecated(self):
for api_cls in (
AccountNameApi,
AccountAvatarApi,
AccountInterfaceLanguageApi,
AccountInterfaceThemeApi,
AccountTimezoneApi,
):
assert api_cls.post.__apidoc__["deprecated"] is True
class TestAccountProfilePatchApi:
def test_json_schema_matches_runtime_patch_rules(self):
schema = AccountProfilePatchPayload.model_json_schema()
validator = Draft202012Validator(schema)
assert schema["type"] == "object"
assert schema["additionalProperties"] is False
assert "required" not in schema
assert set(schema["properties"]) == {
"name",
"avatar",
"interface_language",
"interface_theme",
"timezone",
}
validator.validate({})
validator.validate({"name": "Jane"})
validator.validate({"name": "Jane", "interface_language": "en-US", "timezone": "UTC"})
for payload in (
{"name": None},
{"unexpected": "value"},
{"name": "Jane", "unexpected": "value"},
):
assert list(validator.iter_errors(payload))
def test_updates_multiple_profile_fields(self, app: Flask):
api = AccountProfileApi()
method = inspect.unwrap(api.patch)
user = make_account()
request_context = RequestContext(
request_id="request-1",
trace_id="trace-1",
account_id=user.id,
active_workspace_id="workspace-1",
)
profile = MagicMock()
profile.update.return_value = user
payload = {"name": "Jane", "interface_language": "en-US", "timezone": "UTC"}
args = AccountProfilePatchPayload.model_validate(payload)
with (
app.test_request_context("/account/profile", method="PATCH", json=payload),
patch(
"controllers.console.workspace.account.application_services",
return_value=SimpleNamespace(accounts=SimpleNamespace(profile=profile)),
),
):
result = method(api, args, request_context)
assert result["id"] == user.id
profile.update.assert_called_once_with(
request_context,
AccountProfileChanges(name="Jane", interface_language="en-US", timezone="UTC"),
)
def test_empty_patch_is_a_noop(self, app: Flask):
api = AccountProfileApi()
method = inspect.unwrap(api.patch)
user = make_account()
request_context = RequestContext(
request_id="request-1",
trace_id="trace-1",
account_id=user.id,
active_workspace_id="workspace-1",
)
profile = MagicMock()
profile.update.return_value = user
args = AccountProfilePatchPayload.model_validate({})
with (
app.test_request_context("/account/profile", method="PATCH", json={}),
patch(
"controllers.console.workspace.account.application_services",
return_value=SimpleNamespace(accounts=SimpleNamespace(profile=profile)),
),
):
result = method(api, args, request_context)
assert result["id"] == user.id
profile.update.assert_called_once_with(request_context, AccountProfileChanges())
@pytest.mark.parametrize("payload", [{"name": None}, {"unexpected": "value"}])
def test_rejects_null_or_unknown_changes(self, payload: dict[str, object]):
with pytest.raises(ValueError):
AccountProfilePatchPayload.model_validate(payload)
class TestAccountAvatarApiGet:
@@ -576,6 +576,40 @@ def test_console_account_avatar_query_param_renders_as_query(monkeypatch: pytest
assert params["avatar"]["required"] is True
def test_console_account_profile_patch_and_deprecated_aliases(monkeypatch: pytest.MonkeyPatch):
from configs import dify_config
from controllers.console import bp as console_bp
monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True)
app = Flask(__name__)
app.config["TESTING"] = True
app.config["RESTX_INCLUDE_ALL_MODELS"] = True
app.register_blueprint(console_bp)
payload = app.test_client().get("/console/api/openapi.json").get_json()
paths = payload["paths"]
profile_patch = paths["/account/profile"]["patch"]
assert profile_patch.get("deprecated") is not True
profile_patch_schema = _json_body_schema(payload, profile_patch)
assert profile_patch_schema["type"] == "object"
assert profile_patch_schema["additionalProperties"] is False
assert "required" not in profile_patch_schema
assert profile_patch_schema["properties"]["name"]["type"] == "string"
for path in (
"/account/name",
"/account/avatar",
"/account/interface-language",
"/account/interface-theme",
"/account/timezone",
):
assert paths[path]["post"]["deprecated"] is True
assert paths["/account/avatar"]["get"].get("deprecated") is not True
def test_console_agent_debug_conversation_refresh_has_no_body(monkeypatch: pytest.MonkeyPatch):
from configs import dify_config
from controllers.console import bp as console_bp
@@ -15,6 +15,7 @@ from extensions import ext_application_services
from extensions.ext_redis import RedisClientWrapper
from models.model import DifySetup
from repositories.account_activation_repository import SQLAlchemyAccountActivationRepository
from repositories.account_repository import SQLAlchemyAccountRepository
from services.account_activation_adapters import (
BillingAccountActivationEligibility,
BillingWorkspaceMembershipCache,
@@ -151,6 +152,21 @@ def test_build_application_services_does_not_construct_schema_manager(
schema_manager.assert_not_called()
def test_build_application_services_wires_account_profile_repository(
sqlite_session_factory: sessionmaker[Session],
) -> None:
services = ext_application_services.build_application_services(
database_client=sqlite_session_factory,
deployment_edition=DeploymentEdition.COMMUNITY,
initialization_password="",
redis=MagicMock(spec=RedisClientWrapper),
)
accounts = services.accounts.profile._accounts
assert isinstance(accounts, SQLAlchemyAccountRepository)
assert accounts._session_factory is sqlite_session_factory
@pytest.mark.parametrize(
("deployment_edition", "billing_enabled"),
[
@@ -0,0 +1,72 @@
import pytest
from sqlalchemy.orm import Session, sessionmaker
from models.account import Account
from repositories.account_repository import SQLAlchemyAccountRepository
from services.entities.account_entities import AccountProfileChanges
def _persist_account(session: Session) -> Account:
account = Account(name="Original", email="account@example.com")
account.id = "account-1"
account.interface_language = "en-US"
account.interface_theme = "light"
account.timezone = "UTC"
session.add(account)
session.commit()
return account
def test_update_profile_persists_multiple_fields(
sqlite_session: Session,
sqlite_session_factory: sessionmaker[Session],
) -> None:
_persist_account(sqlite_session)
repository = SQLAlchemyAccountRepository(sqlite_session_factory)
result = repository.update_profile(
"account-1",
AccountProfileChanges(
name="Updated",
avatar="avatar-file",
interface_language="zh-Hans",
interface_theme="dark",
timezone="Asia/Shanghai",
),
)
assert result is not None
assert result.name == "Updated"
sqlite_session.expire_all()
persisted = sqlite_session.get(Account, "account-1")
assert persisted is not None
assert persisted.name == "Updated"
assert persisted.avatar == "avatar-file"
assert persisted.interface_language == "zh-Hans"
assert persisted.interface_theme == "dark"
assert persisted.timezone == "Asia/Shanghai"
def test_update_profile_rolls_back_on_error(
sqlite_session: Session,
sqlite_session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
) -> None:
_persist_account(sqlite_session)
repository = SQLAlchemyAccountRepository(sqlite_session_factory)
def fail_to_create_snapshot(_account: Account) -> None:
raise RuntimeError("abort update")
monkeypatch.setattr(SQLAlchemyAccountRepository, "_to_snapshot", staticmethod(fail_to_create_snapshot))
with pytest.raises(RuntimeError, match="abort update"):
repository.update_profile(
"account-1",
AccountProfileChanges(name="Should Roll Back"),
)
sqlite_session.expire_all()
persisted = sqlite_session.get(Account, "account-1")
assert persisted is not None
assert persisted.name == "Original"
@@ -0,0 +1,86 @@
from __future__ import annotations
from datetime import datetime
from unittest.mock import Mock
import pytest
from machinery.context import RequestContext
from services.account_errors import AccountNotFoundError
from services.account_ports import AccountRepository
from services.account_profile_service import AccountProfileService
from services.entities.account_entities import AccountProfileChanges, AccountSnapshot
def _context() -> RequestContext:
return RequestContext(
request_id="request-1",
trace_id="trace-1",
account_id="account-1",
active_workspace_id="workspace-1",
)
def _account() -> AccountSnapshot:
return AccountSnapshot(
id="account-1",
name="Account",
email="account@example.com",
avatar=None,
is_password_set=False,
interface_language="en-US",
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 test_get_returns_framework_neutral_account_snapshot() -> None:
accounts = Mock(spec=AccountRepository)
accounts.get.return_value = _account()
service = AccountProfileService(accounts=accounts)
result = service.get(_context())
assert result == _account()
accounts.get.assert_called_once_with("account-1")
def test_update_applies_profile_changes() -> None:
accounts = Mock(spec=AccountRepository)
accounts.update_profile.return_value = _account()
service = AccountProfileService(accounts=accounts)
changes = AccountProfileChanges(name="Updated", timezone="Asia/Singapore")
result = service.update(_context(), changes)
assert result == _account()
accounts.update_profile.assert_called_once_with("account-1", changes)
def test_update_treats_empty_changes_as_noop() -> None:
accounts = Mock(spec=AccountRepository)
accounts.get.return_value = _account()
service = AccountProfileService(accounts=accounts)
result = service.update(_context(), AccountProfileChanges())
assert result == _account()
accounts.get.assert_called_once_with("account-1")
accounts.update_profile.assert_not_called()
def test_update_rejects_missing_account() -> None:
accounts = Mock(spec=AccountRepository)
accounts.update_profile.return_value = None
service = AccountProfileService(accounts=accounts)
changes = AccountProfileChanges(name="Updated")
with pytest.raises(AccountNotFoundError):
service.update(_context(), changes)
accounts.update_profile.assert_called_once_with("account-1", changes)
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { zAccountProfilePatchPayload } from './generated/api/console/account/zod.gen'
describe('generated account profile schema', () => {
it('matches the server rules for partial updates', () => {
expect(zAccountProfilePatchPayload.safeParse({ name: 'Jane' }).success).toBe(true)
expect(zAccountProfilePatchPayload.safeParse({}).success).toBe(true)
expect(zAccountProfilePatchPayload.safeParse({ name: null }).success).toBe(false)
expect(
zAccountProfilePatchPayload.safeParse({ name: 'Jane', unexpected: 'value' }).success,
).toBe(false)
})
})
@@ -12,6 +12,8 @@ import {
zGetAccountEducationVerifyResponse,
zGetAccountIntegratesResponse,
zGetAccountProfileResponse,
zPatchAccountProfileBody,
zPatchAccountProfileResponse,
zPostAccountAvatarBody,
zPostAccountAvatarResponse,
zPostAccountChangeEmailBody,
@@ -57,8 +59,15 @@ export const get = oc
.input(z.object({ query: zGetAccountAvatarQuery }))
.output(zGetAccountAvatarResponse)
/**
* Deprecated. Use PATCH /account/profile instead.
*
* @deprecated
*/
export const post = oc
.route({
deprecated: true,
description: 'Deprecated. Use PATCH /account/profile instead.',
inputStructure: 'detailed',
method: 'POST',
operationId: 'postAccountAvatar',
@@ -268,8 +277,15 @@ export const integrates = {
get: get6,
}
/**
* Deprecated. Use PATCH /account/profile instead.
*
* @deprecated
*/
export const post10 = oc
.route({
deprecated: true,
description: 'Deprecated. Use PATCH /account/profile instead.',
inputStructure: 'detailed',
method: 'POST',
operationId: 'postAccountInterfaceLanguage',
@@ -283,8 +299,15 @@ export const interfaceLanguage = {
post: post10,
}
/**
* Deprecated. Use PATCH /account/profile instead.
*
* @deprecated
*/
export const post11 = oc
.route({
deprecated: true,
description: 'Deprecated. Use PATCH /account/profile instead.',
inputStructure: 'detailed',
method: 'POST',
operationId: 'postAccountInterfaceTheme',
@@ -298,8 +321,15 @@ export const interfaceTheme = {
post: post11,
}
/**
* Deprecated. Use PATCH /account/profile instead.
*
* @deprecated
*/
export const post12 = oc
.route({
deprecated: true,
description: 'Deprecated. Use PATCH /account/profile instead.',
inputStructure: 'detailed',
method: 'POST',
operationId: 'postAccountName',
@@ -338,12 +368,31 @@ export const get7 = oc
})
.output(zGetAccountProfileResponse)
export const patch = oc
.route({
inputStructure: 'detailed',
method: 'PATCH',
operationId: 'patchAccountProfile',
path: '/account/profile',
tags: ['console'],
})
.input(z.object({ body: zPatchAccountProfileBody }))
.output(zPatchAccountProfileResponse)
export const profile = {
get: get7,
patch,
}
/**
* Deprecated. Use PATCH /account/profile instead.
*
* @deprecated
*/
export const post14 = oc
.route({
deprecated: true,
description: 'Deprecated. Use PATCH /account/profile instead.',
inputStructure: 'detailed',
method: 'POST',
operationId: 'postAccountTimezone',
@@ -125,6 +125,14 @@ export type AccountPasswordPayload = {
repeat_new_password: string
}
export type AccountProfilePatchPayload = {
avatar?: string
interface_language?: string
interface_theme?: 'dark' | 'light'
name?: string
timezone?: string
}
export type AccountTimezonePayload = {
timezone: string
}
@@ -432,6 +440,20 @@ export type GetAccountProfileResponses = {
export type GetAccountProfileResponse = GetAccountProfileResponses[keyof GetAccountProfileResponses]
export type PatchAccountProfileData = {
body: AccountProfilePatchPayload
path?: never
query?: never
url: '/account/profile'
}
export type PatchAccountProfileResponses = {
200: AccountResponse
}
export type PatchAccountProfileResponse =
PatchAccountProfileResponses[keyof PatchAccountProfileResponses]
export type PostAccountTimezoneData = {
body: AccountTimezonePayload
path?: never
@@ -182,6 +182,19 @@ export const zAccountPasswordPayload = z.object({
repeat_new_password: z.string(),
})
/**
* AccountProfilePatchPayload
*/
export const zAccountProfilePatchPayload = z
.object({
avatar: z.string().optional(),
interface_language: z.string().optional(),
interface_theme: z.enum(['dark', 'light']).optional(),
name: z.string().min(3).max(30).optional(),
timezone: z.string().optional(),
})
.strict()
/**
* AccountTimezonePayload
*/
@@ -359,6 +372,13 @@ export const zPostAccountPasswordResponse = zAccountResponse
*/
export const zGetAccountProfileResponse = zAccountResponse
export const zPatchAccountProfileBody = zAccountProfilePatchPayload
/**
* Success
*/
export const zPatchAccountProfileResponse = zAccountResponse
export const zPostAccountTimezoneBody = zAccountTimezonePayload
/**
@@ -61,6 +61,7 @@ const currentDir = path.dirname(fileURLToPath(import.meta.url))
const apiOpenApiDir = path.resolve(currentDir, 'openapi')
const operationMethods = new Set(['delete', 'get', 'patch', 'post', 'put'])
const strictZodSchemaNames = new Set(['AccountProfilePatchPayload'])
const pydanticDecimalStringPattern = '^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$'
const codegenSafeDecimalStringPattern = '^(?![-+.]*$)[+-]?0*\\d*\\.?\\d*$'
const fastOpenApiConsoleSpecFilename = 'fastopenapi-console-openapi.json'
@@ -492,6 +493,22 @@ const createApiConfig = (job: ApiJob): UserConfig => ({
{
name: 'zod',
'~resolvers': {
object: (ctx) => {
const objectSchema = ctx.nodes.base(ctx)
const additionalProperties = ctx.schema.additionalProperties
// openapi-ts normalizes `additionalProperties: false` to `never`, but
// does not make shaped Zod objects strict.
const isStrictSchema = ctx.path['~ref'].some(
(segment) => typeof segment === 'string' && strictZodSchemaNames.has(segment),
)
if (
isStrictSchema &&
(additionalProperties === false || additionalProperties?.type === 'never')
)
return objectSchema.attr('strict').call()
return objectSchema
},
string: (ctx) => {
if (ctx.schema.format === 'binary')
return $(ctx.symbols.z)