refactor(console): dep-inject account payloads with @model_validate (#41500)

This commit is contained in:
Eddy ZHANG authored and GitHub committed 2026-08-31 00:52:10 +00:00
1 parent 8894b09c3b
commit 6130f081e0
3 files changed
+113 -82

No files matched your search

+30 -52
View File
@@ -292,10 +292,8 @@ class AccountInitApi(Resource):
@console_ns.expect(console_ns.models[AccountInitPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_account_admission(require_initialized=False)
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountInitPayload.model_validate(payload)
@model_validate(AccountInitPayload)
def post(self, args: AccountInitPayload, request_context: RequestContext):
try:
application_services().accounts.initialization.initialize(
request_context,
@@ -344,9 +342,8 @@ class AccountNameApi(Resource):
@console_ns.expect(console_ns.models[AccountNamePayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@console_account_admission()
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountNamePayload.model_validate(payload)
@model_validate(AccountNamePayload)
def post(self, args: AccountNamePayload, request_context: RequestContext):
return _update_account_profile(request_context, AccountProfileChanges(name=args.name))
@@ -371,9 +368,8 @@ class AccountAvatarApi(Resource):
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@console_account_admission()
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountAvatarPayload.model_validate(payload)
@model_validate(AccountAvatarPayload)
def post(self, args: AccountAvatarPayload, request_context: RequestContext):
return _update_account_profile(request_context, AccountProfileChanges(avatar=args.avatar))
@@ -387,9 +383,8 @@ class AccountInterfaceLanguageApi(Resource):
@console_ns.expect(console_ns.models[AccountInterfaceLanguagePayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@console_account_admission()
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountInterfaceLanguagePayload.model_validate(payload)
@model_validate(AccountInterfaceLanguagePayload)
def post(self, args: AccountInterfaceLanguagePayload, request_context: RequestContext):
return _update_account_profile(
request_context,
AccountProfileChanges(interface_language=args.interface_language),
@@ -406,9 +401,8 @@ class AccountInterfaceThemeApi(Resource):
@console_ns.expect(console_ns.models[AccountInterfaceThemePayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@console_account_admission()
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountInterfaceThemePayload.model_validate(payload)
@model_validate(AccountInterfaceThemePayload)
def post(self, args: AccountInterfaceThemePayload, request_context: RequestContext):
return _update_account_profile(
request_context,
AccountProfileChanges(interface_theme=args.interface_theme),
@@ -425,9 +419,8 @@ class AccountTimezoneApi(Resource):
@console_ns.expect(console_ns.models[AccountTimezonePayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@console_account_admission()
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountTimezonePayload.model_validate(payload)
@model_validate(AccountTimezonePayload)
def post(self, args: AccountTimezonePayload, request_context: RequestContext):
return _update_account_profile(request_context, AccountProfileChanges(timezone=args.timezone))
@@ -436,10 +429,8 @@ class AccountPasswordApi(Resource):
@console_ns.expect(console_ns.models[AccountPasswordPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@console_account_admission()
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountPasswordPayload.model_validate(payload)
@model_validate(AccountPasswordPayload)
def post(self, args: AccountPasswordPayload, request_context: RequestContext):
try:
assert args.password is not None
account = application_services().accounts.password.change(
@@ -498,10 +489,8 @@ class AccountDeleteApi(Resource):
@console_ns.expect(console_ns.models[AccountDeletePayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_account_admission()
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountDeletePayload.model_validate(payload)
@model_validate(AccountDeletePayload)
def post(self, args: AccountDeletePayload, request_context: RequestContext):
try:
application_services().accounts.deletion.request_deletion(
request_context,
@@ -519,10 +508,8 @@ class AccountDeleteUpdateFeedbackApi(Resource):
@console_ns.expect(console_ns.models[AccountDeletionFeedbackPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
def post(self):
payload = console_ns.payload or {}
args = AccountDeletionFeedbackPayload.model_validate(payload)
@model_validate(AccountDeletionFeedbackPayload)
def post(self, args: AccountDeletionFeedbackPayload):
application_services().accounts.deletion_feedback.submit(email=args.email, feedback=args.feedback)
return SimpleResultResponse(result="success").model_dump(mode="json")
@@ -547,9 +534,8 @@ class EducationApi(Resource):
@console_ns.expect(console_ns.models[EducationActivatePayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationActivateResponse.__name__])
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = EducationActivatePayload.model_validate(payload)
@model_validate(EducationActivatePayload)
def post(self, args: EducationActivatePayload, request_context: RequestContext):
try:
activation = application_services().accounts.education.activate(
request_context,
@@ -574,10 +560,8 @@ class EducationAutoCompleteApi(Resource):
@console_ns.doc(params=query_params_from_model(EducationAutocompleteQuery))
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationAutocompleteResponse.__name__])
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
def get(self, request_context: RequestContext):
payload = request.args.to_dict(flat=True)
args = EducationAutocompleteQuery.model_validate(payload)
@model_validate(EducationAutocompleteQuery)
def get(self, args: EducationAutocompleteQuery, request_context: RequestContext):
return dump_response(
EducationAutocompleteResponse,
application_services().accounts.education.autocomplete(
@@ -594,10 +578,8 @@ class ChangeEmailSendEmailApi(Resource):
@console_ns.expect(console_ns.models[ChangeEmailSendPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@console_account_admission(require_change_email_enabled=True)
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = ChangeEmailSendPayload.model_validate(payload)
@model_validate(ChangeEmailSendPayload)
def post(self, args: ChangeEmailSendPayload, request_context: RequestContext):
ip_address = extract_remote_ip(request)
language = "zh-Hans" if args.language == "zh-Hans" else "en-US"
try:
@@ -627,10 +609,8 @@ class ChangeEmailCheckApi(Resource):
@console_ns.expect(console_ns.models[ChangeEmailValidityPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[VerificationTokenResponse.__name__])
@console_account_admission(require_change_email_enabled=True)
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = ChangeEmailValidityPayload.model_validate(payload)
@model_validate(ChangeEmailValidityPayload)
def post(self, args: ChangeEmailValidityPayload, request_context: RequestContext):
try:
verification = application_services().accounts.change_email.verify_code(
request_context,
@@ -656,9 +636,8 @@ class ChangeEmailResetApi(Resource):
@console_ns.expect(console_ns.models[ChangeEmailResetPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@console_account_admission(require_change_email_enabled=True)
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = ChangeEmailResetPayload.model_validate(payload)
@model_validate(ChangeEmailResetPayload)
def post(self, args: ChangeEmailResetPayload, request_context: RequestContext):
try:
updated_account = application_services().accounts.change_email.reset(
request_context,
@@ -684,9 +663,8 @@ class CheckEmailUnique(Resource):
@console_ns.expect(console_ns.models[CheckEmailUniquePayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
def post(self):
payload = console_ns.payload or {}
args = CheckEmailUniquePayload.model_validate(payload)
@model_validate(CheckEmailUniquePayload)
def post(self, args: CheckEmailUniquePayload):
try:
application_services().accounts.change_email.ensure_available(args.email)
except account_errors.AccountEmailDomainSuspendedError:
@@ -4,17 +4,23 @@ from unittest.mock import MagicMock, patch
from uuid import NAMESPACE_URL, uuid5
import pytest
from flask import Flask
from flask import Flask, request
from sqlalchemy.orm import Session
from controllers.console.auth.error import InvalidTokenError
from controllers.console.error import EducationActivateLimitError, EducationVerifyLimitError, EmailDomainSuspendedError
from controllers.console.workspace.account import (
AccountDeleteUpdateFeedbackApi,
AccountDeletionFeedbackPayload,
ChangeEmailCheckApi,
ChangeEmailResetApi,
ChangeEmailResetPayload,
ChangeEmailSendEmailApi,
ChangeEmailSendPayload,
ChangeEmailValidityPayload,
CheckEmailUnique,
CheckEmailUniquePayload,
EducationActivatePayload,
EducationApi,
EducationVerifyApi,
)
@@ -127,7 +133,7 @@ class TestEducationApi:
):
api = EducationApi()
method = inspect.unwrap(api.post)
result = method(api, request_context)
result = method(api, EducationActivatePayload.model_validate(request.get_json() or {}), request_context)
assert result == {"message": "success"}
education.activate.assert_called_once_with(
@@ -181,7 +187,9 @@ class TestEducationApi:
):
api = EducationApi()
with pytest.raises(EducationActivateLimitError):
inspect.unwrap(api.post)(api, request_context)
inspect.unwrap(api.post)(
api, EducationActivatePayload.model_validate(request.get_json() or {}), request_context
)
def _change_email_context(account_id: str = "acc") -> RequestContext:
@@ -218,7 +226,9 @@ class TestChangeEmailControllers:
),
):
api = ChangeEmailSendEmailApi()
response = inspect.unwrap(api.post)(api, context)
response = inspect.unwrap(api.post)(
api, ChangeEmailSendPayload.model_validate(request.get_json() or {}), context
)
assert response == {"result": "success", "data": "change-token"}
change_email.send_code.assert_called_once_with(
@@ -248,7 +258,7 @@ class TestChangeEmailControllers:
api = ChangeEmailSendEmailApi()
method = inspect.unwrap(api.post)
with pytest.raises(InvalidTokenError):
method(api, _change_email_context())
method(api, ChangeEmailSendPayload.model_validate(request.get_json() or {}), _change_email_context())
def test_validity_serializes_promoted_token(self, app: Flask):
change_email = MagicMock()
@@ -270,7 +280,9 @@ class TestChangeEmailControllers:
),
):
api = ChangeEmailCheckApi()
response = inspect.unwrap(api.post)(api, context)
response = inspect.unwrap(api.post)(
api, ChangeEmailValidityPayload.model_validate(request.get_json() or {}), context
)
assert response == {"is_valid": True, "email": "new@example.com", "token": "verified-token"}
change_email.verify_code.assert_called_once_with(
@@ -298,7 +310,9 @@ class TestChangeEmailControllers:
),
):
api = ChangeEmailResetApi()
response = inspect.unwrap(api.post)(api, context)
response = inspect.unwrap(api.post)(
api, ChangeEmailResetPayload.model_validate(request.get_json() or {}), context
)
assert response["email"] == "new@example.com"
change_email.reset.assert_called_once_with(
@@ -324,7 +338,9 @@ class TestChangeEmailControllers:
):
api = ChangeEmailResetApi()
with pytest.raises(EmailDomainSuspendedError):
inspect.unwrap(api.post)(api, _change_email_context())
inspect.unwrap(api.post)(
api, ChangeEmailResetPayload.model_validate(request.get_json() or {}), _change_email_context()
)
class TestAccountServiceSendChangeEmailEmail:
@@ -433,7 +449,7 @@ class TestAccountDeletionFeedback:
):
api = AccountDeleteUpdateFeedbackApi()
method = inspect.unwrap(api.post)
response = method(api)
response = method(api, AccountDeletionFeedbackPayload.model_validate(request.get_json() or {}))
assert response == {"result": "success"}
deletion_feedback.submit.assert_called_once_with(email="User@Example.com", feedback="test")
@@ -455,7 +471,7 @@ class TestCheckEmailUnique:
),
):
api = CheckEmailUnique()
response = inspect.unwrap(api.post)(api)
response = inspect.unwrap(api.post)(api, CheckEmailUniquePayload.model_validate(request.get_json() or {}))
assert response == {"result": "success"}
change_email.ensure_available.assert_called_once_with("Case@Test.com")
@@ -477,7 +493,7 @@ class TestCheckEmailUnique:
):
api = CheckEmailUnique()
with pytest.raises(EmailDomainSuspendedError):
inspect.unwrap(api.post)(api)
inspect.unwrap(api.post)(api, CheckEmailUniquePayload.model_validate(request.get_json() or {}))
@pytest.mark.parametrize(
@@ -19,21 +19,32 @@ from controllers.console.auth.error import (
from controllers.console.error import AccountInFreezeError, EmailDomainSuspendedError
from controllers.console.workspace.account import (
AccountAvatarApi,
AccountAvatarPayload,
AccountAvatarQuery,
AccountDeleteApi,
AccountDeletePayload,
AccountDeleteVerifyApi,
AccountInitApi,
AccountInitPayload,
AccountIntegrateApi,
AccountInterfaceLanguageApi,
AccountInterfaceLanguagePayload,
AccountInterfaceThemeApi,
AccountInterfaceThemePayload,
AccountNameApi,
AccountNamePayload,
AccountPasswordApi,
AccountPasswordPayload,
AccountProfileApi,
AccountProfilePatchPayload,
AccountTimezoneApi,
AccountTimezonePayload,
ChangeEmailCheckApi,
ChangeEmailResetApi,
ChangeEmailResetPayload,
ChangeEmailValidityPayload,
CheckEmailUnique,
CheckEmailUniquePayload,
)
from controllers.console.workspace.error import (
AccountAlreadyInitedError,
@@ -120,7 +131,7 @@ class TestAccountInitApi:
return_value=SimpleNamespace(accounts=SimpleNamespace(initialization=initialization)),
),
):
resp = method(api, request_context)
resp = method(api, AccountInitPayload.model_validate(payload), request_context)
assert resp["result"] == "success"
initialization.initialize.assert_called_once_with(
@@ -152,7 +163,7 @@ class TestAccountInitApi:
),
):
with pytest.raises(AccountAlreadyInitedError):
method(api, request_context)
method(api, AccountInitPayload.model_validate(payload), request_context)
def test_init_missing_invitation_code_is_mapped(self, app: Flask):
api = AccountInitApi()
@@ -175,7 +186,7 @@ class TestAccountInitApi:
),
):
with pytest.raises(MissingInvitationCodeRequestError) as exc_info:
method(api, request_context)
method(api, AccountInitPayload.model_validate(payload), request_context)
assert exc_info.value.data == {
"code": "missing_invitation_code",
@@ -213,25 +224,27 @@ class TestAccountProfileApi:
class TestAccountUpdateApis:
@pytest.mark.parametrize(
("api_cls", "payload", "expected_changes"),
("api_cls", "payload_model", "payload", "expected_changes"),
[
(AccountNameApi, {"name": "test"}, AccountProfileChanges(name="test")),
(AccountAvatarApi, {"avatar": "img.png"}, AccountProfileChanges(avatar="img.png")),
(AccountNameApi, AccountNamePayload, {"name": "test"}, AccountProfileChanges(name="test")),
(AccountAvatarApi, AccountAvatarPayload, {"avatar": "img.png"}, AccountProfileChanges(avatar="img.png")),
(
AccountInterfaceLanguageApi,
AccountInterfaceLanguagePayload,
{"interface_language": "en-US"},
AccountProfileChanges(interface_language="en-US"),
),
(
AccountInterfaceThemeApi,
AccountInterfaceThemePayload,
{"interface_theme": "dark"},
AccountProfileChanges(interface_theme="dark"),
),
(AccountTimezoneApi, {"timezone": "UTC"}, AccountProfileChanges(timezone="UTC")),
(AccountTimezoneApi, AccountTimezonePayload, {"timezone": "UTC"}, AccountProfileChanges(timezone="UTC")),
],
)
def test_deprecated_update_routes_delegate_to_profile_service(
self, app: Flask, api_cls, payload, expected_changes: AccountProfileChanges
self, app: Flask, api_cls, payload_model, payload, expected_changes: AccountProfileChanges
):
api = api_cls()
method = inspect.unwrap(api.post)
@@ -252,7 +265,7 @@ class TestAccountUpdateApis:
return_value=SimpleNamespace(accounts=SimpleNamespace(profile=profile)),
),
):
result = method(api, request_context)
result = method(api, payload_model.model_validate(payload), request_context)
assert result["id"] == user.id
profile.update.assert_called_once_with(request_context, expected_changes)
@@ -426,6 +439,30 @@ class TestAccountAvatarApiGet:
assert exc_info.value.code == 422
class TestConvertedPostDecorator:
def test_rejects_an_invalid_body_through_the_decorator(self, app: Flask):
"""The decorator validates the JSON body before the view runs, for the POST handlers too."""
account = make_account()
with (
app.test_request_context("/account/name", method="POST", json={}),
patch("controllers.console.wraps._is_setup_completed", return_value=True),
config_overrides_context(LOGIN_DISABLED=True),
patch(
"controllers.console.wraps.current_account_with_tenant",
return_value=(account, "workspace-1"),
),
patch(
"controllers.console.flask_admission.current_account_with_tenant",
return_value=SimpleNamespace(account=account, tenant_id="workspace-1"),
),
):
with pytest.raises(UnprocessableEntity) as exc_info:
AccountNameApi().post()
assert exc_info.value.code == 422
class TestAccountPasswordApi:
def test_password_success(self, app: Flask):
api = AccountPasswordApi()
@@ -454,7 +491,7 @@ class TestAccountPasswordApi:
return_value=SimpleNamespace(accounts=SimpleNamespace(password=password)),
),
):
result = method(api, request_context)
result = method(api, AccountPasswordPayload.model_validate(payload), request_context)
assert result["id"] == user.id
password.change.assert_called_once_with(
@@ -490,7 +527,7 @@ class TestAccountPasswordApi:
),
):
with pytest.raises(CurrentPasswordIncorrectError):
method(api, request_context)
method(api, AccountPasswordPayload.model_validate(payload), request_context)
def test_password_policy_error_is_mapped(self, app: Flask):
api = AccountPasswordApi()
@@ -519,7 +556,7 @@ class TestAccountPasswordApi:
),
):
with pytest.raises(InvalidAccountPasswordRequestError) as exc_info:
method(api, request_context)
method(api, AccountPasswordPayload.model_validate(payload), request_context)
assert exc_info.value.data == {
"code": "invalid_account_password",
@@ -610,7 +647,7 @@ class TestAccountDeleteApi:
),
):
with pytest.raises(InvalidAccountDeletionCodeError):
method(api, request_context)
method(api, AccountDeletePayload.model_validate(payload), request_context)
def test_delete_verify_maps_rate_limit(self, app: Flask):
api = AccountDeleteVerifyApi()
@@ -653,7 +690,7 @@ class TestAccountDeleteApi:
return_value=SimpleNamespace(accounts=SimpleNamespace(deletion=deletion)),
),
):
result = method(api, request_context)
result = method(api, AccountDeletePayload.model_validate(payload), request_context)
assert result["result"] == "success"
deletion.request_deletion.assert_called_once_with(request_context, token="token", code="123456")
@@ -689,7 +726,7 @@ class TestChangeEmailApis:
),
):
with pytest.raises(EmailCodeError):
method(api, request_context)
method(api, ChangeEmailValidityPayload.model_validate(payload), request_context)
def test_reset_email_already_used(self, app: Flask):
api = ChangeEmailResetApi()
@@ -720,7 +757,7 @@ class TestChangeEmailApis:
),
):
with pytest.raises(EmailAlreadyInUseError):
method(api, request_context)
method(api, ChangeEmailResetPayload.model_validate(payload), request_context)
class TestCheckEmailUniqueApi:
@@ -744,7 +781,7 @@ class TestCheckEmailUniqueApi:
return_value=SimpleNamespace(accounts=SimpleNamespace(change_email=change_email)),
),
):
result = method(api)
result = method(api, CheckEmailUniquePayload.model_validate(payload))
assert result["result"] == "success"
@@ -770,7 +807,7 @@ class TestCheckEmailUniqueApi:
),
):
with pytest.raises(AccountInFreezeError):
method(api)
method(api, CheckEmailUniquePayload.model_validate(payload))
def test_email_domain_is_suspended(self, app: Flask):
api = CheckEmailUnique()
@@ -794,4 +831,4 @@ class TestCheckEmailUniqueApi:
),
):
with pytest.raises(EmailDomainSuspendedError):
method(api)
method(api, CheckEmailUniquePayload.model_validate(payload))