feat: add set phone

This commit is contained in:
2026-05-14 21:45:43 +03:00
parent 6465807394
commit 75362b07ae
10 changed files with 105 additions and 29 deletions

View File

@@ -1 +1,11 @@
from src.application.domain.exceptions.application_exceptions import ApplicationException from src.application.domain.exceptions.application_exceptions import (
ApplicationException,
BadRequestException,
ConflictException,
ForbiddenException,
InternalException,
NotFoundException,
ServiceUnavailableException,
TooManyRequestsException,
UnauthorizedException,
)

View File

@@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
from typing import Mapping from typing import Mapping
@@ -14,5 +15,45 @@ class ApplicationException(Exception):
self.message = message self.message = message
self.headers = headers self.headers = headers
def __str__(self): def __str__(self) -> str:
return f"{self.status_code}: {self.message}" return f'{self.status_code}: {self.message}'
class BadRequestException(ApplicationException):
def __init__(self, message: str, headers: Mapping[str, str] | None = None):
super().__init__(400, message, headers)
class UnauthorizedException(ApplicationException):
def __init__(self, message: str = 'Unauthorized', headers: Mapping[str, str] | None = None):
super().__init__(401, message, headers)
class ForbiddenException(ApplicationException):
def __init__(self, message: str = 'Forbidden', headers: Mapping[str, str] | None = None):
super().__init__(403, message, headers)
class NotFoundException(ApplicationException):
def __init__(self, message: str = 'Not found', headers: Mapping[str, str] | None = None):
super().__init__(404, message, headers)
class ConflictException(ApplicationException):
def __init__(self, message: str, headers: Mapping[str, str] | None = None):
super().__init__(409, message, headers)
class TooManyRequestsException(ApplicationException):
def __init__(self, message: str, headers: Mapping[str, str] | None = None):
super().__init__(429, message, headers)
class ServiceUnavailableException(ApplicationException):
def __init__(self, message: str, headers: Mapping[str, str] | None = None):
super().__init__(503, message, headers)
class InternalException(ApplicationException):
def __init__(self, message: str = 'Internal Server Error', headers: Mapping[str, str] | None = None):
super().__init__(500, message, headers)

View File

@@ -1,10 +1,9 @@
from __future__ import annotations from __future__ import annotations
from fastapi import status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from src.application.contracts import ILogger from src.application.contracts import ILogger
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import ApplicationException, BadRequestException, InternalException, NotFoundException
from src.application.abstractions.repositories import IUserRepository from src.application.abstractions.repositories import IUserRepository
from src.application.domain.entities import UserEntity from src.application.domain.entities import UserEntity
from src.infrastructure.database.models import UserModel from src.infrastructure.database.models import UserModel
@@ -27,7 +26,7 @@ class UserRepository(IUserRepository):
user: UserModel | None = result.scalar_one_or_none() user: UserModel | None = result.scalar_one_or_none()
if user is None: if user is None:
self._logger.warning(f'User not found with user_id {user_id}') self._logger.warning(f'User not found with user_id {user_id}')
raise ApplicationException(status_code=status.HTTP_404_NOT_FOUND, message='User not found') raise NotFoundException(message='User not found')
return user return user
@staticmethod @staticmethod
@@ -60,7 +59,7 @@ class UserRepository(IUserRepository):
raise raise
except SQLAlchemyError as exception: except SQLAlchemyError as exception:
self._logger.exception(str(exception)) self._logger.exception(str(exception))
raise ApplicationException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, message=f'Database error: {str(exception)}') raise InternalException(message=f'Database error: {str(exception)}')
async def _update_field(self, user_id: str, **fields: object) -> UserEntity: async def _update_field(self, user_id: str, **fields: object) -> UserEntity:
try: try:
@@ -74,7 +73,7 @@ class UserRepository(IUserRepository):
raise raise
except SQLAlchemyError as exception: except SQLAlchemyError as exception:
self._logger.exception(str(exception)) self._logger.exception(str(exception))
raise ApplicationException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, message=f'Database error: {str(exception)}') raise InternalException(message=f'Database error: {str(exception)}')
async def set_phone(self, user_id: str, phone: str) -> UserEntity: async def set_phone(self, user_id: str, phone: str) -> UserEntity:
return await self._update_field(user_id, phone=phone) return await self._update_field(user_id, phone=phone)
@@ -83,10 +82,7 @@ class UserRepository(IUserRepository):
allowed = {'passport_data', 'inn', 'erc20'} allowed = {'passport_data', 'inn', 'erc20'}
payload = {k: v for k, v in fields.items() if k in allowed and v is not None} payload = {k: v for k, v in fields.items() if k in allowed and v is not None}
if not payload: if not payload:
raise ApplicationException( raise BadRequestException(message='No identity fields to update')
status_code=status.HTTP_400_BAD_REQUEST,
message='No identity fields to update',
)
return await self._update_field(user_id, **payload) return await self._update_field(user_id, **payload)
async def set_encrypted_mnemonic(self, user_id: str, encrypted_mnemonic: str) -> UserEntity: async def set_encrypted_mnemonic(self, user_id: str, encrypted_mnemonic: str) -> UserEntity:
@@ -100,7 +96,7 @@ class UserRepository(IUserRepository):
raise raise
except SQLAlchemyError as exception: except SQLAlchemyError as exception:
self._logger.exception(str(exception)) self._logger.exception(str(exception))
raise ApplicationException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, message=f'Database error: {str(exception)}') raise InternalException(message=f'Database error: {str(exception)}')
async def set_password(self, user_id: str, password_hash: str) -> UserEntity: async def set_password(self, user_id: str, password_hash: str) -> UserEntity:
return await self._update_field(user_id, password_hash=password_hash) return await self._update_field(user_id, password_hash=password_hash)
@@ -121,4 +117,4 @@ class UserRepository(IUserRepository):
return result.scalar_one_or_none() is not None return result.scalar_one_or_none() is not None
except SQLAlchemyError as exception: except SQLAlchemyError as exception:
self._logger.exception(str(exception)) self._logger.exception(str(exception))
raise ApplicationException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, message=f'Database error: {str(exception)}') raise InternalException(message=f'Database error: {str(exception)}')

