mirror of
https://github.com/langgenius/dify.git
synced 2026-08-29 03:45:08 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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."""
|
||||
@@ -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: ...
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user