diff --git a/pyproject.toml b/pyproject.toml index 1fd0c6c..76d5ac9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,11 +21,13 @@ dependencies = [ "alembic>=1.18.5", "asyncpg>=0.31", "bcrypt>=5", + "geoalchemy2>=0.20", "granian>=2.7.2", "litestar>=2.21", "pydantic>=2.12.5", "pydantic-settings>=2.12", "pyjwt>=2.13", + "shapely>=2.1.2", "sqlalchemy[asyncio]>=2.0.51", ] diff --git a/src/app/interface/http/__init__.py b/src/app/interface/http/__init__.py index cdc6be5..7762b29 100644 --- a/src/app/interface/http/__init__.py +++ b/src/app/interface/http/__init__.py @@ -1,9 +1,11 @@ -from . import asgi, controller, schema, util +from . import asgi, controller, lifespan, middleware, schema, util __all__ = ( "asgi", "controller", + "lifespan", + "middleware", "schema", "util", ) diff --git a/src/app/interface/http/asgi.py b/src/app/interface/http/asgi.py index b5787dc..2a6820e 100644 --- a/src/app/interface/http/asgi.py +++ b/src/app/interface/http/asgi.py @@ -2,17 +2,15 @@ from __future__ import annotations -from contextlib import asynccontextmanager -from typing import TYPE_CHECKING - from litestar import Litestar from litestar.openapi import OpenAPIConfig from litestar.openapi.plugins import ScalarRenderPlugin from app import __api_version__ from app.interface.http.controller.system import SystemController +from app.interface.http.lifespan import lifespan +from app.interface.http.middleware import RequestLoggingMiddleware from app.interface.http.util import ( - RequestLoggingMiddleware, create_exception_handlers, get_all_application_error_mappings, get_all_dependencies, @@ -20,30 +18,7 @@ ) from app.module.identity.interface.http.controller.auth import AuthController from app.module.identity.interface.http.controller.user import UserController -from app.platform.config.loaders import load_app_config -from app.platform.database.engine import create_async_engine_from_config, dispose_engine -from app.platform.logging import configure_logging - - -if TYPE_CHECKING: - from collections.abc import AsyncGenerator - - -@asynccontextmanager -async def lifespan(app: Litestar) -> AsyncGenerator[None]: - """Application lifespan: create engine on startup, dispose on shutdown.""" - # ── Startup ────────────────────────────────────────────────────── - config = load_app_config() - configure_logging(config.logging) - - engine = create_async_engine_from_config(config.database) - app.state.engine = engine - - try: - yield - finally: - # ── Shutdown ───────────────────────────────────────────────── - await dispose_engine(engine) +from app.module.parcel.interface.http.controller.parcel import ParcelController def create_asgi_application() -> Litestar: @@ -59,6 +34,7 @@ def create_asgi_application() -> Litestar: SystemController, AuthController, UserController, + ParcelController, ], dependencies=get_all_dependencies(), openapi_config=OpenAPIConfig( diff --git a/src/app/interface/http/lifespan.py b/src/app/interface/http/lifespan.py new file mode 100644 index 0000000..6981386 --- /dev/null +++ b/src/app/interface/http/lifespan.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING + +from app.platform.config.loaders import load_app_config +from app.platform.database.engine import create_async_engine_from_config, dispose_engine +from app.platform.logging import configure_logging + + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from litestar import Litestar + + +@asynccontextmanager +async def lifespan(app: Litestar) -> AsyncGenerator[None]: + """Application lifespan: create engine on startup, dispose on shutdown.""" + config = load_app_config() + configure_logging(config.logging) + + engine = create_async_engine_from_config(config.database) + app.state.engine = engine + + try: + yield + finally: + await dispose_engine(engine) + + +__all__ = ("lifespan",) diff --git a/src/app/interface/http/util/middleware.py b/src/app/interface/http/middleware.py similarity index 100% rename from src/app/interface/http/util/middleware.py rename to src/app/interface/http/middleware.py diff --git a/src/app/interface/http/util/__init__.py b/src/app/interface/http/util/__init__.py index da210d5..5928871 100644 --- a/src/app/interface/http/util/__init__.py +++ b/src/app/interface/http/util/__init__.py @@ -1,12 +1,10 @@ from .dependencies import get_all_dependencies from .error_mappings import get_all_application_error_mappings, get_all_domain_error_mappings from .exception_handlers import create_exception_handlers -from .middleware import RequestLoggingMiddleware from .response import make_error_response __all__ = ( - "RequestLoggingMiddleware", "create_exception_handlers", "get_all_application_error_mappings", "get_all_dependencies", diff --git a/src/app/interface/http/util/dependencies.py b/src/app/interface/http/util/dependencies.py index 36c1855..70f825b 100644 --- a/src/app/interface/http/util/dependencies.py +++ b/src/app/interface/http/util/dependencies.py @@ -1,13 +1,47 @@ """Dependency assembly for the application.""" -from typing import TYPE_CHECKING +from typing import Annotated + +from litestar.di import Provide +from litestar.params import HeaderParameter from app.module.identity.di import identity_dependencies +from app.module.parcel.di import parcel_dependencies +from app.module.shared.application.dto.response import CurrentUser # noqa: TC001 +from app.module.shared.application.port import CurrentUserProvider # noqa: TC001 from app.platform.di import platform_dependencies -if TYPE_CHECKING: - from litestar.di import Provide +async def provide_current_user( + current_user_provider: CurrentUserProvider, + authorization: Annotated[str | None, HeaderParameter(name="Authorization")] = None, +) -> CurrentUser: + """Resolve the current user from the Authorization header. + + This dependency is automatically available to all route handlers + that declare a ``current_user`` parameter. It extracts the Bearer + token from the header and delegates to ``CurrentUserProvider`` for + token validation and user resolution. + + Parameters + ---------- + current_user_provider : CurrentUserProvider + Injected port for resolving the current user. + authorization : str | None + The raw Authorization header value, if present. + + Returns + ------- + CurrentUser + The authenticated user's id and username. + + Raises + ------ + litestar.exceptions.NotAuthorizedException + If the token is missing, invalid, or expired (via exception handler). + """ + token = (authorization or "").removeprefix("Bearer ") + return await current_user_provider.get_current_user(token) def get_all_dependencies() -> dict[str, Provide]: @@ -21,4 +55,6 @@ def get_all_dependencies() -> dict[str, Provide]: dependencies = {} dependencies.update(platform_dependencies) dependencies.update(identity_dependencies) + dependencies.update(parcel_dependencies) + dependencies["current_user"] = Provide(provide_current_user) return dependencies diff --git a/src/app/interface/http/util/error_mappings.py b/src/app/interface/http/util/error_mappings.py index 5e5d451..ea68466 100644 --- a/src/app/interface/http/util/error_mappings.py +++ b/src/app/interface/http/util/error_mappings.py @@ -1,9 +1,7 @@ """Error mappings assembly for the application.""" -from app.module.identity.error_mappings import ( - get_identity_application_error_mappings, - get_identity_domain_error_mappings, -) +from app.module.identity.error_mappings import get_identity_application_error_mappings +from app.module.parcel.error_mappings import get_parcel_application_error_mappings from app.module.shared.interface.http.error_mappings import ( get_shared_application_error_mappings, get_shared_domain_error_mappings, @@ -20,7 +18,6 @@ def get_all_domain_error_mappings() -> dict: """ mappings = {} mappings.update(get_shared_domain_error_mappings()) - mappings.update(get_identity_domain_error_mappings()) return mappings @@ -35,4 +32,5 @@ def get_all_application_error_mappings() -> dict: mappings = {} mappings.update(get_shared_application_error_mappings()) mappings.update(get_identity_application_error_mappings()) + mappings.update(get_parcel_application_error_mappings()) return mappings diff --git a/src/app/interface/http/util/exception_handlers.py b/src/app/interface/http/util/exception_handlers.py index 02453dd..98091ae 100644 --- a/src/app/interface/http/util/exception_handlers.py +++ b/src/app/interface/http/util/exception_handlers.py @@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, cast from litestar import Request, Response, status_codes +from litestar.exceptions import HTTPException, NotAuthorizedException, SerializationException from app.interface.http.util.response import make_error_response from app.module.shared.application.error import ApplicationError @@ -71,6 +72,49 @@ def handler(request: Request, exc: ApplicationError) -> Response: return cast("ExceptionHandler", handler) +def not_authorized_handler( + request: Request, + exc: NotAuthorizedException, +) -> Response: + """Handle unauthorized requests.""" + logger = get_logger("app.interface.http.exception_handlers") + logger.warning("Unauthorized access attempt: %s %s", request.method, request) + return make_error_response( + status_codes.HTTP_401_UNAUTHORIZED, + exc.detail or "Authorization required", + ) + + +def http_exception_handler(request: Request, exc: HTTPException) -> Response: + """Handle any HTTP exception.""" + logger = get_logger("app.interface.http.exception_handlers") + logger.warning( + "HTTP exception %s: %s while processing %s %s", + exc.status_code, + exc.detail, + request.method, + request.url, + ) + return make_error_response(exc.status_code, exc.detail or "HTTP error") + + +def serialization_exception_handler( + request: Request, + _exc: SerializationException, +) -> Response: + """Handle malformed JSON.""" + logger = get_logger("app.interface.http.exception_handlers") + logger.warning( + "Malformed JSON while processing %s %s", + request.method, + request.url, + ) + return make_error_response( + status_codes.HTTP_400_BAD_REQUEST, + "Invalid JSON payload. Please check your request body.", + ) + + def _internal_server_error_handler(request: Request, exc: Exception) -> Response: """Handle unexpected errors with full traceback logging.""" logger = get_logger("app.interface.http.exception_handlers") @@ -109,6 +153,12 @@ def create_exception_handlers( if application_mappings: handlers[ApplicationError] = _create_application_error_handler(application_mappings) + # HTTP-исключения от Litestar + handlers[NotAuthorizedException] = cast("ExceptionHandler", not_authorized_handler) + handlers[HTTPException] = cast("ExceptionHandler", http_exception_handler) + handlers[SerializationException] = cast("ExceptionHandler", serialization_exception_handler) + + # Fallback handlers[Exception] = _internal_server_error_handler return handlers diff --git a/src/app/module/identity/application/port/password_hasher.py b/src/app/module/identity/application/port/password_hasher.py index c29435f..21ab639 100644 --- a/src/app/module/identity/application/port/password_hasher.py +++ b/src/app/module/identity/application/port/password_hasher.py @@ -1,41 +1,52 @@ """Password hasher port.""" +from __future__ import annotations + from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from app.module.identity.domain.value_object import HashedPassword, Password class PasswordHasher(ABC): """Port for password hashing. + Operates on domain :class:`~app.module.identity.domain.value_object.password.Password` + and :class:`~app.module.identity.domain.value_object.hashed_password.HashedPassword` + value objects. + Implementations: - - :class:`app.module.identity.infrastructure.auth.password_hasher.BcryptPasswordHasher` + - :class:`app.module.identity.infrastructure.security.bcrypt_password_hasher.BcryptPasswordHasher` """ @abstractmethod - def hash(self, password: str) -> str: - """Hash a plain-text password. + def hash(self, password: Password) -> HashedPassword: + """Hash a password. Parameters ---------- - password : str - Plain-text password. + password : Password + Domain password value object. Returns ------- - str - Hashed password. + HashedPassword + Hashed password value object. """ raise NotImplementedError @abstractmethod - def verify(self, password: str, hashed: str) -> bool: - """Verify a plain-text password against a hash. + def verify(self, password: Password, hashed: HashedPassword) -> bool: + """Verify a password against a hash. Parameters ---------- - password : str - Plain-text password to verify. - hashed : str - Stored hash to verify against. + password : Password + Domain password value object to verify. + hashed : HashedPassword + Domain hashed password value object to verify against. Returns ------- diff --git a/src/app/module/identity/application/port/token_service.py b/src/app/module/identity/application/port/token_service.py index b3f99bd..a55cef3 100644 --- a/src/app/module/identity/application/port/token_service.py +++ b/src/app/module/identity/application/port/token_service.py @@ -1,23 +1,33 @@ """Token service port.""" +from __future__ import annotations + from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from app.module.identity.domain.value_object import UserId class TokenService(ABC): """Port for token creation and verification. + Operates on domain :class:`~app.module.identity.domain.value_object.user_id.UserId` + value object. + Implementations: - :class:`app.module.identity.infrastructure.security.jwt_token_service.JWTTokenService` """ @abstractmethod - def create_access_token(self, user_id: str, claims: dict | None = None) -> str: + def create_access_token(self, user_id: UserId, claims: dict | None = None) -> str: """Create an access token. Parameters ---------- - user_id : str - User identifier to embed in the token. + user_id : UserId + User identifier. claims : dict | None Additional claims to include. @@ -29,13 +39,13 @@ def create_access_token(self, user_id: str, claims: dict | None = None) -> str: raise NotImplementedError @abstractmethod - def create_refresh_token(self, user_id: str) -> str: + def create_refresh_token(self, user_id: UserId) -> str: """Create a refresh token. Parameters ---------- - user_id : str - User identifier to embed in the token. + user_id : UserId + User identifier. Returns ------- diff --git a/src/app/module/identity/application/use_case/authenticate_user.py b/src/app/module/identity/application/use_case/authenticate_user.py index c2e63d2..c91fdbf 100644 --- a/src/app/module/identity/application/use_case/authenticate_user.py +++ b/src/app/module/identity/application/use_case/authenticate_user.py @@ -1,5 +1,7 @@ """Authenticate user use case.""" +from __future__ import annotations + from typing import TYPE_CHECKING, override from app.module.identity.application.dto.command import AuthenticateUserCommand @@ -10,6 +12,7 @@ Username, ) from app.module.shared.application.use_case import BaseUseCase +from app.module.shared.domain.error import ValidationError from app.platform.logging import get_logger @@ -40,8 +43,12 @@ def __init__( @override async def __call__(self, command: AuthenticateUserCommand) -> TokenResponse: - username = Username(command.username) - password = Password(command.password) + try: + username = Username(command.username) + password = Password(command.password) + except ValidationError as e: + self._logger.warning("Authentication failed: invalid format: %s", e) + raise AuthenticationError from e self._logger.info("Authentication attempt: username=%s", command.username) @@ -50,13 +57,12 @@ async def __call__(self, command: AuthenticateUserCommand) -> TokenResponse: self._logger.warning("Authentication failed: user not found: %s", command.username) raise AuthenticationError - if not self._password_hasher.verify(password.unwrap(), user.hashed_password.unwrap()): + if not self._password_hasher.verify(password, user.hashed_password): self._logger.warning("Authentication failed: invalid password: %s", command.username) raise AuthenticationError - user_id_str = str(user.id.unwrap()) - access_token = self._token_service.create_access_token(user_id_str) - refresh_token = self._token_service.create_refresh_token(user_id_str) + access_token = self._token_service.create_access_token(user.id) + refresh_token = self._token_service.create_refresh_token(user.id) self._logger.info("Authentication successful: username=%s", command.username) diff --git a/src/app/module/identity/application/use_case/refresh_token.py b/src/app/module/identity/application/use_case/refresh_token.py index ef3ed0f..a750b0f 100644 --- a/src/app/module/identity/application/use_case/refresh_token.py +++ b/src/app/module/identity/application/use_case/refresh_token.py @@ -1,6 +1,9 @@ """Refresh token use case.""" +from __future__ import annotations + from typing import TYPE_CHECKING, override +from uuid import UUID from app.module.identity.application.dto.command import RefreshTokenCommand from app.module.identity.application.dto.response import TokenResponse @@ -9,6 +12,7 @@ RefreshTokenPayloadError, RefreshTokenTypeError, ) +from app.module.identity.domain.value_object import UserId from app.module.shared.application.use_case import BaseUseCase from app.platform.logging import get_logger @@ -45,15 +49,17 @@ async def __call__(self, command: RefreshTokenCommand) -> TokenResponse: self._logger.warning("Token is not a refresh token") raise RefreshTokenTypeError - user_id = payload.get("sub") - if not user_id: + user_id_str = payload.get("sub") + if not user_id_str: self._logger.warning("Refresh token does not contain user ID") raise RefreshTokenPayloadError + user_id = UserId(UUID(user_id_str)) + access_token = self._token_service.create_access_token(user_id) refresh_token = self._token_service.create_refresh_token(user_id) - self._logger.info("Token refreshed successfully: user_id=%s", user_id) + self._logger.info("Token refreshed successfully: user_id=%s", user_id_str) return TokenResponse( access_token=access_token, diff --git a/src/app/module/identity/application/use_case/register_user.py b/src/app/module/identity/application/use_case/register_user.py index b03cd78..0c8ce47 100644 --- a/src/app/module/identity/application/use_case/register_user.py +++ b/src/app/module/identity/application/use_case/register_user.py @@ -1,5 +1,7 @@ """Register user use case.""" +from __future__ import annotations + from typing import TYPE_CHECKING, override from uuid import uuid6 @@ -8,7 +10,6 @@ from app.module.identity.application.error import UserAlreadyExistsError from app.module.identity.domain.entity import User from app.module.identity.domain.value_object import ( - HashedPassword, Password, UserId, Username, @@ -53,7 +54,7 @@ async def __call__(self, command: RegisterUserCommand) -> UserResponse: raise UserAlreadyExistsError(command.username) user_id = UserId(uuid6()) - hashed = HashedPassword(self._password_hasher.hash(password.unwrap())) + hashed = self._password_hasher.hash(password) user = User(id=user_id, username=username, hashed_password=hashed) diff --git a/src/app/module/identity/di.py b/src/app/module/identity/di.py index f126072..9bd985e 100644 --- a/src/app/module/identity/di.py +++ b/src/app/module/identity/di.py @@ -75,14 +75,14 @@ def provide_refresh_token_use_case( # Словарь зависимостей модуля identity_dependencies = { - "user_repository": Provide(provide_postgres_user_repository), - "password_hasher": Provide(provide_bcrypt_password_hasher), - "token_service": Provide(provide_jwt_token_service), - "current_user_provider": Provide(provide_jwt_current_user_provider), - "register_user_use_case": Provide(provide_register_use_case), - "authenticate_user_use_case": Provide(provide_authenticate_use_case), - "get_user_use_case": Provide(provide_get_user_use_case), - "refresh_token_use_case": Provide(provide_refresh_token_use_case), + "user_repository": Provide(provide_postgres_user_repository, sync_to_thread=False), + "password_hasher": Provide(provide_bcrypt_password_hasher, sync_to_thread=False), + "token_service": Provide(provide_jwt_token_service, sync_to_thread=False), + "current_user_provider": Provide(provide_jwt_current_user_provider, sync_to_thread=False), + "register_user_use_case": Provide(provide_register_use_case, sync_to_thread=False), + "authenticate_user_use_case": Provide(provide_authenticate_use_case, sync_to_thread=False), + "get_user_use_case": Provide(provide_get_user_use_case, sync_to_thread=False), + "refresh_token_use_case": Provide(provide_refresh_token_use_case, sync_to_thread=False), } __all__ = ("identity_dependencies",) diff --git a/src/app/module/identity/domain/value_object/__init__.py b/src/app/module/identity/domain/value_object/__init__.py index b22fc5d..36381a5 100644 --- a/src/app/module/identity/domain/value_object/__init__.py +++ b/src/app/module/identity/domain/value_object/__init__.py @@ -1,5 +1,3 @@ -from .email import Email -from .hashed_authentication_key import HashedAuthenticationKey from .hashed_password import HashedPassword from .password import Password from .user_id import UserId @@ -7,8 +5,6 @@ __all__ = ( - "Email", - "HashedAuthenticationKey", "HashedPassword", "Password", "UserId", diff --git a/src/app/module/identity/domain/value_object/email.py b/src/app/module/identity/domain/value_object/email.py deleted file mode 100644 index 8fae958..0000000 --- a/src/app/module/identity/domain/value_object/email.py +++ /dev/null @@ -1,27 +0,0 @@ -import re -from typing import override - -from app.module.shared.domain.error import ValidationError -from app.module.shared.domain.value_object import BaseValueObject - - -class Email(BaseValueObject[str]): - """Value object for an email address.""" - - _PATTERN = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$") - - @override - def _normalize(self, value: str) -> str: - return value.lower().strip() - - @override - def _validate(self) -> None: - if not self._value: - message = "Email must not be empty." - raise ValidationError(message) - if not self._PATTERN.match(self._value): - message = f"Invalid email format: {self._value}" - raise ValidationError(message) - - -__all__ = ("Email",) diff --git a/src/app/module/identity/domain/value_object/hashed_authentication_key.py b/src/app/module/identity/domain/value_object/hashed_authentication_key.py deleted file mode 100644 index 39d4355..0000000 --- a/src/app/module/identity/domain/value_object/hashed_authentication_key.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import override - -from app.module.shared.domain.error import ValidationError -from app.module.shared.domain.value_object import BaseValueObject - - -class HashedAuthenticationKey(BaseValueObject[str]): - """Value object for a hashed authentication key.""" - - @override - def _normalize(self, value: str) -> str: - return value - - @override - def _validate(self) -> None: - if not self._value: - message = "Hashed authentication key must not be empty." - raise ValidationError(message) - - -__all__ = ("HashedAuthenticationKey",) diff --git a/src/app/module/identity/error_mappings.py b/src/app/module/identity/error_mappings.py index cdd9272..96cb041 100644 --- a/src/app/module/identity/error_mappings.py +++ b/src/app/module/identity/error_mappings.py @@ -5,7 +5,6 @@ from typing import TYPE_CHECKING from litestar.status_codes import ( - HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT, @@ -16,24 +15,10 @@ UserAlreadyExistsError, UserNotFoundError, ) -from app.module.shared.domain.error import ValidationError if TYPE_CHECKING: from app.module.shared.application.error import ApplicationError - from app.module.shared.domain.error import DomainError - - -def get_identity_domain_error_mappings() -> dict[type[DomainError], int]: - """Return domain error to HTTP status mappings for this module. - - Returns - ------- - dict[type[DomainError], int] - """ - return { - ValidationError: HTTP_400_BAD_REQUEST, - } def get_identity_application_error_mappings() -> dict[type[ApplicationError], int]: @@ -50,7 +35,4 @@ def get_identity_application_error_mappings() -> dict[type[ApplicationError], in } -__all__ = ( - "get_identity_application_error_mappings", - "get_identity_domain_error_mappings", -) +__all__ = ("get_identity_application_error_mappings",) diff --git a/src/app/module/identity/infrastructure/security/bcrypt_password_hasher.py b/src/app/module/identity/infrastructure/security/bcrypt_password_hasher.py index f6fb100..f0a1443 100644 --- a/src/app/module/identity/infrastructure/security/bcrypt_password_hasher.py +++ b/src/app/module/identity/infrastructure/security/bcrypt_password_hasher.py @@ -1,24 +1,28 @@ """Bcrypt password hasher implementation.""" +from __future__ import annotations + from typing import override import bcrypt from app.module.identity.application.port import PasswordHasher +from app.module.identity.domain.value_object import HashedPassword, Password class BcryptPasswordHasher(PasswordHasher): """Hash and verify passwords using bcrypt.""" @override - def hash(self, password: str) -> str: + def hash(self, password: Password) -> HashedPassword: """See :class:`app.module.identity.application.port.PasswordHasher.hash`.""" - return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + hashed = bcrypt.hashpw(password.unwrap().encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + return HashedPassword(hashed) @override - def verify(self, password: str, hashed: str) -> bool: + def verify(self, password: Password, hashed: HashedPassword) -> bool: """See :class:`app.module.identity.application.port.PasswordHasher.verify`.""" - return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8")) + return bcrypt.checkpw(password.unwrap().encode("utf-8"), hashed.unwrap().encode("utf-8")) __all__ = ("BcryptPasswordHasher",) diff --git a/src/app/module/identity/infrastructure/security/jwt_current_user_provider.py b/src/app/module/identity/infrastructure/security/jwt_current_user_provider.py index 39ad2e0..06aaf3f 100644 --- a/src/app/module/identity/infrastructure/security/jwt_current_user_provider.py +++ b/src/app/module/identity/infrastructure/security/jwt_current_user_provider.py @@ -5,6 +5,7 @@ from app.module.identity.application.error import AuthenticationError, UserNotFoundError from app.module.identity.domain.value_object import UserId +from app.module.shared.application.dto.response import CurrentUser from app.module.shared.application.port import CurrentUserProvider from app.platform.logging import get_logger @@ -14,7 +15,7 @@ class JWTCurrentUserProvider(CurrentUserProvider): - """Provides current user ID from a JWT token. + """Provides current user from a JWT token. Parameters ---------- @@ -34,8 +35,8 @@ def __init__( self._logger = get_logger("app.identity.infrastructure.jwt_current_user_provider") @override - async def get_current_user_id(self, token: str) -> str: - """Resolve a user ID from a JWT token. + async def get_current_user(self, token: str) -> CurrentUser: + """Resolve a current user from a JWT token. Parameters ---------- @@ -44,8 +45,8 @@ async def get_current_user_id(self, token: str) -> str: Returns ------- - str - The user ID string. + CurrentUser + The authenticated user's id and username. Raises ------ @@ -73,7 +74,7 @@ async def get_current_user_id(self, token: str) -> str: raise UserNotFoundError(user_id) self._logger.info("Token verified successfully: user_id=%s", user_id) - return user_id + return CurrentUser(id=user_id, username=user.username.unwrap()) __all__ = ("JWTCurrentUserProvider",) diff --git a/src/app/module/identity/infrastructure/security/jwt_token_service.py b/src/app/module/identity/infrastructure/security/jwt_token_service.py index 8107f4a..73cde4c 100644 --- a/src/app/module/identity/infrastructure/security/jwt_token_service.py +++ b/src/app/module/identity/infrastructure/security/jwt_token_service.py @@ -1,5 +1,7 @@ """JWT token service implementation.""" +from __future__ import annotations + from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, override @@ -9,6 +11,7 @@ if TYPE_CHECKING: + from app.module.identity.domain.value_object import UserId from app.platform.config.models import AuthConfig @@ -19,10 +22,10 @@ def __init__(self, config: AuthConfig) -> None: self._config = config @override - def create_access_token(self, user_id: str, claims: dict | None = None) -> str: + def create_access_token(self, user_id: UserId, claims: dict | None = None) -> str: """See :class:`app.module.identity.application.port.TokenService.create_access_token`.""" payload = { - "sub": user_id, + "sub": str(user_id.unwrap()), "iat": datetime.now(UTC), "exp": datetime.now(UTC) + timedelta(minutes=self._config.access_token_expire_minutes), "type": "access", @@ -33,10 +36,10 @@ def create_access_token(self, user_id: str, claims: dict | None = None) -> str: return jwt.encode(payload, self._config.secret_key.get_secret_value(), algorithm=self._config.algorithm) @override - def create_refresh_token(self, user_id: str) -> str: + def create_refresh_token(self, user_id: UserId) -> str: """See :class:`app.module.identity.application.port.TokenService.create_refresh_token`.""" payload = { - "sub": user_id, + "sub": str(user_id.unwrap()), "iat": datetime.now(UTC), "exp": datetime.now(UTC) + timedelta(days=self._config.refresh_token_expire_days), "type": "refresh", diff --git a/src/app/module/identity/interface/http/controller/user.py b/src/app/module/identity/interface/http/controller/user.py index 7c16779..6e4b617 100644 --- a/src/app/module/identity/interface/http/controller/user.py +++ b/src/app/module/identity/interface/http/controller/user.py @@ -1,17 +1,15 @@ """User profile endpoints.""" -from typing import Annotated - from litestar import get from litestar.controller import Controller from litestar.di import NamedDependency -from litestar.params import HeaderParameter from litestar.status_codes import HTTP_200_OK from app.module.identity.application.dto.command import GetUserCommand from app.module.identity.application.use_case import GetUserUseCase from app.module.identity.interface.http.schema.user import UserResponse -from app.module.shared.application.port import CurrentUserProvider +from app.module.shared.application.dto.response import CurrentUser +from app.module.shared.interface.http.guards import require_authorization class UserController(Controller): @@ -19,6 +17,7 @@ class UserController(Controller): path = "/api/v1/users" tags = ("users",) + guards = [require_authorization] # noqa: RUF012 @get( "/profile", @@ -28,8 +27,7 @@ class UserController(Controller): async def get_profile( self, get_user_use_case: NamedDependency[GetUserUseCase], - current_user_provider: NamedDependency[CurrentUserProvider], - authorization: Annotated[str, HeaderParameter(name="Authorization", required=True)], + current_user: CurrentUser, ) -> UserResponse: """Get the profile of the currently authenticated user. @@ -37,19 +35,15 @@ async def get_profile( ---------- get_user_use_case : GetUserUseCase Injected use case. - current_user_provider : CurrentUserProvider - Injected provider for extracting user ID from the token. - authorization : str | None - Raw Authorization header value (injected by Litestar via Parameter). + current_user : CurrentUser + The currently authenticated user (resolved from token). Returns ------- UserResponse User profile data. """ - token = (authorization or "").removeprefix("Bearer ") - current_user_id = await current_user_provider.get_current_user_id(token) - command = GetUserCommand(user_id=current_user_id) + command = GetUserCommand(user_id=current_user.id) result = await get_user_use_case(command) return UserResponse(id=result.id, username=result.username) diff --git a/src/app/module/parcel/__init__.py b/src/app/module/parcel/__init__.py new file mode 100644 index 0000000..cadcf61 --- /dev/null +++ b/src/app/module/parcel/__init__.py @@ -0,0 +1,4 @@ +from . import application, domain, infrastructure, interface + + +__all__ = ("application", "domain", "infrastructure", "interface") diff --git a/src/app/module/parcel/application/__init__.py b/src/app/module/parcel/application/__init__.py new file mode 100644 index 0000000..064e5d4 --- /dev/null +++ b/src/app/module/parcel/application/__init__.py @@ -0,0 +1,9 @@ +from . import dto, error, port, use_case + + +__all__ = ( + "dto", + "error", + "port", + "use_case", +) diff --git a/src/app/module/parcel/application/dto/__init__.py b/src/app/module/parcel/application/dto/__init__.py new file mode 100644 index 0000000..65f83f8 --- /dev/null +++ b/src/app/module/parcel/application/dto/__init__.py @@ -0,0 +1,7 @@ +from . import command, response + + +__all__ = ( + "command", + "response", +) diff --git a/src/app/module/parcel/application/dto/command/__init__.py b/src/app/module/parcel/application/dto/command/__init__.py new file mode 100644 index 0000000..d72f1bc --- /dev/null +++ b/src/app/module/parcel/application/dto/command/__init__.py @@ -0,0 +1,12 @@ +from .create_parcel import CreateParcelCommand +from .delete_parcel import DeleteParcelCommand +from .get_parcel import GetParcelCommand +from .list_user_parcels import ListUserParcelsCommand + + +__all__ = ( + "CreateParcelCommand", + "DeleteParcelCommand", + "GetParcelCommand", + "ListUserParcelsCommand", +) diff --git a/src/app/module/parcel/application/dto/command/create_parcel.py b/src/app/module/parcel/application/dto/command/create_parcel.py new file mode 100644 index 0000000..839be6b --- /dev/null +++ b/src/app/module/parcel/application/dto/command/create_parcel.py @@ -0,0 +1,25 @@ +"""Create parcel command.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class CreateParcelCommand: + """Command for creating a new parcel. + + Attributes + ---------- + name : str + Human-readable name of the parcel. + polygon : dict + GeoJSON Polygon geometry. + owner_id : str + ID of the user who owns this parcel. + """ + + name: str + polygon: dict + owner_id: str + + +__all__ = ("CreateParcelCommand",) diff --git a/src/app/module/parcel/application/dto/command/delete_parcel.py b/src/app/module/parcel/application/dto/command/delete_parcel.py new file mode 100644 index 0000000..7493958 --- /dev/null +++ b/src/app/module/parcel/application/dto/command/delete_parcel.py @@ -0,0 +1,22 @@ +"""Delete parcel command.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class DeleteParcelCommand: + """Command for deleting a parcel. + + Attributes + ---------- + parcel_id : str + Parcel identifier. + current_user_id : str + ID of the user requesting the deletion (for ownership check). + """ + + parcel_id: str + current_user_id: str + + +__all__ = ("DeleteParcelCommand",) diff --git a/src/app/module/parcel/application/dto/command/get_parcel.py b/src/app/module/parcel/application/dto/command/get_parcel.py new file mode 100644 index 0000000..03fd2c7 --- /dev/null +++ b/src/app/module/parcel/application/dto/command/get_parcel.py @@ -0,0 +1,22 @@ +"""Get parcel command.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class GetParcelCommand: + """Command for retrieving a parcel by ID. + + Attributes + ---------- + parcel_id : str + Parcel identifier. + current_user_id : str + ID of the user requesting the parcel (for ownership check). + """ + + parcel_id: str + current_user_id: str + + +__all__ = ("GetParcelCommand",) diff --git a/src/app/module/parcel/application/dto/command/list_user_parcels.py b/src/app/module/parcel/application/dto/command/list_user_parcels.py new file mode 100644 index 0000000..f004d54 --- /dev/null +++ b/src/app/module/parcel/application/dto/command/list_user_parcels.py @@ -0,0 +1,19 @@ +"""List user parcels command DTO.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class ListUserParcelsCommand: + """Command to list all parcels owned by a specific user. + + Attributes + ---------- + owner_id : str + Owner identifier. + """ + + owner_id: str + + +__all__ = ("ListUserParcelsCommand",) diff --git a/src/app/module/parcel/application/dto/response/__init__.py b/src/app/module/parcel/application/dto/response/__init__.py new file mode 100644 index 0000000..e7f0673 --- /dev/null +++ b/src/app/module/parcel/application/dto/response/__init__.py @@ -0,0 +1,4 @@ +from .parcel import ParcelResponse + + +__all__ = ("ParcelResponse",) diff --git a/src/app/module/parcel/application/dto/response/parcel.py b/src/app/module/parcel/application/dto/response/parcel.py new file mode 100644 index 0000000..59ad820 --- /dev/null +++ b/src/app/module/parcel/application/dto/response/parcel.py @@ -0,0 +1,28 @@ +"""Parcel response DTO.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class ParcelResponse: + """Response DTO for a parcel. + + Attributes + ---------- + id : str + Parcel identifier. + name : str + Human-readable name of the parcel. + polygon : dict + GeoJSON Polygon geometry. + owner_id : str + ID of the user who owns this parcel. + """ + + id: str + name: str + polygon: dict + owner_id: str + + +__all__ = ("ParcelResponse",) diff --git a/src/app/module/parcel/application/error.py b/src/app/module/parcel/application/error.py new file mode 100644 index 0000000..9b3754a --- /dev/null +++ b/src/app/module/parcel/application/error.py @@ -0,0 +1,47 @@ +"""Parcel module application errors.""" + +from app.module.shared.application.error import ApplicationError + + +class ParcelNotFoundError(ApplicationError): + """Raised when a parcel is not found.""" + + def __init__(self, parcel_id: str) -> None: + super().__init__(f"Parcel with id '{parcel_id}' not found.") + + +class ParcelAlreadyExistsError(ApplicationError): + """Raised when a parcel with the given name already exists.""" + + def __init__(self, name: str) -> None: + super().__init__(f"Parcel with name '{name}' already exists.") + + +class InvalidGeoJsonError(ApplicationError): + """Raised when the provided GeoJSON is malformed or not a valid Polygon.""" + + def __init__(self, reason: str) -> None: + super().__init__(f"Invalid GeoJSON: {reason}.") + + +class InvalidPolygonError(ApplicationError): + """Raised when the polygon geometry is invalid.""" + + def __init__(self, reason: str) -> None: + super().__init__(f"Polygon is not valid: {reason}.") + + +class NotParcelOwnerError(ApplicationError): + """Raised when a user tries to modify a parcel they do not own.""" + + def __init__(self, parcel_id: str) -> None: + super().__init__(f"User is not the owner of parcel '{parcel_id}'.") + + +__all__ = ( + "InvalidGeoJsonError", + "InvalidPolygonError", + "NotParcelOwnerError", + "ParcelAlreadyExistsError", + "ParcelNotFoundError", +) diff --git a/src/app/module/parcel/application/port/__init__.py b/src/app/module/parcel/application/port/__init__.py new file mode 100644 index 0000000..5c6531f --- /dev/null +++ b/src/app/module/parcel/application/port/__init__.py @@ -0,0 +1,8 @@ +from .parcel_repository import ParcelRepository +from .polygon_service import PolygonService + + +__all__ = ( + "ParcelRepository", + "PolygonService", +) diff --git a/src/app/module/parcel/application/port/parcel_repository.py b/src/app/module/parcel/application/port/parcel_repository.py new file mode 100644 index 0000000..458755e --- /dev/null +++ b/src/app/module/parcel/application/port/parcel_repository.py @@ -0,0 +1,76 @@ +"""Parcel repository port.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from app.module.parcel.domain.entity.parcel import Parcel + from app.module.parcel.domain.value_object import OwnerId, ParcelId + + +class ParcelRepository(ABC): + """Port for parcel persistence. + + Implementations: + - :class:`app.module.parcel.infrastructure.repository.postgres_parcel_repository.PostgresParcelRepository` + """ + + @abstractmethod + async def save(self, parcel: Parcel) -> None: + """Persist a parcel. + + Parameters + ---------- + parcel : Parcel + Parcel entity to save. + """ + raise NotImplementedError + + @abstractmethod + async def get_by_id(self, parcel_id: ParcelId) -> Parcel | None: + """Retrieve a parcel by its ID. + + Parameters + ---------- + parcel_id : ParcelId + Parcel identifier. + + Returns + ------- + Parcel | None + The parcel if found, ``None`` otherwise. + """ + raise NotImplementedError + + @abstractmethod + async def get_by_owner_id(self, owner_id: OwnerId) -> list[Parcel]: + """Retrieve all parcels owned by a specific user. + + Parameters + ---------- + owner_id : OwnerId + Owner identifier. + + Returns + ------- + list[Parcel] + List of parcels owned by the user. + """ + raise NotImplementedError + + @abstractmethod + async def delete(self, parcel_id: ParcelId) -> None: + """Delete a parcel by its ID. + + Parameters + ---------- + parcel_id : ParcelId + Parcel identifier. + """ + raise NotImplementedError + + +__all__ = ("ParcelRepository",) diff --git a/src/app/module/parcel/application/port/polygon_service.py b/src/app/module/parcel/application/port/polygon_service.py new file mode 100644 index 0000000..dcc22b1 --- /dev/null +++ b/src/app/module/parcel/application/port/polygon_service.py @@ -0,0 +1,99 @@ +"""Polygon service port.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + from app.module.parcel.domain.value_object.polygon import Polygon + + +class PolygonService(ABC): + """Port for polygon geometry operations. + + Provides GeoJSON conversion, advanced geospatial validation, + and analysis using external libraries (e.g. Shapely). + + Implementations: + - :class:`app.module.parcel.infrastructure.geo.shapely_polygon_service.ShapelyPolygonService` + """ + + @abstractmethod + def to_domain(self, geojson: dict[str, Any]) -> Polygon: + """Convert a GeoJSON Polygon geometry to a domain Polygon. + + Parameters + ---------- + geojson : dict[str, Any] + GeoJSON Polygon geometry. + + Returns + ------- + Polygon + Domain polygon value object. + + Raises + ------ + ValidationError + If the GeoJSON is malformed or not a Polygon type. + """ + raise NotImplementedError + + @abstractmethod + def from_domain(self, polygon: Polygon) -> dict[str, Any]: + """Convert a domain Polygon to a GeoJSON Polygon geometry dict. + + Parameters + ---------- + polygon : Polygon + Domain polygon value object. + + Returns + ------- + dict[str, Any] + GeoJSON Polygon geometry. + """ + raise NotImplementedError + + @abstractmethod + def validate(self, polygon: Polygon) -> None: + """Validate polygon geometry. + + Checks for: + - Self-intersections + - Ring orientation + - Minimum area threshold + - Overall geometric validity + + Parameters + ---------- + polygon : Polygon + Domain polygon value object to validate. + + Raises + ------ + InvalidPolygonError + If the polygon geometry is invalid. + """ + raise NotImplementedError + + @abstractmethod + def calculate_area(self, polygon: Polygon) -> float: + """Calculate the area of a polygon in square meters. + + Parameters + ---------- + polygon : Polygon + Domain polygon value object. + + Returns + ------- + float + Area in square meters. + """ + raise NotImplementedError + + +__all__ = ("PolygonService",) diff --git a/src/app/module/parcel/application/use_case/__init__.py b/src/app/module/parcel/application/use_case/__init__.py new file mode 100644 index 0000000..e05477a --- /dev/null +++ b/src/app/module/parcel/application/use_case/__init__.py @@ -0,0 +1,12 @@ +from .create_parcel import CreateParcelUseCase +from .delete_parcel import DeleteParcelUseCase +from .get_parcel import GetParcelUseCase +from .list_user_parcels import ListUserParcelsUseCase + + +__all__ = ( + "CreateParcelUseCase", + "DeleteParcelUseCase", + "GetParcelUseCase", + "ListUserParcelsUseCase", +) diff --git a/src/app/module/parcel/application/use_case/create_parcel.py b/src/app/module/parcel/application/use_case/create_parcel.py new file mode 100644 index 0000000..2f22ceb --- /dev/null +++ b/src/app/module/parcel/application/use_case/create_parcel.py @@ -0,0 +1,60 @@ +"""Create parcel use case.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, override +from uuid import UUID, uuid6 + +from app.module.parcel.application.dto.command import CreateParcelCommand +from app.module.parcel.application.dto.response import ParcelResponse +from app.module.parcel.domain.entity import Parcel +from app.module.parcel.domain.value_object import OwnerId, ParcelId, ParcelName +from app.module.shared.application.use_case import BaseUseCase +from app.platform.logging import get_logger + + +if TYPE_CHECKING: + from app.module.parcel.application.port import ParcelRepository, PolygonService + + +class CreateParcelUseCase(BaseUseCase[CreateParcelCommand, ParcelResponse]): + """Create a new parcel. + + Validates the polygon geometry, creates a parcel entity, and persists it. + """ + + def __init__( + self, + parcel_repository: ParcelRepository, + polygon_service: PolygonService, + ) -> None: + self._parcel_repository = parcel_repository + self._polygon_service = polygon_service + self._logger = get_logger("app.parcel.use_case.create_parcel") + + @override + async def __call__(self, command: CreateParcelCommand) -> ParcelResponse: + self._logger.info("Creating parcel: name=%s", command.name) + + name = ParcelName(command.name) + polygon = self._polygon_service.to_domain(command.polygon) + + self._polygon_service.validate(polygon) + + parcel_id = ParcelId(uuid6()) + owner_id = OwnerId(UUID(command.owner_id)) + parcel = Parcel(id=parcel_id, name=name, polygon=polygon, owner_id=owner_id) + + await self._parcel_repository.save(parcel) + + self._logger.info("Parcel created: id=%s name=%s", parcel_id, command.name) + + return ParcelResponse( + id=str(parcel.id.unwrap()), + name=name.unwrap(), + polygon=command.polygon, + owner_id=str(owner_id.unwrap()), + ) + + +__all__ = ("CreateParcelUseCase",) diff --git a/src/app/module/parcel/application/use_case/delete_parcel.py b/src/app/module/parcel/application/use_case/delete_parcel.py new file mode 100644 index 0000000..ea7c5a5 --- /dev/null +++ b/src/app/module/parcel/application/use_case/delete_parcel.py @@ -0,0 +1,57 @@ +"""Delete parcel use case.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, override +from uuid import UUID + +from app.module.parcel.application.dto.command import DeleteParcelCommand +from app.module.parcel.application.error import NotParcelOwnerError, ParcelNotFoundError +from app.module.parcel.domain.value_object import OwnerId, ParcelId +from app.module.shared.application.use_case import BaseUseCase +from app.platform.logging import get_logger + + +if TYPE_CHECKING: + from app.module.parcel.application.port import ParcelRepository + + +class DeleteParcelUseCase(BaseUseCase[DeleteParcelCommand, None]): + """Delete a parcel by its ID. + + Only the owner of the parcel can delete it. + """ + + def __init__( + self, + parcel_repository: ParcelRepository, + ) -> None: + self._parcel_repository = parcel_repository + self._logger = get_logger("app.parcel.use_case.delete_parcel") + + @override + async def __call__(self, command: DeleteParcelCommand) -> None: + self._logger.info("Deleting parcel: id=%s", command.parcel_id) + + parcel_id = ParcelId(UUID(command.parcel_id)) + parcel = await self._parcel_repository.get_by_id(parcel_id) + + if parcel is None: + self._logger.warning("Parcel not found for deletion: id=%s", command.parcel_id) + raise ParcelNotFoundError(command.parcel_id) + + current_user_id = OwnerId(UUID(command.current_user_id)) + if parcel.owner_id != current_user_id: + self._logger.warning( + "User %s is not the owner of parcel %s", + command.current_user_id, + command.parcel_id, + ) + raise NotParcelOwnerError(command.parcel_id) + + await self._parcel_repository.delete(parcel_id) + + self._logger.info("Parcel deleted: id=%s", command.parcel_id) + + +__all__ = ("DeleteParcelUseCase",) diff --git a/src/app/module/parcel/application/use_case/get_parcel.py b/src/app/module/parcel/application/use_case/get_parcel.py new file mode 100644 index 0000000..533e221 --- /dev/null +++ b/src/app/module/parcel/application/use_case/get_parcel.py @@ -0,0 +1,65 @@ +"""Get parcel use case.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, override +from uuid import UUID + +from app.module.parcel.application.dto.command import GetParcelCommand +from app.module.parcel.application.dto.response import ParcelResponse +from app.module.parcel.application.error import NotParcelOwnerError, ParcelNotFoundError +from app.module.parcel.domain.value_object import OwnerId, ParcelId +from app.module.shared.application.use_case import BaseUseCase +from app.platform.logging import get_logger + + +if TYPE_CHECKING: + from app.module.parcel.application.port import ParcelRepository, PolygonService + + +class GetParcelUseCase(BaseUseCase[GetParcelCommand, ParcelResponse]): + """Retrieve a parcel by its ID. + + Only the owner of the parcel can retrieve it. + """ + + def __init__( + self, + parcel_repository: ParcelRepository, + polygon_service: PolygonService, + ) -> None: + self._parcel_repository = parcel_repository + self._polygon_service = polygon_service + self._logger = get_logger("app.parcel.use_case.get_parcel") + + @override + async def __call__(self, command: GetParcelCommand) -> ParcelResponse: + self._logger.info("Getting parcel: id=%s", command.parcel_id) + + parcel_id = ParcelId(UUID(command.parcel_id)) + parcel = await self._parcel_repository.get_by_id(parcel_id) + + if parcel is None: + self._logger.warning("Parcel not found: id=%s", command.parcel_id) + raise ParcelNotFoundError(command.parcel_id) + + current_user_id = OwnerId(UUID(command.current_user_id)) + if parcel.owner_id != current_user_id: + self._logger.warning( + "User %s is not the owner of parcel %s", + command.current_user_id, + command.parcel_id, + ) + raise NotParcelOwnerError(command.parcel_id) + + self._logger.info("Parcel found: id=%s name=%s", command.parcel_id, parcel.name.unwrap()) + + return ParcelResponse( + id=str(parcel.id.unwrap()), + name=parcel.name.unwrap(), + polygon=self._polygon_service.from_domain(parcel.polygon), + owner_id=str(parcel.owner_id.unwrap()), + ) + + +__all__ = ("GetParcelUseCase",) diff --git a/src/app/module/parcel/application/use_case/list_user_parcels.py b/src/app/module/parcel/application/use_case/list_user_parcels.py new file mode 100644 index 0000000..d2fb369 --- /dev/null +++ b/src/app/module/parcel/application/use_case/list_user_parcels.py @@ -0,0 +1,51 @@ +"""List user parcels use case.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, override +from uuid import UUID + +from app.module.parcel.application.dto.command import ListUserParcelsCommand +from app.module.parcel.application.dto.response import ParcelResponse +from app.module.parcel.domain.value_object import OwnerId +from app.module.shared.application.use_case import BaseUseCase +from app.platform.logging import get_logger + + +if TYPE_CHECKING: + from app.module.parcel.application.port import ParcelRepository, PolygonService + + +class ListUserParcelsUseCase(BaseUseCase[ListUserParcelsCommand, list[ParcelResponse]]): + """Retrieve all parcels owned by a specific user.""" + + def __init__( + self, + parcel_repository: ParcelRepository, + polygon_service: PolygonService, + ) -> None: + self._parcel_repository = parcel_repository + self._polygon_service = polygon_service + self._logger = get_logger("app.parcel.use_case.list_user_parcels") + + @override + async def __call__(self, command: ListUserParcelsCommand) -> list[ParcelResponse]: + self._logger.info("Listing parcels for owner: id=%s", command.owner_id) + + owner_id = OwnerId(UUID(command.owner_id)) + parcels = await self._parcel_repository.get_by_owner_id(owner_id) + + self._logger.info("Found %d parcels for owner: id=%s", len(parcels), command.owner_id) + + return [ + ParcelResponse( + id=str(parcel.id.unwrap()), + name=parcel.name.unwrap(), + polygon=self._polygon_service.from_domain(parcel.polygon), + owner_id=str(parcel.owner_id.unwrap()), + ) + for parcel in parcels + ] + + +__all__ = ("ListUserParcelsUseCase",) diff --git a/src/app/module/parcel/di.py b/src/app/module/parcel/di.py new file mode 100644 index 0000000..51a5b07 --- /dev/null +++ b/src/app/module/parcel/di.py @@ -0,0 +1,65 @@ +"""Dependency injection for Parcel module.""" + +from litestar.di import NamedDependency, Provide +from sqlalchemy.ext.asyncio import AsyncSession + +from app.module.parcel.application.use_case import ( + CreateParcelUseCase, + DeleteParcelUseCase, + GetParcelUseCase, + ListUserParcelsUseCase, +) +from app.module.parcel.infrastructure.geo import ShapelyPolygonService +from app.module.parcel.infrastructure.repository import PostgresParcelRepository + + +# ----- Repositories ----- +def provide_postgres_parcel_repository( + session: NamedDependency[AsyncSession], +) -> PostgresParcelRepository: + return PostgresParcelRepository(session) + + +# ----- Services ----- +def provide_shapely_polygon_service() -> ShapelyPolygonService: + return ShapelyPolygonService() + + +# ----- Use Cases ----- +def provide_create_parcel_use_case( + parcel_repository: NamedDependency[PostgresParcelRepository], + polygon_service: NamedDependency[ShapelyPolygonService], +) -> CreateParcelUseCase: + return CreateParcelUseCase(parcel_repository, polygon_service) + + +def provide_get_parcel_use_case( + parcel_repository: NamedDependency[PostgresParcelRepository], + polygon_service: NamedDependency[ShapelyPolygonService], +) -> GetParcelUseCase: + return GetParcelUseCase(parcel_repository, polygon_service) + + +def provide_list_user_parcels_use_case( + parcel_repository: NamedDependency[PostgresParcelRepository], + polygon_service: NamedDependency[ShapelyPolygonService], +) -> ListUserParcelsUseCase: + return ListUserParcelsUseCase(parcel_repository, polygon_service) + + +def provide_delete_parcel_use_case( + parcel_repository: NamedDependency[PostgresParcelRepository], +) -> DeleteParcelUseCase: + return DeleteParcelUseCase(parcel_repository) + + +parcel_dependencies = { + "parcel_repository": Provide(provide_postgres_parcel_repository, sync_to_thread=False), + "polygon_service": Provide(provide_shapely_polygon_service, sync_to_thread=False), + "create_parcel_use_case": Provide(provide_create_parcel_use_case, sync_to_thread=False), + "get_parcel_use_case": Provide(provide_get_parcel_use_case, sync_to_thread=False), + "list_user_parcels_use_case": Provide(provide_list_user_parcels_use_case, sync_to_thread=False), + "delete_parcel_use_case": Provide(provide_delete_parcel_use_case, sync_to_thread=False), +} + +__all__ = ("parcel_dependencies",) diff --git a/src/app/module/parcel/domain/__init__.py b/src/app/module/parcel/domain/__init__.py new file mode 100644 index 0000000..58b0fc8 --- /dev/null +++ b/src/app/module/parcel/domain/__init__.py @@ -0,0 +1,7 @@ +from . import entity, value_object + + +__all__ = ( + "entity", + "value_object", +) diff --git a/src/app/module/parcel/domain/entity/__init__.py b/src/app/module/parcel/domain/entity/__init__.py new file mode 100644 index 0000000..185fb75 --- /dev/null +++ b/src/app/module/parcel/domain/entity/__init__.py @@ -0,0 +1,4 @@ +from .parcel import Parcel + + +__all__ = ("Parcel",) diff --git a/src/app/module/parcel/domain/entity/parcel.py b/src/app/module/parcel/domain/entity/parcel.py new file mode 100644 index 0000000..74c4349 --- /dev/null +++ b/src/app/module/parcel/domain/entity/parcel.py @@ -0,0 +1,62 @@ +"""Parcel entity representing a land plot.""" + +from typing import override + +from app.module.parcel.domain.value_object import ( + OwnerId, + ParcelId, + ParcelName, + Polygon, +) +from app.module.shared.domain.entity import BaseEntity + + +class Parcel(BaseEntity[ParcelId]): + """Land parcel entity. + + Attributes + ---------- + id : ParcelId + Unique identifier for the parcel. + name : ParcelName + Human-readable name of the parcel. + polygon : Polygon + Geographic boundary of the parcel. + owner_id : OwnerId + ID of the user who owns this parcel. + """ + + def __init__( + self, + id: ParcelId, + name: ParcelName, + polygon: Polygon, + owner_id: OwnerId, + ) -> None: + self._name: ParcelName = name + self._polygon: Polygon = polygon + self._owner_id: OwnerId = owner_id + + super().__init__(id) + + @override + def _validate(self) -> None: + pass + + @property + def name(self) -> ParcelName: + """Name of the parcel.""" + return self._name + + @property + def polygon(self) -> Polygon: + """Geographic boundary of the parcel.""" + return self._polygon + + @property + def owner_id(self) -> OwnerId: + """ID of the user who owns this parcel.""" + return self._owner_id + + +__all__ = ("Parcel",) diff --git a/src/app/module/parcel/domain/value_object/__init__.py b/src/app/module/parcel/domain/value_object/__init__.py new file mode 100644 index 0000000..6098bd8 --- /dev/null +++ b/src/app/module/parcel/domain/value_object/__init__.py @@ -0,0 +1,18 @@ +from .geo_point import GeoPoint +from .latitude import Latitude +from .longitude import Longitude +from .owner_id import OwnerId +from .parcel_id import ParcelId +from .parcel_name import ParcelName +from .polygon import Polygon + + +__all__ = ( + "GeoPoint", + "Latitude", + "Longitude", + "OwnerId", + "ParcelId", + "ParcelName", + "Polygon", +) diff --git a/src/app/module/parcel/domain/value_object/geo_point.py b/src/app/module/parcel/domain/value_object/geo_point.py new file mode 100644 index 0000000..5a3baf6 --- /dev/null +++ b/src/app/module/parcel/domain/value_object/geo_point.py @@ -0,0 +1,43 @@ +"""Geographic point value object.""" + +from __future__ import annotations + +from typing import override + +from app.module.parcel.domain.value_object.latitude import Latitude +from app.module.parcel.domain.value_object.longitude import Longitude +from app.module.shared.domain.value_object import BaseValueObject + + +class GeoPoint(BaseValueObject[tuple[Latitude, Longitude]]): + """Geographic point value object.""" + + @property + def latitude(self) -> Latitude: + """Latitude of geographic point.""" + return self._value[0] + + @property + def longitude(self) -> Longitude: + """Longitude of geographic point.""" + return self._value[1] + + @override + def _normalize(self, value: tuple[Latitude, Longitude]) -> tuple[Latitude, Longitude]: + return value + + @override + def _validate(self) -> None: + pass + + @classmethod + def create(cls, lat: float, lon: float) -> GeoPoint: + """Create a GeoPoint from raw latitude and longitude values.""" + return cls((Latitude(lat), Longitude(lon))) + + def to_tuple(self) -> tuple[float, float]: + """Return (lat, lon) as floats.""" + return (self.latitude.unwrap(), self.longitude.unwrap()) + + +__all__ = ("GeoPoint",) diff --git a/src/app/module/parcel/domain/value_object/latitude.py b/src/app/module/parcel/domain/value_object/latitude.py new file mode 100644 index 0000000..8ee830f --- /dev/null +++ b/src/app/module/parcel/domain/value_object/latitude.py @@ -0,0 +1,28 @@ +"""Latitude value object.""" + +from __future__ import annotations + +from typing import override + +from app.module.shared.domain.error import ValidationError +from app.module.shared.domain.value_object import BaseValueObject + + +class Latitude(BaseValueObject[float]): + """Latitude value object (-90 to 90).""" + + _MIN_VALUE = -90 + _MAX_VALUE = 90 + + @override + def _normalize(self, value: float) -> float: + return value + + @override + def _validate(self) -> None: + if not (self._MIN_VALUE <= self._value <= self._MAX_VALUE): + message = f"Latitude must be between {self._MIN_VALUE} and {self._MAX_VALUE}, got {self._value}" + raise ValidationError(message) + + +__all__ = ("Latitude",) diff --git a/src/app/module/parcel/domain/value_object/longitude.py b/src/app/module/parcel/domain/value_object/longitude.py new file mode 100644 index 0000000..bc743db --- /dev/null +++ b/src/app/module/parcel/domain/value_object/longitude.py @@ -0,0 +1,28 @@ +"""Longitude value object.""" + +from __future__ import annotations + +from typing import override + +from app.module.shared.domain.error import ValidationError +from app.module.shared.domain.value_object import BaseValueObject + + +class Longitude(BaseValueObject[float]): + """Latitude value object (-180 to 180).""" + + _MIN_VALUE = -180 + _MAX_VALUE = 180 + + @override + def _normalize(self, value: float) -> float: + return value + + @override + def _validate(self) -> None: + if not (self._MIN_VALUE <= self._value <= self._MAX_VALUE): + message = f"Longitude must be between {self._MIN_VALUE} and {self._MAX_VALUE}, got {self._value}" + raise ValidationError(message) + + +__all__ = ("Longitude",) diff --git a/src/app/module/parcel/domain/value_object/owner_id.py b/src/app/module/parcel/domain/value_object/owner_id.py new file mode 100644 index 0000000..b1965ac --- /dev/null +++ b/src/app/module/parcel/domain/value_object/owner_id.py @@ -0,0 +1,12 @@ +"""Owner ID value object.""" + +from __future__ import annotations + +from app.module.shared.domain.value_object import EntityIdUUID6ValueObject + + +class OwnerId(EntityIdUUID6ValueObject): + """Value object for a parcel owner's ID.""" + + +__all__ = ("OwnerId",) diff --git a/src/app/module/parcel/domain/value_object/parcel_id.py b/src/app/module/parcel/domain/value_object/parcel_id.py new file mode 100644 index 0000000..dc42414 --- /dev/null +++ b/src/app/module/parcel/domain/value_object/parcel_id.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from app.module.shared.domain.value_object import EntityIdUUID6ValueObject + + +class ParcelId(EntityIdUUID6ValueObject): + """Parcel ID value object using UUID6.""" + + +__all__ = ("ParcelId",) diff --git a/src/app/module/parcel/domain/value_object/parcel_name.py b/src/app/module/parcel/domain/value_object/parcel_name.py new file mode 100644 index 0000000..b83bf8a --- /dev/null +++ b/src/app/module/parcel/domain/value_object/parcel_name.py @@ -0,0 +1,49 @@ +"""Parcel name value object.""" + +from __future__ import annotations + +import re +from typing import ClassVar, override + +from app.module.shared.domain.error import ValidationError +from app.module.shared.domain.value_object import BaseValueObject + + +class ParcelName(BaseValueObject[str]): + """Value object for a parcel name. + + Rules: + - Length: 3-64 characters + - Allowed: letters, digits, spaces, hyphens, underscores + - Must start with a letter + - No leading/trailing whitespace + - Normalized: stripped (case-sensitive for display) + """ + + _MIN_LENGTH = 3 + _MAX_LENGTH = 64 + _SPECIAL_CHARS: ClassVar[str] = " _-" + _PATTERN: ClassVar[re.Pattern] = re.compile( + rf"^[A-Za-z][A-Za-z0-9{re.escape(_SPECIAL_CHARS)}]{{{_MIN_LENGTH - 1},{_MAX_LENGTH - 1}}}$" + ) + + @override + def _normalize(self, value: str) -> str: + return value.strip() + + @override + def _validate(self) -> None: + if not (self._MIN_LENGTH <= len(self._value) <= self._MAX_LENGTH): + message = ( + f"Parcel name must be between {self._MIN_LENGTH} and {self._MAX_LENGTH} " + f"characters long, got {len(self._value)}." + ) + raise ValidationError(message) + + if not self._PATTERN.match(self._value): + allowed = "letters, digits, spaces, hyphens, and underscores" + message = f"Parcel name must start with a letter and contain only {allowed} characters." + raise ValidationError(message) + + +__all__ = ("ParcelName",) diff --git a/src/app/module/parcel/domain/value_object/polygon.py b/src/app/module/parcel/domain/value_object/polygon.py new file mode 100644 index 0000000..c4dfa6d --- /dev/null +++ b/src/app/module/parcel/domain/value_object/polygon.py @@ -0,0 +1,61 @@ +"""Polygon value object.""" + +from __future__ import annotations + +from typing import override + +from app.module.parcel.domain.value_object.geo_point import GeoPoint +from app.module.shared.domain.error import ValidationError +from app.module.shared.domain.value_object import BaseValueObject + + +class Polygon(BaseValueObject[tuple[GeoPoint, ...]]): + """Polygon value object representing a simple geographic polygon. + + Invariants: + - Minimum 3 distinct points (triangle) + - No consecutive duplicate points + - Automatically closed (first == last) + """ + + _MIN_POINTS = 3 + + @override + def _normalize(self, value: list[GeoPoint] | tuple[GeoPoint, ...]) -> tuple[GeoPoint, ...]: + points = tuple(value) + + if points and points[0] != points[-1]: + points = (*points, points[0]) + + return points + + @override + def _validate(self) -> None: + if len(self._value) < self._MIN_POINTS + 1: + message = f"Polygon must have at least {self._MIN_POINTS} distinct points, got {len(self._value) - 1}." + raise ValidationError(message) + + self._validate_no_consecutive_duplicates() + + def _validate_no_consecutive_duplicates(self) -> None: + """Check that no two consecutive points are identical.""" + for i in range(len(self._value) - 1): + if self._value[i] == self._value[i + 1]: + message = ( + f"Polygon has consecutive duplicate points at index {i} and {i + 1}. " + "This creates a zero-length edge." + ) + raise ValidationError(message) + + @property + def points(self) -> tuple[GeoPoint, ...]: + """Return all points including closing point.""" + return self._value + + @property + def distinct_points(self) -> tuple[GeoPoint, ...]: + """Return points without the closing duplicate.""" + return self._value[:-1] if len(self._value) > 1 else self._value + + +__all__ = ("Polygon",) diff --git a/src/app/module/parcel/error_mappings.py b/src/app/module/parcel/error_mappings.py new file mode 100644 index 0000000..321825f --- /dev/null +++ b/src/app/module/parcel/error_mappings.py @@ -0,0 +1,43 @@ +"""Error-to-HTTP-status mappings for the Parcel module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from litestar.status_codes import ( + HTTP_400_BAD_REQUEST, + HTTP_403_FORBIDDEN, + HTTP_404_NOT_FOUND, + HTTP_409_CONFLICT, +) + +from app.module.parcel.application.error import ( + InvalidGeoJsonError, + InvalidPolygonError, + NotParcelOwnerError, + ParcelAlreadyExistsError, + ParcelNotFoundError, +) + + +if TYPE_CHECKING: + from app.module.shared.application.error import ApplicationError + + +def get_parcel_application_error_mappings() -> dict[type[ApplicationError], int]: + """Return application error to HTTP status mappings for this module. + + Returns + ------- + dict[type[ApplicationError], int] + """ + return { + ParcelNotFoundError: HTTP_404_NOT_FOUND, + ParcelAlreadyExistsError: HTTP_409_CONFLICT, + InvalidGeoJsonError: HTTP_400_BAD_REQUEST, + InvalidPolygonError: HTTP_400_BAD_REQUEST, + NotParcelOwnerError: HTTP_403_FORBIDDEN, + } + + +__all__ = ("get_parcel_application_error_mappings",) diff --git a/src/app/module/parcel/infrastructure/__init__.py b/src/app/module/parcel/infrastructure/__init__.py new file mode 100644 index 0000000..563091c --- /dev/null +++ b/src/app/module/parcel/infrastructure/__init__.py @@ -0,0 +1,8 @@ +from . import geo, model, repository + + +__all__ = ( + "geo", + "model", + "repository", +) diff --git a/src/app/module/parcel/infrastructure/alembic.ini b/src/app/module/parcel/infrastructure/alembic.ini new file mode 100644 index 0000000..7065de1 --- /dev/null +++ b/src/app/module/parcel/infrastructure/alembic.ini @@ -0,0 +1,8 @@ +# A generic, single database configuration. + +[alembic] + +script_location = %(here)s/migrations +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s +prepend_sys_path = ../../../../.. +path_separator = os diff --git a/src/app/module/parcel/infrastructure/geo/__init__.py b/src/app/module/parcel/infrastructure/geo/__init__.py new file mode 100644 index 0000000..b8bb3ca --- /dev/null +++ b/src/app/module/parcel/infrastructure/geo/__init__.py @@ -0,0 +1,6 @@ +"""Geo infrastructure adapters.""" + +from .shapely_polygon_service import ShapelyPolygonService + + +__all__ = ("ShapelyPolygonService",) diff --git a/src/app/module/parcel/infrastructure/geo/shapely_polygon_service.py b/src/app/module/parcel/infrastructure/geo/shapely_polygon_service.py new file mode 100644 index 0000000..f6f3c4b --- /dev/null +++ b/src/app/module/parcel/infrastructure/geo/shapely_polygon_service.py @@ -0,0 +1,88 @@ +"""Shapely-based polygon service implementation.""" + +from __future__ import annotations + +from typing import Any, override + +from shapely.geometry import Polygon as ShapelyPolygon, mapping +from shapely.validation import explain_validity + +from app.module.parcel.application.error import InvalidGeoJsonError, InvalidPolygonError +from app.module.parcel.application.port.polygon_service import PolygonService +from app.module.parcel.domain.value_object.geo_point import GeoPoint +from app.module.parcel.domain.value_object.polygon import Polygon + + +class ShapelyPolygonService(PolygonService): + """Polygon service implementation using Shapely. + + Handles GeoJSON conversion, geometry validation, and spatial analysis + via the Shapely library. + """ + + @override + def to_domain(self, geojson: dict[str, Any]) -> Polygon: + """See :class:`app.module.parcel.application.port.polygon_service.PolygonService.to_domain`.""" + if not isinstance(geojson, dict): + reason = "GeoJSON must be a dict" + raise InvalidGeoJsonError(reason) + + if geojson.get("type") != "Polygon": + reason = f"Expected Polygon geometry, got '{geojson.get('type')}'" + raise InvalidGeoJsonError(reason) + + coordinates = geojson.get("coordinates") + if not isinstance(coordinates, list) or len(coordinates) == 0: + reason = "GeoJSON Polygon must have a non-empty coordinates array" + raise InvalidGeoJsonError(reason) + + ring = coordinates[0] + if not isinstance(ring, list): + reason = "GeoJSON Polygon ring must be an array of coordinates" + raise InvalidGeoJsonError(reason) + + points = [GeoPoint.create(float(coord[1]), float(coord[0])) for coord in ring] + + return Polygon(tuple(points)) + + @override + def from_domain(self, polygon: Polygon) -> dict[str, Any]: + """See :class:`app.module.parcel.application.port.polygon_service.PolygonService.from_domain`.""" + coords = [(point.longitude.unwrap(), point.latitude.unwrap()) for point in polygon.points] + + shapely_geom = ShapelyPolygon(coords) + + return mapping(shapely_geom) + + @override + def validate(self, polygon: Polygon) -> None: + shapely_geom = self._to_shapely(polygon) + + if not shapely_geom.is_valid: + reason = explain_validity(shapely_geom) + raise InvalidPolygonError(reason) + + if not shapely_geom.is_simple: + reason = "Polygon is not simple (self-intersections detected)." + raise InvalidPolygonError(reason) + + @override + def calculate_area(self, polygon: Polygon) -> float: + shapely_geom = self._to_shapely(polygon) + + area_deg = shapely_geom.area + area_m2 = area_deg * (111_320**2) + + return area_m2 + + @staticmethod + def _to_shapely(polygon: Polygon) -> ShapelyPolygon: + """Convert domain Polygon to Shapely Polygon. + + Shapely uses (x, y) = (lon, lat) order. + """ + coords = [(point.longitude.unwrap(), point.latitude.unwrap()) for point in polygon.points] + return ShapelyPolygon(coords) + + +__all__ = ("ShapelyPolygonService",) diff --git a/src/app/module/parcel/infrastructure/migrations/README b/src/app/module/parcel/infrastructure/migrations/README new file mode 100644 index 0000000..a23d4fb --- /dev/null +++ b/src/app/module/parcel/infrastructure/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. diff --git a/src/app/module/parcel/infrastructure/migrations/env.py b/src/app/module/parcel/infrastructure/migrations/env.py new file mode 100644 index 0000000..3e21165 --- /dev/null +++ b/src/app/module/parcel/infrastructure/migrations/env.py @@ -0,0 +1,82 @@ +"""Alembic environment configuration for Parcel module migrations.""" + +import asyncio +from typing import TYPE_CHECKING + +from alembic import context +from sqlalchemy import text + + +if TYPE_CHECKING: + from sqlalchemy.engine import Connection + +from app.platform.config.loaders import load_app_config +from app.platform.database.engine import create_async_engine_from_config, dispose_engine +from app.platform.logging import configure_logging + + +config = context.config +app_config = load_app_config() + +configure_logging(app_config.logging) + +# Import all models so that Alembic can detect them for autogenerate. +# Identity model is imported so that Alembic can resolve the FK to identity.users. +import app.module.identity.infrastructure.model # noqa: E402 +import app.module.parcel.infrastructure.model # noqa: F401, E402 +from app.platform.database.base import BaseModel # noqa: E402 + + +# Set target metadata for autogenerate support. +target_metadata = BaseModel.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode.""" + context.configure( + url=app_config.database.get_url(), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + version_table_schema="parcel", + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + """Run migrations with a given connection.""" + context.configure( + connection=connection, + target_metadata=target_metadata, + version_table_schema="parcel", + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """Run migrations in 'online' mode with an async engine.""" + engine = create_async_engine_from_config(app_config.database) + + async with engine.connect() as connection: + # Ensure the schema exists before Alembic tries to write version_table there. + # Must be outside Alembic's transaction to avoid DDL conflicts. + await connection.execute(text("CREATE SCHEMA IF NOT EXISTS parcel")) + await connection.commit() + await connection.run_sync(do_run_migrations) + + await dispose_engine(engine) + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/src/app/module/parcel/infrastructure/migrations/script.py.mako b/src/app/module/parcel/infrastructure/migrations/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/src/app/module/parcel/infrastructure/migrations/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/src/app/module/parcel/infrastructure/migrations/versions/0001_15c4a02c5334_add_parcel_parcels_table.py b/src/app/module/parcel/infrastructure/migrations/versions/0001_15c4a02c5334_add_parcel_parcels_table.py new file mode 100644 index 0000000..60432c7 --- /dev/null +++ b/src/app/module/parcel/infrastructure/migrations/versions/0001_15c4a02c5334_add_parcel_parcels_table.py @@ -0,0 +1,73 @@ +"""Add parcel parcels table. + +Revision ID: 15c4a02c5334 +Revises: +Create Date: 2026-07-15 11:14:47.918714 + +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import geoalchemy2 +import sqlalchemy as sa +from alembic import op + + +if TYPE_CHECKING: + from collections.abc import Sequence + + +# revision identifiers, used by Alembic. +revision: str = "15c4a02c5334" +down_revision: str | Sequence[str] | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "parcels", + sa.Column("name", sa.String(length=64), nullable=False), + sa.Column( + "polygon", + geoalchemy2.types.Geometry( + geometry_type="POLYGON", + srid=4326, + dimension=2, + from_text="ST_GeomFromEWKT", + name="geometry", + nullable=False, + ), + nullable=False, + comment="PostGIS Polygon geometry in SRID 4326 (WGS 84)", + ), + sa.Column("owner_id", sa.UUID(), nullable=False, comment="ID of the user who owns this parcel"), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.text("timezone('utc', now())"), nullable=False + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), server_default=sa.text("timezone('utc', now())"), nullable=False + ), + sa.Column("id", sa.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["owner_id"], ["identity.users.id"], name=op.f("fk_parcels_owner_id_users"), ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_parcels")), + schema="parcel", + ) + # PostGIS automatically creates a GIST index on Geometry columns, + # so we use IF NOT EXISTS to avoid duplicate index errors. + op.execute("CREATE INDEX IF NOT EXISTS idx_parcels_polygon ON parcel.parcels USING gist (polygon)") + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index("idx_parcels_polygon", table_name="parcels", schema="parcel", postgresql_using="gist") + op.drop_table("parcels", schema="parcel") + # ### end Alembic commands ### diff --git a/src/app/module/parcel/infrastructure/model/__init__.py b/src/app/module/parcel/infrastructure/model/__init__.py new file mode 100644 index 0000000..90e98ad --- /dev/null +++ b/src/app/module/parcel/infrastructure/model/__init__.py @@ -0,0 +1,6 @@ +"""Infrastructure ORM models.""" + +from .parcel_model import ParcelModel + + +__all__ = ("ParcelModel",) diff --git a/src/app/module/parcel/infrastructure/model/parcel_model.py b/src/app/module/parcel/infrastructure/model/parcel_model.py new file mode 100644 index 0000000..d956640 --- /dev/null +++ b/src/app/module/parcel/infrastructure/model/parcel_model.py @@ -0,0 +1,45 @@ +"""SQLAlchemy ORM model for Parcel.""" + +from typing import TYPE_CHECKING +from uuid import UUID + +from geoalchemy2 import Geometry +from sqlalchemy import ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.platform.database.base import TimestampedModel + + +if TYPE_CHECKING: + from uuid import UUID + + +class ParcelModel(TimestampedModel): + """ORM model for the Parcel entity. + + Maps to the ``parcel.parcels`` table. + + Uses PostGIS ``Geometry(Polygon, 4326)`` for geographic data storage. + SRID 4326 corresponds to WGS 84 (standard GPS coordinates). + """ + + __tablename__ = "parcels" + __table_args__ = {"schema": "parcel"} # noqa: RUF012 + + name: Mapped[str] = mapped_column( + String(64), + nullable=False, + ) + polygon: Mapped[Geometry] = mapped_column( + Geometry("Polygon", srid=4326), + nullable=False, + comment="PostGIS Polygon geometry in SRID 4326 (WGS 84)", + ) + owner_id: Mapped[UUID] = mapped_column( + ForeignKey("identity.users.id", ondelete="CASCADE"), + nullable=False, + comment="ID of the user who owns this parcel", + ) + + +__all__ = ("ParcelModel",) diff --git a/src/app/module/parcel/infrastructure/repository/__init__.py b/src/app/module/parcel/infrastructure/repository/__init__.py new file mode 100644 index 0000000..776493e --- /dev/null +++ b/src/app/module/parcel/infrastructure/repository/__init__.py @@ -0,0 +1,6 @@ +"""Infrastructure repositories.""" + +from .postgres_parcel_repository import PostgresParcelRepository + + +__all__ = ("PostgresParcelRepository",) diff --git a/src/app/module/parcel/infrastructure/repository/postgres_parcel_repository.py b/src/app/module/parcel/infrastructure/repository/postgres_parcel_repository.py new file mode 100644 index 0000000..84aba5f --- /dev/null +++ b/src/app/module/parcel/infrastructure/repository/postgres_parcel_repository.py @@ -0,0 +1,110 @@ +"""PostgreSQL (PostGIS) parcel repository implementation.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast, override + +from geoalchemy2.shape import from_shape, to_shape +from shapely.geometry import Polygon as ShapelyPolygon +from sqlalchemy import select + +from app.module.parcel.application.port import ParcelRepository +from app.module.parcel.domain.entity import Parcel +from app.module.parcel.domain.value_object import ( + OwnerId, + ParcelId, + ParcelName, + Polygon, +) +from app.module.parcel.domain.value_object.geo_point import GeoPoint +from app.module.parcel.infrastructure.model import ParcelModel +from app.platform.database.repository import BaseSQLAlchemyRepository + + +if TYPE_CHECKING: + from geoalchemy2 import Geometry, WKBElement + from sqlalchemy.ext.asyncio import AsyncSession + + +class PostgresParcelRepository(BaseSQLAlchemyRepository, ParcelRepository): + """Parcel repository backed by PostgreSQL with PostGIS extension. + + Stores polygon geometry in PostGIS ``Geometry(Polygon, 4326)`` column. + Converts between domain :class:`~app.module.parcel.domain.value_object.polygon.Polygon` + and Shapely/PostGIS formats directly using coordinate data from the domain object. + """ + + def __init__(self, session: AsyncSession) -> None: + super().__init__(session) + + @override + async def save(self, parcel: Parcel) -> None: + """See :class:`app.module.parcel.application.port.ParcelRepository.save`.""" + shapely_geom = self._domain_to_shapely(parcel.polygon) + wkb_element = from_shape(shapely_geom, srid=4326) + + model = ParcelModel( + id=parcel.id.unwrap(), + name=parcel.name.unwrap(), + polygon=cast("Geometry", wkb_element), + owner_id=parcel.owner_id.unwrap(), + ) + self._session.add(model) + + @override + async def get_by_id(self, parcel_id: ParcelId) -> Parcel | None: + """See :class:`app.module.parcel.application.port.ParcelRepository.get_by_id`.""" + result = await self._session.execute( + select(ParcelModel).where(ParcelModel.id == parcel_id.unwrap()), + ) + model = result.scalar_one_or_none() + + return self._to_domain(model) if model is not None else None + + @override + async def get_by_owner_id(self, owner_id: OwnerId) -> list[Parcel]: + """See :class:`app.module.parcel.application.port.ParcelRepository.get_by_owner_id`.""" + result = await self._session.execute( + select(ParcelModel).where(ParcelModel.owner_id == owner_id.unwrap()), + ) + models = result.scalars().all() + + return [self._to_domain(model) for model in models] + + @override + async def delete(self, parcel_id: ParcelId) -> None: + """See :class:`app.module.parcel.application.port.ParcelRepository.delete`.""" + result = await self._session.execute( + select(ParcelModel).where(ParcelModel.id == parcel_id.unwrap()), + ) + model = result.scalar_one_or_none() + if model is not None: + await self._session.delete(model) + + @staticmethod + def _domain_to_shapely(polygon: Polygon) -> ShapelyPolygon: + """Convert domain Polygon to Shapely Polygon for PostGIS storage. + + Shapely uses (x, y) = (lon, lat) order. + """ + coords = [(point.longitude.unwrap(), point.latitude.unwrap()) for point in polygon.points] + return ShapelyPolygon(coords) + + @staticmethod + def _to_domain(model: ParcelModel) -> Parcel: + """Convert an ORM model to a domain entity.""" + wkb_element = cast("WKBElement", model.polygon) + shapely_geom = to_shape(wkb_element) + + points = [GeoPoint.create(float(coord[1]), float(coord[0])) for coord in shapely_geom.exterior.coords] + domain_polygon = Polygon(tuple(points)) + + return Parcel( + id=ParcelId(model.id), + name=ParcelName(model.name), + polygon=domain_polygon, + owner_id=OwnerId(model.owner_id), + ) + + +__all__ = ("PostgresParcelRepository",) diff --git a/src/app/module/parcel/interface/__init__.py b/src/app/module/parcel/interface/__init__.py new file mode 100644 index 0000000..9a7e9d2 --- /dev/null +++ b/src/app/module/parcel/interface/__init__.py @@ -0,0 +1,4 @@ +from . import http + + +__all__ = ("http",) diff --git a/src/app/module/parcel/interface/http/__init__.py b/src/app/module/parcel/interface/http/__init__.py new file mode 100644 index 0000000..492984e --- /dev/null +++ b/src/app/module/parcel/interface/http/__init__.py @@ -0,0 +1,7 @@ +from . import controller, schema + + +__all__ = ( + "controller", + "schema", +) diff --git a/src/app/module/parcel/interface/http/controller/__init__.py b/src/app/module/parcel/interface/http/controller/__init__.py new file mode 100644 index 0000000..b934a74 --- /dev/null +++ b/src/app/module/parcel/interface/http/controller/__init__.py @@ -0,0 +1,4 @@ +from .parcel import ParcelController + + +__all__ = ("ParcelController",) diff --git a/src/app/module/parcel/interface/http/controller/parcel.py b/src/app/module/parcel/interface/http/controller/parcel.py new file mode 100644 index 0000000..4e773f5 --- /dev/null +++ b/src/app/module/parcel/interface/http/controller/parcel.py @@ -0,0 +1,194 @@ +"""Parcel endpoints.""" + +from litestar import delete, get, post +from litestar.controller import Controller +from litestar.di import NamedDependency +from litestar.status_codes import HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT + +from app.module.parcel.application.dto.command import ( + CreateParcelCommand, + DeleteParcelCommand, + GetParcelCommand, + ListUserParcelsCommand, +) +from app.module.parcel.application.use_case import ( + CreateParcelUseCase, + DeleteParcelUseCase, + GetParcelUseCase, + ListUserParcelsUseCase, +) +from app.module.parcel.interface.http.schema.parcel import ( + CreateParcelRequest, + ParcelFeature, + ParcelFeatureCollection, + ParcelFeatureProperties, +) +from app.module.shared.application.dto.response import CurrentUser +from app.module.shared.interface.http.guards import require_authorization + + +class ParcelController(Controller): + """Parcel management endpoints.""" + + path = "/api/v1/parcels" + tags = ("parcels",) + guards = [require_authorization] # noqa: RUF012 + + @post( + "/", + status_code=HTTP_201_CREATED, + description="Create a new parcel.", + ) + async def create_parcel( + self, + data: CreateParcelRequest, + create_parcel_use_case: NamedDependency[CreateParcelUseCase], + current_user: CurrentUser, + ) -> ParcelFeature: + """Create a new parcel. + + Parameters + ---------- + data : CreateParcelRequest + Parcel data. + create_parcel_use_case : CreateParcelUseCase + Injected use case. + current_user : CurrentUser + The currently authenticated user (resolved from token). + + Returns + ------- + ParcelFeature + Created parcel as a GeoJSON Feature. + """ + command = CreateParcelCommand( + name=data.name, + polygon=data.polygon.model_dump(), + owner_id=current_user.id, + ) + result = await create_parcel_use_case(command) + + return ParcelFeature( + geometry=result.polygon, + properties=ParcelFeatureProperties( + id=result.id, + name=result.name, + owner_id=result.owner_id, + ), + ) + + @get( + "/", + status_code=HTTP_200_OK, + description="List all parcels owned by the current user.", + ) + async def list_user_parcels( + self, + list_user_parcels_use_case: NamedDependency[ListUserParcelsUseCase], + current_user: CurrentUser, + ) -> ParcelFeatureCollection: + """List all parcels owned by the currently authenticated user. + + Parameters + ---------- + list_user_parcels_use_case : ListUserParcelsUseCase + Injected use case. + current_user : CurrentUser + The currently authenticated user (resolved from token). + + Returns + ------- + ParcelFeatureCollection + List of parcels as a GeoJSON FeatureCollection. + """ + command = ListUserParcelsCommand(owner_id=current_user.id) + results = await list_user_parcels_use_case(command) + + features = [ + ParcelFeature( + geometry=r.polygon, + properties=ParcelFeatureProperties( + id=r.id, + name=r.name, + owner_id=r.owner_id, + ), + ) + for r in results + ] + + return ParcelFeatureCollection(features=features, total=len(features)) + + @get( + "/{parcel_id:str}", + status_code=HTTP_200_OK, + description="Get a parcel by its ID.", + ) + async def get_parcel( + self, + parcel_id: str, + get_parcel_use_case: NamedDependency[GetParcelUseCase], + current_user: CurrentUser, + ) -> ParcelFeature: + """Get a parcel by its ID. + + Parameters + ---------- + parcel_id : str + Parcel identifier from the path. + get_parcel_use_case : GetParcelUseCase + Injected use case. + current_user : CurrentUser + The currently authenticated user (resolved from token). + + Returns + ------- + ParcelFeature + Parcel as a GeoJSON Feature. + """ + command = GetParcelCommand( + parcel_id=parcel_id, + current_user_id=current_user.id, + ) + result = await get_parcel_use_case(command) + + return ParcelFeature( + geometry=result.polygon, + properties=ParcelFeatureProperties( + id=result.id, + name=result.name, + owner_id=result.owner_id, + ), + ) + + @delete( + "/{parcel_id:str}", + status_code=HTTP_204_NO_CONTENT, + description="Delete a parcel by its ID (owner only).", + ) + async def delete_parcel( + self, + parcel_id: str, + delete_parcel_use_case: NamedDependency[DeleteParcelUseCase], + current_user: CurrentUser, + ) -> None: + """Delete a parcel by its ID. + + Only the owner of the parcel can delete it. + + Parameters + ---------- + parcel_id : str + Parcel identifier from the path. + delete_parcel_use_case : DeleteParcelUseCase + Injected use case. + current_user : CurrentUser + The currently authenticated user (resolved from token). + """ + command = DeleteParcelCommand( + parcel_id=parcel_id, + current_user_id=current_user.id, + ) + await delete_parcel_use_case(command) + + +__all__ = ("ParcelController",) diff --git a/src/app/module/parcel/interface/http/schema/__init__.py b/src/app/module/parcel/interface/http/schema/__init__.py new file mode 100644 index 0000000..e78b58e --- /dev/null +++ b/src/app/module/parcel/interface/http/schema/__init__.py @@ -0,0 +1,14 @@ +from .parcel import ( + CreateParcelRequest, + ParcelFeature, + ParcelFeatureCollection, + ParcelFeatureProperties, +) + + +__all__ = ( + "CreateParcelRequest", + "ParcelFeature", + "ParcelFeatureCollection", + "ParcelFeatureProperties", +) diff --git a/src/app/module/parcel/interface/http/schema/parcel.py b/src/app/module/parcel/interface/http/schema/parcel.py new file mode 100644 index 0000000..5fc7d22 --- /dev/null +++ b/src/app/module/parcel/interface/http/schema/parcel.py @@ -0,0 +1,52 @@ +"""Parcel HTTP schemas.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + +from app.module.shared.interface.http.schema.geojson import ( + GeoJSONFeature, + GeoJSONFeatureCollection, + GeoJSONPolygon, +) + + +class CreateParcelRequest(BaseModel): + """Request body for creating a parcel.""" + + name: str = Field( + description="Human-readable name of the parcel.", + min_length=1, + max_length=64, + ) + polygon: GeoJSONPolygon = Field( + description="Parcel geometry in GeoJSON Polygon format.", + ) + + +class ParcelFeatureProperties(BaseModel): + """Properties of a parcel GeoJSON Feature.""" + + id: str = Field(description="Parcel identifier.") + name: str = Field(description="Human-readable name of the parcel.") + owner_id: str = Field(description="ID of the user who owns this parcel.") + + +class ParcelFeature(GeoJSONFeature): + """GeoJSON Feature for a parcel.""" + + properties: ParcelFeatureProperties = Field(description="Parcel properties.") + + +class ParcelFeatureCollection(GeoJSONFeatureCollection): + """GeoJSON FeatureCollection for a list of parcels.""" + + features: list[ParcelFeature] = Field(description="List of parcel features.") + + +__all__ = ( + "CreateParcelRequest", + "ParcelFeature", + "ParcelFeatureCollection", + "ParcelFeatureProperties", +) diff --git a/src/app/module/shared/application/dto/__init__.py b/src/app/module/shared/application/dto/__init__.py new file mode 100644 index 0000000..3399fcc --- /dev/null +++ b/src/app/module/shared/application/dto/__init__.py @@ -0,0 +1 @@ +"""Response DTOs for the shared module.""" diff --git a/src/app/module/shared/application/dto/response/__init__.py b/src/app/module/shared/application/dto/response/__init__.py new file mode 100644 index 0000000..6dd95a3 --- /dev/null +++ b/src/app/module/shared/application/dto/response/__init__.py @@ -0,0 +1,4 @@ +from .current_user import CurrentUser + + +__all__ = ("CurrentUser",) diff --git a/src/app/module/shared/application/dto/response/current_user.py b/src/app/module/shared/application/dto/response/current_user.py new file mode 100644 index 0000000..9a92557 --- /dev/null +++ b/src/app/module/shared/application/dto/response/current_user.py @@ -0,0 +1,26 @@ +"""Current user response DTO.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class CurrentUser: + """Response DTO representing the currently authenticated user. + + This is a lightweight, framework-agnostic representation of the + authenticated user that can be used across all modules without + creating a dependency on the identity module. + + Attributes + ---------- + id : str + Unique identifier of the user. + username : str + Username of the user. + """ + + id: str + username: str + + +__all__ = ("CurrentUser",) diff --git a/src/app/module/shared/application/port/current_user_provider.py b/src/app/module/shared/application/port/current_user_provider.py index 8a2fd5b..f506378 100644 --- a/src/app/module/shared/application/port/current_user_provider.py +++ b/src/app/module/shared/application/port/current_user_provider.py @@ -1,10 +1,17 @@ """Current user provider port.""" +from __future__ import annotations + from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from app.module.shared.application.dto.response import CurrentUser class CurrentUserProvider(ABC): - """Port for resolving the current user ID from an authentication token. + """Port for resolving the current user from an authentication token. The token is extracted from the HTTP request by the controller layer and passed to this port. This keeps the port free of framework @@ -15,8 +22,8 @@ class CurrentUserProvider(ABC): """ @abstractmethod - async def get_current_user_id(self, token: str) -> str: - """Resolve a user ID from an authentication token. + async def get_current_user(self, token: str) -> CurrentUser: + """Resolve a current user from an authentication token. Parameters ---------- @@ -25,8 +32,8 @@ async def get_current_user_id(self, token: str) -> str: Returns ------- - str - The user ID as a string. + CurrentUser + The authenticated user's id and username. Raises ------ diff --git a/src/app/module/shared/interface/http/__init__.py b/src/app/module/shared/interface/http/__init__.py index 8024df8..908c79a 100644 --- a/src/app/module/shared/interface/http/__init__.py +++ b/src/app/module/shared/interface/http/__init__.py @@ -1,4 +1,7 @@ -from . import error_mappings +from . import error_mappings, guards -__all__ = ("error_mappings",) +__all__ = ( + "error_mappings", + "guards", +) diff --git a/src/app/module/shared/interface/http/guards.py b/src/app/module/shared/interface/http/guards.py new file mode 100644 index 0000000..6fbecde --- /dev/null +++ b/src/app/module/shared/interface/http/guards.py @@ -0,0 +1,49 @@ +"""Guards for the application.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from litestar.exceptions import NotAuthorizedException + + +if TYPE_CHECKING: + from litestar.connection import ASGIConnection + from litestar.handlers.base import BaseRouteHandler + + +async def require_authorization( + connection: ASGIConnection, + _handler: BaseRouteHandler, +) -> None: + """Guard that checks for the presence of an Authorization header. + + This guard is applied globally to all routes. It only checks that + the header *exists* — it does NOT validate the token itself. + Token validation is handled by the ``current_user`` dependency. + + Routes that should not require authentication (e.g., login, register) + must override the guard list with an empty list:: + + class AuthController(Controller): + guards = [] + + Parameters + ---------- + connection : ASGIConnection + The incoming connection (request or websocket). + _handler : BaseRouteHandler + The route handler being guarded. + + Raises + ------ + NotAuthorizedException + If the Authorization header is missing. + """ + auth_header = connection.headers.get("Authorization") + if not auth_header: + message = "Authorization header is required" + raise NotAuthorizedException(message) + + +__all__ = ("require_authorization",) diff --git a/src/app/module/shared/interface/http/schema/__init__.py b/src/app/module/shared/interface/http/schema/__init__.py new file mode 100644 index 0000000..59d3bf3 --- /dev/null +++ b/src/app/module/shared/interface/http/schema/__init__.py @@ -0,0 +1,12 @@ +from app.module.shared.interface.http.schema.geojson import ( + GeoJSONFeature, + GeoJSONFeatureCollection, + GeoJSONPolygon, +) + + +__all__ = ( + "GeoJSONFeature", + "GeoJSONFeatureCollection", + "GeoJSONPolygon", +) diff --git a/src/app/module/shared/interface/http/schema/geojson.py b/src/app/module/shared/interface/http/schema/geojson.py new file mode 100644 index 0000000..32dcab2 --- /dev/null +++ b/src/app/module/shared/interface/http/schema/geojson.py @@ -0,0 +1,78 @@ +"""GeoJSON schemas for HTTP request/response validation.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +class GeoJSONPolygon(BaseModel): + """GeoJSON Polygon geometry. + + Examples + -------- + .. code-block:: python + + GeoJSONPolygon( + type="Polygon", + coordinates=[ + [ + [37.618423, 55.751244], + [37.628423, 55.751244], + [37.618423, 55.741244], + [37.618423, 55.751244], + ], + ], + ) + """ + + type: str = Field( + default="Polygon", + description='GeoJSON geometry type. Must be ``"Polygon"``.', + ) + coordinates: list[list[list[float]]] = Field( + description="Polygon coordinates: an array of rings, where each ring is an array of ``[longitude, latitude]`` pairs.", + min_length=1, + ) + + +class GeoJSONFeature(BaseModel): + """GeoJSON Feature. + + A Feature contains a geometry and associated properties. + """ + + type: str = Field( + default="Feature", + description='GeoJSON type. Must be ``"Feature"``.', + ) + geometry: GeoJSONPolygon = Field( + description="Feature geometry.", + ) + properties: dict[str, Any] = Field( + default_factory=dict, + description="Feature properties.", + ) + + +class GeoJSONFeatureCollection(BaseModel): + """GeoJSON FeatureCollection. + + A collection of GeoJSON Features. + """ + + type: str = Field( + default="FeatureCollection", + description='GeoJSON type. Must be ``"FeatureCollection"``.', + ) + features: list[GeoJSONFeature] = Field( + description="List of GeoJSON Features.", + ) + + +__all__ = ( + "GeoJSONFeature", + "GeoJSONFeatureCollection", + "GeoJSONPolygon", +) diff --git a/src/app/platform/database/migrations/versions/0002_ef70c85c6229_add_postgis_extension.py b/src/app/platform/database/migrations/versions/0002_ef70c85c6229_add_postgis_extension.py new file mode 100644 index 0000000..df187c4 --- /dev/null +++ b/src/app/platform/database/migrations/versions/0002_ef70c85c6229_add_postgis_extension.py @@ -0,0 +1,35 @@ +"""Add postgis extension. + +Revision ID: ef70c85c6229 +Revises: fd780af9c921 +Create Date: 2026-07-15 11:21:42.429355 + +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from alembic import op + + +if TYPE_CHECKING: + from collections.abc import Sequence + + +# revision identifiers, used by Alembic. +revision: str = "ef70c85c6229" +down_revision: str | Sequence[str] | None = "fd780af9c921" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # Enable PostGIS extension (required for Geometry type used by Parcel module). + op.execute("CREATE EXTENSION IF NOT EXISTS postgis") + + +def downgrade() -> None: + """Downgrade schema.""" + op.execute("DROP EXTENSION IF EXISTS postgis") diff --git a/src/app/platform/di.py b/src/app/platform/di.py index 7e265ef..2f3a13f 100644 --- a/src/app/platform/di.py +++ b/src/app/platform/di.py @@ -56,9 +56,9 @@ async def provide_async_session( platform_dependencies = { - "database_config": Provide(provide_database_config, use_cache=True), - "auth_config": Provide(provide_auth_config, use_cache=True), - "session_factory": Provide(provide_async_session_factory, use_cache=True), + "database_config": Provide(provide_database_config, use_cache=True, sync_to_thread=False), + "auth_config": Provide(provide_auth_config, use_cache=True, sync_to_thread=False), + "session_factory": Provide(provide_async_session_factory, use_cache=True, sync_to_thread=False), "session": Provide(provide_async_session), } diff --git a/uv.lock b/uv.lock index 8c96c66..d193139 100644 --- a/uv.lock +++ b/uv.lock @@ -202,6 +202,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/a6/6111b9f13c1564b0e2f5dbeeb611fd00dc731e6add2336f62b798598c73d/faker-40.28.1-py3-none-any.whl", hash = "sha256:e8d3f5c469100a553d246dce7937c291308068a5ec6c9c3a228d7878b50720be", size = 2061052, upload-time = "2026-07-01T22:23:41.946Z" }, ] +[[package]] +name = "geoalchemy2" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/74/6cb1ef591bf47d28f41aa770f2f3a91c0a570aee0a4083bed7f8c533d8df/geoalchemy2-0.20.0.tar.gz", hash = "sha256:450f427f4bc3cf2d5ddee0af3763aed0f3eea2384e7c9a99798d8f1508279322", size = 280805, upload-time = "2026-05-12T14:50:26.132Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/08/b66ad4239f592e05202e25925c08cdd04cc14c3994000ec70ec61fea202c/geoalchemy2-0.20.0-py3-none-any.whl", hash = "sha256:1489a1d106519542a79c97cd0b4c537d80462c353610ebc2429cf2c43daac717", size = 96467, upload-time = "2026-05-12T14:50:24.998Z" }, +] + [[package]] name = "granian" version = "2.7.9" @@ -360,11 +373,13 @@ dependencies = [ { name = "alembic" }, { name = "asyncpg" }, { name = "bcrypt" }, + { name = "geoalchemy2" }, { name = "granian" }, { name = "litestar" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt" }, + { name = "shapely" }, { name = "sqlalchemy", extra = ["asyncio"] }, ] @@ -400,13 +415,15 @@ test = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.18.5" }, - { name = "asyncpg", specifier = ">=0.31.0" }, - { name = "bcrypt", specifier = ">=5.0.0" }, + { name = "asyncpg", specifier = ">=0.31" }, + { name = "bcrypt", specifier = ">=5" }, + { name = "geoalchemy2", specifier = ">=0.20.0" }, { name = "granian", specifier = ">=2.7.2" }, { name = "litestar", specifier = ">=2.21" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic-settings", specifier = ">=2.12" }, - { name = "pyjwt", specifier = ">=2.13.0" }, + { name = "pyjwt", specifier = ">=2.13" }, + { name = "shapely", specifier = ">=2.1.2" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.51" }, ] @@ -616,6 +633,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/ed/e1f03200ee1f0bf4a2b9b72709afefbf5319b68df654e0b84b35c65613ee/multipart-1.3.1-py3-none-any.whl", hash = "sha256:a82b59e1befe74d3d30b3d3f70efd5a2eba4d938f845dcff9faace968888ff29", size = 15061, upload-time = "2026-02-27T10:17:11.943Z" }, ] +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -889,6 +935,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, ] +[[package]] +name = "shapely" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290, upload-time = "2025-09-24T13:51:13.56Z" }, + { url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463, upload-time = "2025-09-24T13:51:14.972Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145, upload-time = "2025-09-24T13:51:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806, upload-time = "2025-09-24T13:51:18.712Z" }, + { url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803, upload-time = "2025-09-24T13:51:20.37Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301, upload-time = "2025-09-24T13:51:21.887Z" }, + { url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247, upload-time = "2025-09-24T13:51:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019, upload-time = "2025-09-24T13:51:24.873Z" }, + { url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137, upload-time = "2025-09-24T13:51:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884, upload-time = "2025-09-24T13:51:28.029Z" }, + { url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320, upload-time = "2025-09-24T13:51:29.903Z" }, + { url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931, upload-time = "2025-09-24T13:51:32.699Z" }, + { url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406, upload-time = "2025-09-24T13:51:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511, upload-time = "2025-09-24T13:51:36.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607, upload-time = "2025-09-24T13:51:37.757Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" }, +] + [[package]] name = "sniffio" version = "1.3.1"