View File

@@ -2,18 +2,25 @@ from __future__ import annotations
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import secrets import secrets
from typing import AsyncGenerator from typing import AsyncGenerator
from fastapi import Depends, FastAPI, status from fastapi import Depends, FastAPI
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials from fastapi.security import HTTPBasic, HTTPBasicCredentials
from starlette.middleware.cors import CORSMiddleware from starlette.middleware.cors import CORSMiddleware
from src.application.domain.exceptions import ApplicationException from starlette.exceptions import HTTPException
from fastapi.exceptions import RequestValidationError
from src.application.domain.exceptions import ApplicationException, UnauthorizedException
from src.infrastructure.cache import create_redis_client from src.infrastructure.cache import create_redis_client
from src.infrastructure.vault import JwtKeyStore, start_jwt_keys_scheduler from src.infrastructure.vault import JwtKeyStore, start_jwt_keys_scheduler
from src.infrastructure.utils import generate_instance_id from src.infrastructure.utils import generate_instance_id
from src.infrastructure.logger import logger from src.infrastructure.logger import logger
from src.infrastructure.config import settings from src.infrastructure.config import settings
from src.presentation.handlers import application_exception_handler, unhandled_exception_handler from src.presentation.handlers import (
application_exception_handler,
http_exception_handler,
unhandled_exception_handler,
validation_exception_handler,
)
from src.presentation.middleware import TraceIDMiddleware, SecurityHeadersMiddleware from src.presentation.middleware import TraceIDMiddleware, SecurityHeadersMiddleware
from src.presentation.routing import me_router from src.presentation.routing import me_router
@@ -24,8 +31,7 @@ async def verify_credentials(credentials: HTTPBasicCredentials = Depends(securit
user_ok = secrets.compare_digest(credentials.username, settings.DOCS_USERNAME) user_ok = secrets.compare_digest(credentials.username, settings.DOCS_USERNAME)
pass_ok = secrets.compare_digest(credentials.password, settings.DOCS_PASSWORD) pass_ok = secrets.compare_digest(credentials.password, settings.DOCS_PASSWORD)
if not (user_ok and pass_ok): if not (user_ok and pass_ok):
raise ApplicationException( raise UnauthorizedException(
status_code=status.HTTP_401_UNAUTHORIZED,
message='Unauthorized', message='Unauthorized',
headers={'WWW-Authenticate': 'Basic'}, headers={'WWW-Authenticate': 'Basic'},
) )
@@ -78,6 +84,8 @@ app: FastAPI = FastAPI(
}, },
) )
app.add_exception_handler(RequestValidationError, validation_exception_handler)
app.add_exception_handler(HTTPException, http_exception_handler)
app.add_exception_handler(ApplicationException, application_exception_handler) app.add_exception_handler(ApplicationException, application_exception_handler)
app.add_exception_handler(Exception, unhandled_exception_handler) app.add_exception_handler(Exception, unhandled_exception_handler)

View File

@@ -1,7 +1,7 @@
from fastapi import Depends, Request from fastapi import Depends, Request
from fastapi.security.utils import get_authorization_scheme_param from fastapi.security.utils import get_authorization_scheme_param
from src.application.contracts import IJwtService from src.application.contracts import IJwtService
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import UnauthorizedException
from src.application.domain.dto import AccessTokenPayload, AuthContext from src.application.domain.dto import AccessTokenPayload, AuthContext
from src.presentation.dependencies import get_jwt_service from src.presentation.dependencies import get_jwt_service
@@ -27,10 +27,10 @@ async def require_access_token(
) -> AuthContext: ) -> AuthContext:
token = _extract_access_token(request) token = _extract_access_token(request)
if not token: if not token:
raise ApplicationException(status_code=401, message='Not authenticated') raise UnauthorizedException(message='Not authenticated')
payload: AccessTokenPayload = await jwt_service.decode_access_token(token) payload: AccessTokenPayload = await jwt_service.decode_access_token(token)
if payload.type != 'access': if payload.type != 'access':
raise ApplicationException(status_code=401, message='Invalid token type') raise UnauthorizedException(message='Invalid token type')
return AuthContext(user_id=payload.sub, sid=payload.sid, token=payload) return AuthContext(user_id=payload.sub, sid=payload.sid, token=payload)

View File

@@ -1,2 +1,4 @@
from src.presentation.handlers.unhandled_handler import unhandled_exception_handler from src.presentation.handlers.unhandled_handler import unhandled_exception_handler
from src.presentation.handlers.application_handler import application_exception_handler from src.presentation.handlers.application_handler import application_exception_handler
from src.presentation.handlers.http_exception_handler import http_exception_handler
from src.presentation.handlers.validation_handler import validation_exception_handler

View File

@@ -0,0 +1,11 @@
from fastapi import Request
from fastapi.responses import ORJSONResponse
from starlette.exceptions import HTTPException
async def http_exception_handler(_request: Request, exc: HTTPException) -> ORJSONResponse:
return ORJSONResponse(
status_code=exc.status_code,
content={'detail': exc.detail},
headers=dict(exc.headers) if exc.headers else None,
)

View File

@@ -0,0 +1,10 @@
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import ORJSONResponse
async def validation_exception_handler(_request: Request, exc: RequestValidationError) -> ORJSONResponse:
return ORJSONResponse(
status_code=422,
content={'detail': exc.errors()},
)

View File

@@ -1,13 +1,12 @@
from fastapi import APIRouter from fastapi import APIRouter
from src.presentation.routing.account import account_router from src.presentation.routing.account import account_router
from src.presentation.routing.account_settings import account_settings_router
me_router = APIRouter(prefix='/me', tags=['Account']) me_router = APIRouter(prefix='/me', tags=['Account'])
me_router.include_router(account_router) me_router.include_router(account_router)
me_router.include_router(account_settings_router)
# from src.presentation.routing.account_settings import account_settings_router
# me_router.include_router(account_settings_router)
# from src.presentation.routing.devices import devices_router # from src.presentation.routing.devices import devices_router
# me_devices_router = APIRouter(prefix='/me', tags=['Devices']) # me_devices_router = APIRouter(prefix='/me', tags=['Devices'])

View File

@@ -1,6 +1,5 @@
import re import re
from pydantic import BaseModel, field_validator from pydantic import BaseModel, field_validator
from src.application.domain.exceptions import ApplicationException
class SetPhoneRequest(BaseModel): class SetPhoneRequest(BaseModel):
@@ -12,6 +11,6 @@ class SetPhoneRequest(BaseModel):
cleaned = re.sub(r'[\s\-\(\)]', '', v) cleaned = re.sub(r'[\s\-\(\)]', '', v)
pattern = r'^(\+7|8)\d{10}$' pattern = r'^(\+7|8)\d{10}$'
if not re.match(pattern, cleaned): if not re.match(pattern, cleaned):
raise ApplicationException(message='Invalid Russian phone number', status_code=429) raise ValueError('Invalid Russian phone number')
normalized = '+7' + cleaned[-10:] normalized = '+7' + cleaned[-10:]
return normalized return normalized