From 582b3fc3f76e5eb624707586e2d0a158bcdea982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 10:01:44 +0300 Subject: [PATCH 01/49] feat(parcel): init module structure --- src/app/module/parcel/__init__.py | 4 ++++ src/app/module/parcel/application/__init__.py | 0 src/app/module/parcel/domain/__init__.py | 0 src/app/module/parcel/infrastructure/__init__.py | 0 src/app/module/parcel/interface/__init__.py | 0 5 files changed, 4 insertions(+) create mode 100644 src/app/module/parcel/__init__.py create mode 100644 src/app/module/parcel/application/__init__.py create mode 100644 src/app/module/parcel/domain/__init__.py create mode 100644 src/app/module/parcel/infrastructure/__init__.py create mode 100644 src/app/module/parcel/interface/__init__.py 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..e69de29 diff --git a/src/app/module/parcel/domain/__init__.py b/src/app/module/parcel/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/module/parcel/infrastructure/__init__.py b/src/app/module/parcel/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/module/parcel/interface/__init__.py b/src/app/module/parcel/interface/__init__.py new file mode 100644 index 0000000..e69de29 From 09d0ef2be9b869066fcac50d4b1f37c16394e708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 10:55:48 +0300 Subject: [PATCH 02/49] feat(parcel): add latitude value object --- .../parcel/domain/value_object/latitude.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/app/module/parcel/domain/value_object/latitude.py 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..82006b9 --- /dev/null +++ b/src/app/module/parcel/domain/value_object/latitude.py @@ -0,0 +1,26 @@ +"""Latitude value object.""" + +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",) From 429d3f3abfc70896a159fa8cee10a503c5153740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 10:56:14 +0300 Subject: [PATCH 03/49] feat(parcel): add longitude value object --- .../parcel/domain/value_object/longitude.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/app/module/parcel/domain/value_object/longitude.py 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..461561e --- /dev/null +++ b/src/app/module/parcel/domain/value_object/longitude.py @@ -0,0 +1,26 @@ +"""Longitude value object.""" + +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",) From 5f764a370a1d72811c6e18f56595bc665cf3fc17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 11:00:20 +0300 Subject: [PATCH 04/49] feat(parcel): add geographic point value object --- .../parcel/domain/value_object/geo_point.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/app/module/parcel/domain/value_object/geo_point.py 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..c7eba2a --- /dev/null +++ b/src/app/module/parcel/domain/value_object/geo_point.py @@ -0,0 +1,52 @@ +"""Geographic point value object.""" + +from dataclasses import dataclass +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 + + +@dataclass(frozen=True, slots=True) +class _GeoPointCoords: + """Internal immutable container for lat/lon.""" + + lat: Latitude + lon: Longitude + + +class GeoPoint(BaseValueObject[_GeoPointCoords]): + """Geographic point value object.""" + + @property + def latitude(self) -> Latitude: + """Latitude of geographic point.""" + return self._value.lat + + @property + def longitude(self) -> Longitude: + """Longitude of geographic point.""" + return self._value.lon + + @override + def _normalize(self, value: tuple[float, float] | _GeoPointCoords) -> _GeoPointCoords: + if isinstance(value, _GeoPointCoords): + return value + + lat_val, lon_val = value + return _GeoPointCoords( + lat=Latitude(lat_val), + lon=Longitude(lon_val), + ) + + @override + def _validate(self) -> None: + pass + + def to_tuple(self) -> tuple[float, float]: + """Return (lat, lon) as floats.""" + return (self._value.lat.unwrap(), self._value.lon.unwrap()) + + +__all__ = ("GeoPoint",) From b2f630c1ae4195d2df9e41e2771088c5409fd20c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 11:00:57 +0300 Subject: [PATCH 05/49] feat(parcel): add polygon value object --- .../parcel/domain/value_object/polygon.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/app/module/parcel/domain/value_object/polygon.py 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..5a70c97 --- /dev/null +++ b/src/app/module/parcel/domain/value_object/polygon.py @@ -0,0 +1,59 @@ +"""Polygon value object.""" + +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",) From 9fc6f9aeaee98b7ae96b8ce4350d9452c61a6381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 11:01:10 +0300 Subject: [PATCH 06/49] feat(parcel): add parcel id value object --- src/app/module/parcel/domain/value_object/parcel_id.py | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/app/module/parcel/domain/value_object/parcel_id.py 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..5e2e60e --- /dev/null +++ b/src/app/module/parcel/domain/value_object/parcel_id.py @@ -0,0 +1,8 @@ +from app.module.shared.domain.value_object import EntityIdUUID6ValueObject + + +class ParcelId(EntityIdUUID6ValueObject): + """Parcel ID value object using UUID6.""" + + +__all__ = ("ParcelId",) From 261dcf6352684a9f399f8f05411346b8db7f12c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 11:01:28 +0300 Subject: [PATCH 07/49] feat(parcel): add parcel name value object --- .../parcel/domain/value_object/parcel_name.py | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/app/module/parcel/domain/value_object/parcel_name.py 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..e12054c --- /dev/null +++ b/src/app/module/parcel/domain/value_object/parcel_name.py @@ -0,0 +1,47 @@ +"""Parcel name value object.""" + +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 and lowercased + """ + + _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().lower() + + @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 = "alphanumeric, spaces, hyphens, and underscores" + message = f"Parcel name must start with a letter and contain only {allowed} characters." + raise ValidationError(message) + + +__all__ = ("ParcelName",) From 1bb94017a441bcb9a8d3095e9c3afba4c574d346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 11:01:50 +0300 Subject: [PATCH 08/49] feat(parcel): update init for value objects --- .../parcel/domain/value_object/__init__.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/app/module/parcel/domain/value_object/__init__.py 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..0ad2152 --- /dev/null +++ b/src/app/module/parcel/domain/value_object/__init__.py @@ -0,0 +1,16 @@ +from .geo_point import GeoPoint +from .latitude import Latitude +from .longitude import Longitude +from .parcel_id import ParcelId +from .parcel_name import ParcelName +from .polygon import Polygon + + +__all__ = ( + "GeoPoint", + "Latitude", + "Longitude", + "ParcelId", + "ParcelName", + "Polygon", +) From 49b0946ffcb755b3971d4bfdb161a651314839aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 11:02:23 +0300 Subject: [PATCH 09/49] feat(parcel): add parcel entity --- .../module/parcel/domain/entity/__init__.py | 4 ++ src/app/module/parcel/domain/entity/parcel.py | 48 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 src/app/module/parcel/domain/entity/__init__.py create mode 100644 src/app/module/parcel/domain/entity/parcel.py 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..f3eda83 --- /dev/null +++ b/src/app/module/parcel/domain/entity/parcel.py @@ -0,0 +1,48 @@ +"""Parcel entity representing a land plot.""" + +from typing import override + +from app.module.parcel.domain.value_object import 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. + """ + + def __init__( + self, + id: ParcelId, + name: ParcelName, + polygon: Polygon, + ) -> None: + self._name: ParcelName = name + self._polygon: Polygon = polygon + + 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 + + +__all__ = ("Parcel",) From 257beab7074b99165a2a4a6638b5824f7cd11fb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 11:05:22 +0300 Subject: [PATCH 10/49] feat(parcel): update domain init --- src/app/module/parcel/domain/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/app/module/parcel/domain/__init__.py b/src/app/module/parcel/domain/__init__.py index e69de29..58b0fc8 100644 --- a/src/app/module/parcel/domain/__init__.py +++ b/src/app/module/parcel/domain/__init__.py @@ -0,0 +1,7 @@ +from . import entity, value_object + + +__all__ = ( + "entity", + "value_object", +) From 07c3563d1215932a81aca97ab02665c71b8eaef3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 13:03:03 +0300 Subject: [PATCH 11/49] refactor(identity): remove unnessesary value objects --- .../identity/domain/value_object/__init__.py | 4 --- .../identity/domain/value_object/email.py | 27 ------------------- .../value_object/hashed_authentication_key.py | 21 --------------- 3 files changed, 52 deletions(-) delete mode 100644 src/app/module/identity/domain/value_object/email.py delete mode 100644 src/app/module/identity/domain/value_object/hashed_authentication_key.py 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",) From de5ab4c0b7ca5d56d489fdd3bc23e7d4323f996a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 13:09:06 +0300 Subject: [PATCH 12/49] refactor(identity): update services to use domain value objects --- .../application/port/password_hasher.py | 37 ++++++++++++------- .../application/port/token_service.py | 22 ++++++++--- .../application/use_case/authenticate_user.py | 9 +++-- .../application/use_case/refresh_token.py | 12 ++++-- .../application/use_case/register_user.py | 5 ++- .../security/bcrypt_password_hasher.py | 12 ++++-- .../security/jwt_token_service.py | 11 ++++-- 7 files changed, 72 insertions(+), 36 deletions(-) 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..abc9fcd 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 @@ -50,13 +52,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/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_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", From 1f4150ddcf1c4e03296093b776f17c292c5b5d23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 13:10:46 +0300 Subject: [PATCH 13/49] refactor(parcel): add annotations from __future__ --- src/app/module/parcel/domain/value_object/geo_point.py | 2 ++ src/app/module/parcel/domain/value_object/latitude.py | 2 ++ src/app/module/parcel/domain/value_object/longitude.py | 2 ++ src/app/module/parcel/domain/value_object/parcel_id.py | 2 ++ src/app/module/parcel/domain/value_object/parcel_name.py | 2 ++ src/app/module/parcel/domain/value_object/polygon.py | 2 ++ 6 files changed, 12 insertions(+) diff --git a/src/app/module/parcel/domain/value_object/geo_point.py b/src/app/module/parcel/domain/value_object/geo_point.py index c7eba2a..7f3bee9 100644 --- a/src/app/module/parcel/domain/value_object/geo_point.py +++ b/src/app/module/parcel/domain/value_object/geo_point.py @@ -1,5 +1,7 @@ """Geographic point value object.""" +from __future__ import annotations + from dataclasses import dataclass from typing import override diff --git a/src/app/module/parcel/domain/value_object/latitude.py b/src/app/module/parcel/domain/value_object/latitude.py index 82006b9..8ee830f 100644 --- a/src/app/module/parcel/domain/value_object/latitude.py +++ b/src/app/module/parcel/domain/value_object/latitude.py @@ -1,5 +1,7 @@ """Latitude value object.""" +from __future__ import annotations + from typing import override from app.module.shared.domain.error import ValidationError diff --git a/src/app/module/parcel/domain/value_object/longitude.py b/src/app/module/parcel/domain/value_object/longitude.py index 461561e..bc743db 100644 --- a/src/app/module/parcel/domain/value_object/longitude.py +++ b/src/app/module/parcel/domain/value_object/longitude.py @@ -1,5 +1,7 @@ """Longitude value object.""" +from __future__ import annotations + from typing import override from app.module.shared.domain.error import ValidationError diff --git a/src/app/module/parcel/domain/value_object/parcel_id.py b/src/app/module/parcel/domain/value_object/parcel_id.py index 5e2e60e..dc42414 100644 --- a/src/app/module/parcel/domain/value_object/parcel_id.py +++ b/src/app/module/parcel/domain/value_object/parcel_id.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from app.module.shared.domain.value_object import EntityIdUUID6ValueObject diff --git a/src/app/module/parcel/domain/value_object/parcel_name.py b/src/app/module/parcel/domain/value_object/parcel_name.py index e12054c..ce23d90 100644 --- a/src/app/module/parcel/domain/value_object/parcel_name.py +++ b/src/app/module/parcel/domain/value_object/parcel_name.py @@ -1,5 +1,7 @@ """Parcel name value object.""" +from __future__ import annotations + import re from typing import ClassVar, override diff --git a/src/app/module/parcel/domain/value_object/polygon.py b/src/app/module/parcel/domain/value_object/polygon.py index 5a70c97..c4dfa6d 100644 --- a/src/app/module/parcel/domain/value_object/polygon.py +++ b/src/app/module/parcel/domain/value_object/polygon.py @@ -1,5 +1,7 @@ """Polygon value object.""" +from __future__ import annotations + from typing import override from app.module.parcel.domain.value_object.geo_point import GeoPoint From 81b68305b6bc6e861e9fccddf0fe154b6042f488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 13:11:37 +0300 Subject: [PATCH 14/49] feat(parcel): add owner id value object --- .../module/parcel/domain/value_object/__init__.py | 2 ++ .../module/parcel/domain/value_object/owner_id.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) create mode 100644 src/app/module/parcel/domain/value_object/owner_id.py diff --git a/src/app/module/parcel/domain/value_object/__init__.py b/src/app/module/parcel/domain/value_object/__init__.py index 0ad2152..6098bd8 100644 --- a/src/app/module/parcel/domain/value_object/__init__.py +++ b/src/app/module/parcel/domain/value_object/__init__.py @@ -1,6 +1,7 @@ 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 @@ -10,6 +11,7 @@ "GeoPoint", "Latitude", "Longitude", + "OwnerId", "ParcelId", "ParcelName", "Polygon", 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",) From c9c8213999affc9ab94552749642c7989307127b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 13:12:11 +0300 Subject: [PATCH 15/49] feat(parcel): add owner id property for parcel entity --- src/app/module/parcel/domain/entity/parcel.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/app/module/parcel/domain/entity/parcel.py b/src/app/module/parcel/domain/entity/parcel.py index f3eda83..74c4349 100644 --- a/src/app/module/parcel/domain/entity/parcel.py +++ b/src/app/module/parcel/domain/entity/parcel.py @@ -2,7 +2,12 @@ from typing import override -from app.module.parcel.domain.value_object import ParcelId, ParcelName, Polygon +from app.module.parcel.domain.value_object import ( + OwnerId, + ParcelId, + ParcelName, + Polygon, +) from app.module.shared.domain.entity import BaseEntity @@ -17,6 +22,8 @@ class Parcel(BaseEntity[ParcelId]): 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__( @@ -24,9 +31,11 @@ def __init__( 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) @@ -44,5 +53,10 @@ 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",) From b48afa00ce306abe1cec821b47d07c3333d9f053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 13:13:36 +0300 Subject: [PATCH 16/49] feat(parcel): add application errors --- src/app/module/parcel/application/error.py | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/app/module/parcel/application/error.py diff --git a/src/app/module/parcel/application/error.py b/src/app/module/parcel/application/error.py new file mode 100644 index 0000000..c62f819 --- /dev/null +++ b/src/app/module/parcel/application/error.py @@ -0,0 +1,31 @@ +"""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 InvalidPolygonError(ApplicationError): + """Raised when the polygon geometry is invalid.""" + + def __init__(self, message: str) -> None: + super().__init__(message) + + +__all__ = ( + "InvalidPolygonError", + "ParcelAlreadyExistsError", + "ParcelNotFoundError", +) From 844bb9f825aa3d1ea2ffbf32fa6483478bdc029d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 13:14:49 +0300 Subject: [PATCH 17/49] feat(parcel): add dto commands --- .../application/dto/command/__init__.py | 10 ++++++++ .../application/dto/command/create_parcel.py | 25 +++++++++++++++++++ .../application/dto/command/delete_parcel.py | 19 ++++++++++++++ .../application/dto/command/get_parcel.py | 19 ++++++++++++++ 4 files changed, 73 insertions(+) create mode 100644 src/app/module/parcel/application/dto/command/__init__.py create mode 100644 src/app/module/parcel/application/dto/command/create_parcel.py create mode 100644 src/app/module/parcel/application/dto/command/delete_parcel.py create mode 100644 src/app/module/parcel/application/dto/command/get_parcel.py 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..dfe6cef --- /dev/null +++ b/src/app/module/parcel/application/dto/command/__init__.py @@ -0,0 +1,10 @@ +from .create_parcel import CreateParcelCommand +from .delete_parcel import DeleteParcelCommand +from .get_parcel import GetParcelCommand + + +__all__ = ( + "CreateParcelCommand", + "DeleteParcelCommand", + "GetParcelCommand", +) 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..b0a8a05 --- /dev/null +++ b/src/app/module/parcel/application/dto/command/delete_parcel.py @@ -0,0 +1,19 @@ +"""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. + """ + + parcel_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..b2c5343 --- /dev/null +++ b/src/app/module/parcel/application/dto/command/get_parcel.py @@ -0,0 +1,19 @@ +"""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. + """ + + parcel_id: str + + +__all__ = ("GetParcelCommand",) From c7cd1a578dd31d43e118982310dbef961495b4b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 13:15:36 +0300 Subject: [PATCH 18/49] feat(parcel): add dto response --- .../module/parcel/application/dto/__init__.py | 7 +++++ .../application/dto/response/__init__.py | 4 +++ .../parcel/application/dto/response/parcel.py | 28 +++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 src/app/module/parcel/application/dto/__init__.py create mode 100644 src/app/module/parcel/application/dto/response/__init__.py create mode 100644 src/app/module/parcel/application/dto/response/parcel.py 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/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",) From 12d6dd5d3876c485652910573fc9f3879f102e60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 13:16:20 +0300 Subject: [PATCH 19/49] feat(parcel): add parcel repository port --- .../application/port/parcel_repository.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/app/module/parcel/application/port/parcel_repository.py 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..8006aaf --- /dev/null +++ b/src/app/module/parcel/application/port/parcel_repository.py @@ -0,0 +1,60 @@ +"""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 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 delete(self, parcel_id: ParcelId) -> None: + """Delete a parcel by its ID. + + Parameters + ---------- + parcel_id : ParcelId + Parcel identifier. + """ + raise NotImplementedError + + +__all__ = ("ParcelRepository",) From 4357d4902cab610ca19f227d8332493724141a1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 13:17:54 +0300 Subject: [PATCH 20/49] feat(parcel): add polygon service port --- .../parcel/application/port/__init__.py | 8 ++ .../application/port/polygon_service.py | 99 +++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 src/app/module/parcel/application/port/__init__.py create mode 100644 src/app/module/parcel/application/port/polygon_service.py 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/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",) From b0bcebde4b69a133f44954e5fed6dcf63dcc0132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 13:31:04 +0300 Subject: [PATCH 21/49] feat(parcel): add create parcel use case --- .../application/use_case/create_parcel.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/app/module/parcel/application/use_case/create_parcel.py 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",) From 7b40ac40b27cbdb0c1d1fc0ec9a6f8a1152646e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 13:32:40 +0300 Subject: [PATCH 22/49] feat(parcel): add delete parcel use case --- .../application/use_case/delete_parcel.py | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/app/module/parcel/application/use_case/delete_parcel.py 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..ab8437d --- /dev/null +++ b/src/app/module/parcel/application/use_case/delete_parcel.py @@ -0,0 +1,45 @@ +"""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 ParcelNotFoundError +from app.module.parcel.domain.value_object import 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.""" + + 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) + + await self._parcel_repository.delete(parcel_id) + + self._logger.info("Parcel deleted: id=%s", command.parcel_id) + + +__all__ = ("DeleteParcelUseCase",) From c9efedd854de56fb7777e6a1fb514b92cd24b709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 13:33:36 +0300 Subject: [PATCH 23/49] feat(parcel): add get parcel use case --- .../parcel/application/use_case/__init__.py | 10 ++++ .../parcel/application/use_case/get_parcel.py | 53 +++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 src/app/module/parcel/application/use_case/__init__.py create mode 100644 src/app/module/parcel/application/use_case/get_parcel.py 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..a8caa3c --- /dev/null +++ b/src/app/module/parcel/application/use_case/__init__.py @@ -0,0 +1,10 @@ +from .create_parcel import CreateParcelUseCase +from .delete_parcel import DeleteParcelUseCase +from .get_parcel import GetParcelUseCase + + +__all__ = ( + "CreateParcelUseCase", + "DeleteParcelUseCase", + "GetParcelUseCase", +) 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..5d4ebb5 --- /dev/null +++ b/src/app/module/parcel/application/use_case/get_parcel.py @@ -0,0 +1,53 @@ +"""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 ParcelNotFoundError +from app.module.parcel.domain.value_object import 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.""" + + 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) + + 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), + ) + + +__all__ = ("GetParcelUseCase",) From abdbc2b634391c482878c79223f7e475e7068f42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 14:10:30 +0300 Subject: [PATCH 24/49] feat(parcel): add polygon service implimentation --- src/app/module/parcel/application/error.py | 4 +- .../parcel/domain/value_object/geo_point.py | 33 +++---- .../parcel/infrastructure/geo/__init__.py | 6 ++ .../geo/shapely_polygon_service.py | 85 +++++++++++++++++++ 4 files changed, 104 insertions(+), 24 deletions(-) create mode 100644 src/app/module/parcel/infrastructure/geo/__init__.py create mode 100644 src/app/module/parcel/infrastructure/geo/shapely_polygon_service.py diff --git a/src/app/module/parcel/application/error.py b/src/app/module/parcel/application/error.py index c62f819..f356aac 100644 --- a/src/app/module/parcel/application/error.py +++ b/src/app/module/parcel/application/error.py @@ -20,8 +20,8 @@ def __init__(self, name: str) -> None: class InvalidPolygonError(ApplicationError): """Raised when the polygon geometry is invalid.""" - def __init__(self, message: str) -> None: - super().__init__(message) + def __init__(self, reason: str) -> None: + super().__init__(f"Polygon is not valid: {reason}.") __all__ = ( diff --git a/src/app/module/parcel/domain/value_object/geo_point.py b/src/app/module/parcel/domain/value_object/geo_point.py index 7f3bee9..5a3baf6 100644 --- a/src/app/module/parcel/domain/value_object/geo_point.py +++ b/src/app/module/parcel/domain/value_object/geo_point.py @@ -2,7 +2,6 @@ from __future__ import annotations -from dataclasses import dataclass from typing import override from app.module.parcel.domain.value_object.latitude import Latitude @@ -10,45 +9,35 @@ from app.module.shared.domain.value_object import BaseValueObject -@dataclass(frozen=True, slots=True) -class _GeoPointCoords: - """Internal immutable container for lat/lon.""" - - lat: Latitude - lon: Longitude - - -class GeoPoint(BaseValueObject[_GeoPointCoords]): +class GeoPoint(BaseValueObject[tuple[Latitude, Longitude]]): """Geographic point value object.""" @property def latitude(self) -> Latitude: """Latitude of geographic point.""" - return self._value.lat + return self._value[0] @property def longitude(self) -> Longitude: """Longitude of geographic point.""" - return self._value.lon + return self._value[1] @override - def _normalize(self, value: tuple[float, float] | _GeoPointCoords) -> _GeoPointCoords: - if isinstance(value, _GeoPointCoords): - return value - - lat_val, lon_val = value - return _GeoPointCoords( - lat=Latitude(lat_val), - lon=Longitude(lon_val), - ) + 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._value.lat.unwrap(), self._value.lon.unwrap()) + return (self.latitude.unwrap(), self.longitude.unwrap()) __all__ = ("GeoPoint",) 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..a08497c --- /dev/null +++ b/src/app/module/parcel/infrastructure/geo/shapely_polygon_service.py @@ -0,0 +1,85 @@ +"""Shapely-based polygon service implementation.""" + +from __future__ import annotations + +from typing import Any, override + +from shapely.geometry import Polygon as ShapelyPolygon, mapping, shape +from shapely.validation import explain_validity + +from app.module.parcel.application.error import 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 +from app.module.shared.domain.error import ValidationError + + +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): + message = "GeoJSON must be a dict." + raise ValidationError(message) + + shapely_geom = shape(geojson) + + if shapely_geom.geom_type != "Polygon": + message = f"Expected Polygon geometry, got '{shapely_geom.geom_type}'." + raise ValidationError(message) + + points = [GeoPoint.create(float(coord[1]), float(coord[0])) for coord in shapely_geom.exterior.coords] + + 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 = shape( + { + "type": "Polygon", + "coordinates": [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 + + def _to_shapely(self, 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",) From 061ab1dcb7ee50686b0796530745ff0ea07e8993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 14:24:34 +0300 Subject: [PATCH 25/49] feat(parcel): add parcel model --- .../parcel/infrastructure/model/__init__.py | 6 +++ .../infrastructure/model/parcel_model.py | 45 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 src/app/module/parcel/infrastructure/model/__init__.py create mode 100644 src/app/module/parcel/infrastructure/model/parcel_model.py 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",) From fa4ded4f17dec043fe8994234a037b5519767739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 14:39:42 +0300 Subject: [PATCH 26/49] feat(parcel): add parcel repository --- .../module/parcel/infrastructure/__init__.py | 8 ++ .../infrastructure/repository/__init__.py | 6 ++ .../repository/postgres_parcel_repository.py | 100 ++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 src/app/module/parcel/infrastructure/repository/__init__.py create mode 100644 src/app/module/parcel/infrastructure/repository/postgres_parcel_repository.py diff --git a/src/app/module/parcel/infrastructure/__init__.py b/src/app/module/parcel/infrastructure/__init__.py index e69de29..563091c 100644 --- a/src/app/module/parcel/infrastructure/__init__.py +++ 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/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..d3cc2b8 --- /dev/null +++ b/src/app/module/parcel/infrastructure/repository/postgres_parcel_repository.py @@ -0,0 +1,100 @@ +"""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 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",) From 78cb40125e8355df21da1001f9e14b59da61a0e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 14:40:07 +0300 Subject: [PATCH 27/49] feat(parcel): update application init file --- src/app/module/parcel/application/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/app/module/parcel/application/__init__.py b/src/app/module/parcel/application/__init__.py index e69de29..064e5d4 100644 --- a/src/app/module/parcel/application/__init__.py +++ 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", +) From 19eb1decd405c55ab08e531c06ba74515271899e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 14:47:24 +0300 Subject: [PATCH 28/49] feat(parcel): add repository method for getting a list of user parcels --- .../application/port/parcel_repository.py | 18 +++++++++++++++++- .../repository/postgres_parcel_repository.py | 10 ++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/app/module/parcel/application/port/parcel_repository.py b/src/app/module/parcel/application/port/parcel_repository.py index 8006aaf..458755e 100644 --- a/src/app/module/parcel/application/port/parcel_repository.py +++ b/src/app/module/parcel/application/port/parcel_repository.py @@ -8,7 +8,7 @@ if TYPE_CHECKING: from app.module.parcel.domain.entity.parcel import Parcel - from app.module.parcel.domain.value_object import ParcelId + from app.module.parcel.domain.value_object import OwnerId, ParcelId class ParcelRepository(ABC): @@ -45,6 +45,22 @@ async def get_by_id(self, parcel_id: ParcelId) -> Parcel | None: """ 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. diff --git a/src/app/module/parcel/infrastructure/repository/postgres_parcel_repository.py b/src/app/module/parcel/infrastructure/repository/postgres_parcel_repository.py index d3cc2b8..84aba5f 100644 --- a/src/app/module/parcel/infrastructure/repository/postgres_parcel_repository.py +++ b/src/app/module/parcel/infrastructure/repository/postgres_parcel_repository.py @@ -61,6 +61,16 @@ async def get_by_id(self, parcel_id: ParcelId) -> Parcel | 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`.""" From 42bb2728250fa39edf938e17737f5eaa50d61703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 14:48:17 +0300 Subject: [PATCH 29/49] feat(parcel): add list user parcels command --- .../application/dto/command/__init__.py | 2 ++ .../dto/command/list_user_parcels.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 src/app/module/parcel/application/dto/command/list_user_parcels.py diff --git a/src/app/module/parcel/application/dto/command/__init__.py b/src/app/module/parcel/application/dto/command/__init__.py index dfe6cef..d72f1bc 100644 --- a/src/app/module/parcel/application/dto/command/__init__.py +++ b/src/app/module/parcel/application/dto/command/__init__.py @@ -1,10 +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/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",) From bff02cd579a8be91f5dd9f83729e7b149505c3ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 14:48:49 +0300 Subject: [PATCH 30/49] feat(parcel): add list user parcels use case --- .../parcel/application/use_case/__init__.py | 2 + .../application/use_case/list_user_parcels.py | 51 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 src/app/module/parcel/application/use_case/list_user_parcels.py diff --git a/src/app/module/parcel/application/use_case/__init__.py b/src/app/module/parcel/application/use_case/__init__.py index a8caa3c..e05477a 100644 --- a/src/app/module/parcel/application/use_case/__init__.py +++ b/src/app/module/parcel/application/use_case/__init__.py @@ -1,10 +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/list_user_parcels.py b/src/app/module/parcel/application/use_case/list_user_parcels.py new file mode 100644 index 0000000..8ba8845 --- /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), + ) + for parcel in parcels + ] + + +__all__ = ("ListUserParcelsUseCase",) From 228d1c681676378881b8b485454d61240838681d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 15:00:30 +0300 Subject: [PATCH 31/49] feat(parcel): update delete parcel use case --- .../application/dto/command/delete_parcel.py | 3 +++ src/app/module/parcel/application/error.py | 8 ++++++++ .../application/use_case/delete_parcel.py | 18 +++++++++++++++--- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/app/module/parcel/application/dto/command/delete_parcel.py b/src/app/module/parcel/application/dto/command/delete_parcel.py index b0a8a05..7493958 100644 --- a/src/app/module/parcel/application/dto/command/delete_parcel.py +++ b/src/app/module/parcel/application/dto/command/delete_parcel.py @@ -11,9 +11,12 @@ class DeleteParcelCommand: ---------- 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/error.py b/src/app/module/parcel/application/error.py index f356aac..20a65d9 100644 --- a/src/app/module/parcel/application/error.py +++ b/src/app/module/parcel/application/error.py @@ -24,8 +24,16 @@ 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__ = ( "InvalidPolygonError", + "NotParcelOwnerError", "ParcelAlreadyExistsError", "ParcelNotFoundError", ) diff --git a/src/app/module/parcel/application/use_case/delete_parcel.py b/src/app/module/parcel/application/use_case/delete_parcel.py index ab8437d..ea7c5a5 100644 --- a/src/app/module/parcel/application/use_case/delete_parcel.py +++ b/src/app/module/parcel/application/use_case/delete_parcel.py @@ -6,8 +6,8 @@ from uuid import UUID from app.module.parcel.application.dto.command import DeleteParcelCommand -from app.module.parcel.application.error import ParcelNotFoundError -from app.module.parcel.domain.value_object import ParcelId +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 @@ -17,7 +17,10 @@ class DeleteParcelUseCase(BaseUseCase[DeleteParcelCommand, None]): - """Delete a parcel by its ID.""" + """Delete a parcel by its ID. + + Only the owner of the parcel can delete it. + """ def __init__( self, @@ -37,6 +40,15 @@ async def __call__(self, command: DeleteParcelCommand) -> 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) From d3d03c4cd2efd13160d8de8d5a685d3201640c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 15:01:06 +0300 Subject: [PATCH 32/49] feat(parcel): add parcel schemas --- .../parcel/interface/http/schema/__init__.py | 12 ++++++ .../parcel/interface/http/schema/parcel.py | 37 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 src/app/module/parcel/interface/http/schema/__init__.py create mode 100644 src/app/module/parcel/interface/http/schema/parcel.py 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..71b437f --- /dev/null +++ b/src/app/module/parcel/interface/http/schema/__init__.py @@ -0,0 +1,12 @@ +from .parcel import ( + CreateParcelRequest, + ParcelListResponse, + ParcelResponse, +) + + +__all__ = ( + "CreateParcelRequest", + "ParcelListResponse", + "ParcelResponse", +) 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..9f63c13 --- /dev/null +++ b/src/app/module/parcel/interface/http/schema/parcel.py @@ -0,0 +1,37 @@ +"""Parcel HTTP schemas.""" + +from typing import Any + +from pydantic import BaseModel, Field + + +class CreateParcelRequest(BaseModel): + """Request body for creating a parcel.""" + + name: str = Field(description="Human-readable name of the parcel.") + polygon: dict[str, Any] = Field( + description='GeoJSON Polygon geometry (e.g. ``{"type": "Polygon", "coordinates": [...]}``).', + ) + + +class ParcelResponse(BaseModel): + """Response body for parcel data.""" + + id: str = Field(description="Parcel identifier.") + name: str = Field(description="Human-readable name of the parcel.") + polygon: dict[str, Any] = Field(description="GeoJSON Polygon geometry.") + owner_id: str = Field(description="ID of the user who owns this parcel.") + + +class ParcelListResponse(BaseModel): + """Response body for a list of parcels.""" + + parcels: list[ParcelResponse] = Field(description="List of parcels.") + total: int = Field(description="Total number of parcels.") + + +__all__ = ( + "CreateParcelRequest", + "ParcelListResponse", + "ParcelResponse", +) From 34461a5274aa2d2777e28810dd93b8743ad31e2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 15:05:38 +0300 Subject: [PATCH 33/49] feat(parcel): add parcel controller --- .../interface/http/controller/__init__.py | 4 + .../interface/http/controller/parcel.py | 200 ++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 src/app/module/parcel/interface/http/controller/__init__.py create mode 100644 src/app/module/parcel/interface/http/controller/parcel.py 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..8b19793 --- /dev/null +++ b/src/app/module/parcel/interface/http/controller/parcel.py @@ -0,0 +1,200 @@ +"""Parcel endpoints.""" + +from typing import Annotated + +from litestar import delete, get, post +from litestar.controller import Controller +from litestar.di import NamedDependency +from litestar.params import HeaderParameter +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, + ParcelListResponse, + ParcelResponse, +) +from app.module.shared.application.port import CurrentUserProvider + + +class ParcelController(Controller): + """Parcel management endpoints.""" + + path = "/api/v1/parcels" + tags = ("parcels",) + + @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_provider: NamedDependency[CurrentUserProvider], + authorization: Annotated[str, HeaderParameter(name="Authorization", required=True)], + ) -> ParcelResponse: + """Create a new parcel. + + Parameters + ---------- + data : CreateParcelRequest + Parcel data. + create_parcel_use_case : CreateParcelUseCase + Injected use case. + current_user_provider : CurrentUserProvider + Injected provider for extracting user ID from the token. + authorization : str + Raw Authorization header value. + + Returns + ------- + ParcelResponse + Created parcel data. + """ + token = (authorization or "").removeprefix("Bearer ") + current_user_id = await current_user_provider.get_current_user_id(token) + + command = CreateParcelCommand( + name=data.name, + polygon=data.polygon, + owner_id=current_user_id, + ) + result = await create_parcel_use_case(command) + + return ParcelResponse( + id=result.id, + name=result.name, + polygon=result.polygon, + 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_provider: NamedDependency[CurrentUserProvider], + authorization: Annotated[str, HeaderParameter(name="Authorization", required=True)], + ) -> ParcelListResponse: + """List all parcels owned by the currently authenticated user. + + Parameters + ---------- + list_user_parcels_use_case : ListUserParcelsUseCase + Injected use case. + current_user_provider : CurrentUserProvider + Injected provider for extracting user ID from the token. + authorization : str + Raw Authorization header value. + + Returns + ------- + ParcelListResponse + List of parcels owned by the user. + """ + token = (authorization or "").removeprefix("Bearer ") + current_user_id = await current_user_provider.get_current_user_id(token) + + command = ListUserParcelsCommand(owner_id=current_user_id) + results = await list_user_parcels_use_case(command) + + parcels = [ + ParcelResponse( + id=r.id, + name=r.name, + polygon=r.polygon, + owner_id=r.owner_id, + ) + for r in results + ] + + return ParcelListResponse(parcels=parcels, total=len(parcels)) + + @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], + ) -> ParcelResponse: + """Get a parcel by its ID. + + Parameters + ---------- + parcel_id : str + Parcel identifier from the path. + get_parcel_use_case : GetParcelUseCase + Injected use case. + + Returns + ------- + ParcelResponse + Parcel data. + """ + command = GetParcelCommand(parcel_id=parcel_id) + result = await get_parcel_use_case(command) + + return ParcelResponse( + id=result.id, + name=result.name, + polygon=result.polygon, + 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_provider: NamedDependency[CurrentUserProvider], + authorization: Annotated[str, HeaderParameter(name="Authorization", required=True)], + ) -> 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_provider : CurrentUserProvider + Injected provider for extracting user ID from the token. + authorization : str + Raw Authorization header value. + """ + token = (authorization or "").removeprefix("Bearer ") + current_user_id = await current_user_provider.get_current_user_id(token) + + command = DeleteParcelCommand( + parcel_id=parcel_id, + current_user_id=current_user_id, + ) + await delete_parcel_use_case(command) + + +__all__ = ("ParcelController",) From dc3d2aa3ac2e85b90505d6cd910a871f62bb4d96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 15:06:53 +0300 Subject: [PATCH 34/49] feat(parcel): add parcel di --- src/app/module/parcel/di.py | 65 +++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/app/module/parcel/di.py diff --git a/src/app/module/parcel/di.py b/src/app/module/parcel/di.py new file mode 100644 index 0000000..735b3a8 --- /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), + "polygon_service": Provide(provide_shapely_polygon_service), + "create_parcel_use_case": Provide(provide_create_parcel_use_case), + "get_parcel_use_case": Provide(provide_get_parcel_use_case), + "list_user_parcels_use_case": Provide(provide_list_user_parcels_use_case), + "delete_parcel_use_case": Provide(provide_delete_parcel_use_case), +} + +__all__ = ("parcel_dependencies",) From d3f86ca3ed9d0e972b97526fac30fbd8ac1cb9fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 15:09:31 +0300 Subject: [PATCH 35/49] feat(parcel): add parcel error mapping --- src/app/module/parcel/error_mappings.py | 41 +++++++++++++++++++ src/app/module/parcel/interface/__init__.py | 4 ++ .../module/parcel/interface/http/__init__.py | 7 ++++ 3 files changed, 52 insertions(+) create mode 100644 src/app/module/parcel/error_mappings.py create mode 100644 src/app/module/parcel/interface/http/__init__.py diff --git a/src/app/module/parcel/error_mappings.py b/src/app/module/parcel/error_mappings.py new file mode 100644 index 0000000..91396e3 --- /dev/null +++ b/src/app/module/parcel/error_mappings.py @@ -0,0 +1,41 @@ +"""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 ( + 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, + InvalidPolygonError: HTTP_400_BAD_REQUEST, + NotParcelOwnerError: HTTP_403_FORBIDDEN, + } + + +__all__ = ("get_parcel_application_error_mappings",) diff --git a/src/app/module/parcel/interface/__init__.py b/src/app/module/parcel/interface/__init__.py index e69de29..9a7e9d2 100644 --- a/src/app/module/parcel/interface/__init__.py +++ 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", +) From 2cc3de5f148701fa67e561cf76fbf83efc999dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 15:11:36 +0300 Subject: [PATCH 36/49] feat(interface): register parcel error mapping --- src/app/interface/http/util/error_mappings.py | 8 +++----- src/app/module/identity/error_mappings.py | 20 +------------------ 2 files changed, 4 insertions(+), 24 deletions(-) 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/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",) From fda61cc47213dd99dc66dff73b32964a57eb26c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 15:13:49 +0300 Subject: [PATCH 37/49] feat(interface): register parcel dependencies --- src/app/interface/http/util/dependencies.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/interface/http/util/dependencies.py b/src/app/interface/http/util/dependencies.py index 36c1855..97bdf9f 100644 --- a/src/app/interface/http/util/dependencies.py +++ b/src/app/interface/http/util/dependencies.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING from app.module.identity.di import identity_dependencies +from app.module.parcel.di import parcel_dependencies from app.platform.di import platform_dependencies @@ -21,4 +22,5 @@ def get_all_dependencies() -> dict[str, Provide]: dependencies = {} dependencies.update(platform_dependencies) dependencies.update(identity_dependencies) + dependencies.update(parcel_dependencies) return dependencies From 49a471157c1b69692e7856d5313ee5ee0d2661dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 15:14:53 +0300 Subject: [PATCH 38/49] feat(interface): register parcel controller --- src/app/interface/http/asgi.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/interface/http/asgi.py b/src/app/interface/http/asgi.py index b5787dc..52e33cc 100644 --- a/src/app/interface/http/asgi.py +++ b/src/app/interface/http/asgi.py @@ -20,6 +20,7 @@ ) from app.module.identity.interface.http.controller.auth import AuthController from app.module.identity.interface.http.controller.user import UserController +from app.module.parcel.interface.http.controller.parcel import ParcelController 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 @@ -59,6 +60,7 @@ def create_asgi_application() -> Litestar: SystemController, AuthController, UserController, + ParcelController, ], dependencies=get_all_dependencies(), openapi_config=OpenAPIConfig( From a5ad30f7fd7ea46b4c33d25e90c62ab4ea791b58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 15:15:54 +0300 Subject: [PATCH 39/49] chore: update project dependencies --- pyproject.toml | 2 ++ uv.lock | 79 ++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 78 insertions(+), 3 deletions(-) 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/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" From 04a809015e7a27b31bb72f689d87777a4c672439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 17:00:41 +0300 Subject: [PATCH 40/49] feat(interface): add guards --- src/app/interface/http/__init__.py | 4 +- src/app/interface/http/asgi.py | 4 +- src/app/interface/http/guards.py | 49 +++++++++++++++++++ .../interface/http/{util => }/middleware.py | 0 src/app/interface/http/util/__init__.py | 2 - 5 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 src/app/interface/http/guards.py rename src/app/interface/http/{util => }/middleware.py (100%) diff --git a/src/app/interface/http/__init__.py b/src/app/interface/http/__init__.py index cdc6be5..d2b6273 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, guards, middleware, schema, util __all__ = ( "asgi", "controller", + "guards", + "middleware", "schema", "util", ) diff --git a/src/app/interface/http/asgi.py b/src/app/interface/http/asgi.py index 52e33cc..2cc7426 100644 --- a/src/app/interface/http/asgi.py +++ b/src/app/interface/http/asgi.py @@ -11,8 +11,9 @@ from app import __api_version__ from app.interface.http.controller.system import SystemController +from app.interface.http.guards import require_authorization +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, @@ -63,6 +64,7 @@ def create_asgi_application() -> Litestar: ParcelController, ], dependencies=get_all_dependencies(), + guards=[require_authorization], openapi_config=OpenAPIConfig( title="Land Sight API", version=__api_version__, diff --git a/src/app/interface/http/guards.py b/src/app/interface/http/guards.py new file mode 100644 index 0000000..6fbecde --- /dev/null +++ b/src/app/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/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", From 00abdf7038e6df24ad1ba3ba192b358b5b33cfef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 17:06:23 +0300 Subject: [PATCH 41/49] feat: update current user provider --- src/app/interface/http/util/dependencies.py | 43 ++++++++++++++- .../security/jwt_current_user_provider.py | 13 +++-- .../interface/http/controller/auth.py | 1 + .../interface/http/controller/user.py | 18 ++---- .../application/dto/command/get_parcel.py | 3 + .../parcel/application/use_case/get_parcel.py | 18 +++++- .../interface/http/controller/parcel.py | 55 +++++++------------ .../module/shared/application/dto/__init__.py | 1 + .../application/dto/response/__init__.py | 4 ++ .../application/dto/response/current_user.py | 26 +++++++++ .../application/port/current_user_provider.py | 17 ++++-- 11 files changed, 135 insertions(+), 64 deletions(-) create mode 100644 src/app/module/shared/application/dto/__init__.py create mode 100644 src/app/module/shared/application/dto/response/__init__.py create mode 100644 src/app/module/shared/application/dto/response/current_user.py diff --git a/src/app/interface/http/util/dependencies.py b/src/app/interface/http/util/dependencies.py index 97bdf9f..0e6b96b 100644 --- a/src/app/interface/http/util/dependencies.py +++ b/src/app/interface/http/util/dependencies.py @@ -1,6 +1,11 @@ """Dependency assembly for the application.""" -from typing import TYPE_CHECKING +from __future__ import annotations + +from typing import TYPE_CHECKING, 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 @@ -8,7 +13,40 @@ if TYPE_CHECKING: - from litestar.di import Provide + from app.module.shared.application.dto.response import CurrentUser + from app.module.shared.application.port import CurrentUserProvider + + +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]: @@ -23,4 +61,5 @@ def get_all_dependencies() -> dict[str, Provide]: 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/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/interface/http/controller/auth.py b/src/app/module/identity/interface/http/controller/auth.py index d647fce..fb7a180 100644 --- a/src/app/module/identity/interface/http/controller/auth.py +++ b/src/app/module/identity/interface/http/controller/auth.py @@ -34,6 +34,7 @@ class AuthController(Controller): path = "/api/v1/auth" tags = ("auth",) + guards: list = [] # noqa: RUF012 @post( "/register", diff --git a/src/app/module/identity/interface/http/controller/user.py b/src/app/module/identity/interface/http/controller/user.py index 7c16779..afcb85d 100644 --- a/src/app/module/identity/interface/http/controller/user.py +++ b/src/app/module/identity/interface/http/controller/user.py @@ -1,17 +1,14 @@ """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 class UserController(Controller): @@ -28,8 +25,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 +33,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/application/dto/command/get_parcel.py b/src/app/module/parcel/application/dto/command/get_parcel.py index b2c5343..03fd2c7 100644 --- a/src/app/module/parcel/application/dto/command/get_parcel.py +++ b/src/app/module/parcel/application/dto/command/get_parcel.py @@ -11,9 +11,12 @@ class GetParcelCommand: ---------- 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/use_case/get_parcel.py b/src/app/module/parcel/application/use_case/get_parcel.py index 5d4ebb5..e8861bb 100644 --- a/src/app/module/parcel/application/use_case/get_parcel.py +++ b/src/app/module/parcel/application/use_case/get_parcel.py @@ -7,8 +7,8 @@ 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 ParcelNotFoundError -from app.module.parcel.domain.value_object import ParcelId +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 @@ -18,7 +18,10 @@ class GetParcelUseCase(BaseUseCase[GetParcelCommand, ParcelResponse]): - """Retrieve a parcel by its ID.""" + """Retrieve a parcel by its ID. + + Only the owner of the parcel can retrieve it. + """ def __init__( self, @@ -40,6 +43,15 @@ async def __call__(self, command: GetParcelCommand) -> ParcelResponse: 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( diff --git a/src/app/module/parcel/interface/http/controller/parcel.py b/src/app/module/parcel/interface/http/controller/parcel.py index 8b19793..a5b7e6a 100644 --- a/src/app/module/parcel/interface/http/controller/parcel.py +++ b/src/app/module/parcel/interface/http/controller/parcel.py @@ -1,11 +1,8 @@ """Parcel endpoints.""" -from typing import Annotated - from litestar import delete, get, post from litestar.controller import Controller from litestar.di import NamedDependency -from litestar.params import HeaderParameter from litestar.status_codes import HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT from app.module.parcel.application.dto.command import ( @@ -25,7 +22,7 @@ ParcelListResponse, ParcelResponse, ) -from app.module.shared.application.port import CurrentUserProvider +from app.module.shared.application.dto.response import CurrentUser class ParcelController(Controller): @@ -43,8 +40,7 @@ async def create_parcel( self, data: CreateParcelRequest, create_parcel_use_case: NamedDependency[CreateParcelUseCase], - current_user_provider: NamedDependency[CurrentUserProvider], - authorization: Annotated[str, HeaderParameter(name="Authorization", required=True)], + current_user: CurrentUser, ) -> ParcelResponse: """Create a new parcel. @@ -54,23 +50,18 @@ async def create_parcel( Parcel data. create_parcel_use_case : CreateParcelUseCase Injected use case. - current_user_provider : CurrentUserProvider - Injected provider for extracting user ID from the token. - authorization : str - Raw Authorization header value. + current_user : CurrentUser + The currently authenticated user (resolved from token). Returns ------- ParcelResponse Created parcel data. """ - token = (authorization or "").removeprefix("Bearer ") - current_user_id = await current_user_provider.get_current_user_id(token) - command = CreateParcelCommand( name=data.name, polygon=data.polygon, - owner_id=current_user_id, + owner_id=current_user.id, ) result = await create_parcel_use_case(command) @@ -89,8 +80,7 @@ async def create_parcel( async def list_user_parcels( self, list_user_parcels_use_case: NamedDependency[ListUserParcelsUseCase], - current_user_provider: NamedDependency[CurrentUserProvider], - authorization: Annotated[str, HeaderParameter(name="Authorization", required=True)], + current_user: CurrentUser, ) -> ParcelListResponse: """List all parcels owned by the currently authenticated user. @@ -98,20 +88,15 @@ async def list_user_parcels( ---------- list_user_parcels_use_case : ListUserParcelsUseCase Injected use case. - current_user_provider : CurrentUserProvider - Injected provider for extracting user ID from the token. - authorization : str - Raw Authorization header value. + current_user : CurrentUser + The currently authenticated user (resolved from token). Returns ------- ParcelListResponse List of parcels owned by the user. """ - token = (authorization or "").removeprefix("Bearer ") - current_user_id = await current_user_provider.get_current_user_id(token) - - command = ListUserParcelsCommand(owner_id=current_user_id) + command = ListUserParcelsCommand(owner_id=current_user.id) results = await list_user_parcels_use_case(command) parcels = [ @@ -135,6 +120,7 @@ async def get_parcel( self, parcel_id: str, get_parcel_use_case: NamedDependency[GetParcelUseCase], + current_user: CurrentUser, ) -> ParcelResponse: """Get a parcel by its ID. @@ -144,13 +130,18 @@ async def get_parcel( Parcel identifier from the path. get_parcel_use_case : GetParcelUseCase Injected use case. + current_user : CurrentUser + The currently authenticated user (resolved from token). Returns ------- ParcelResponse Parcel data. """ - command = GetParcelCommand(parcel_id=parcel_id) + command = GetParcelCommand( + parcel_id=parcel_id, + current_user_id=current_user.id, + ) result = await get_parcel_use_case(command) return ParcelResponse( @@ -169,8 +160,7 @@ async def delete_parcel( self, parcel_id: str, delete_parcel_use_case: NamedDependency[DeleteParcelUseCase], - current_user_provider: NamedDependency[CurrentUserProvider], - authorization: Annotated[str, HeaderParameter(name="Authorization", required=True)], + current_user: CurrentUser, ) -> None: """Delete a parcel by its ID. @@ -182,17 +172,12 @@ async def delete_parcel( Parcel identifier from the path. delete_parcel_use_case : DeleteParcelUseCase Injected use case. - current_user_provider : CurrentUserProvider - Injected provider for extracting user ID from the token. - authorization : str - Raw Authorization header value. + current_user : CurrentUser + The currently authenticated user (resolved from token). """ - token = (authorization or "").removeprefix("Bearer ") - current_user_id = await current_user_provider.get_current_user_id(token) - command = DeleteParcelCommand( parcel_id=parcel_id, - current_user_id=current_user_id, + current_user_id=current_user.id, ) await delete_parcel_use_case(command) 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 ------ From f5754b68a7e632f0b1a5aec1a19b72b3379f424b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 17:10:50 +0300 Subject: [PATCH 42/49] fix: incorrect type checking usage --- src/app/interface/http/util/dependencies.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/app/interface/http/util/dependencies.py b/src/app/interface/http/util/dependencies.py index 0e6b96b..70f825b 100644 --- a/src/app/interface/http/util/dependencies.py +++ b/src/app/interface/http/util/dependencies.py @@ -1,22 +1,17 @@ """Dependency assembly for the application.""" -from __future__ import annotations - -from typing import TYPE_CHECKING, Annotated +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 app.module.shared.application.dto.response import CurrentUser - from app.module.shared.application.port import CurrentUserProvider - - async def provide_current_user( current_user_provider: CurrentUserProvider, authorization: Annotated[str | None, HeaderParameter(name="Authorization")] = None, From d236f8bfa46bf4d298df069fe3b8572e3d9423e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Tue, 14 Jul 2026 17:15:19 +0300 Subject: [PATCH 43/49] fix: litestar di warnings --- src/app/module/identity/di.py | 16 ++++++++-------- src/app/module/parcel/di.py | 12 ++++++------ src/app/platform/di.py | 6 +++--- 3 files changed, 17 insertions(+), 17 deletions(-) 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/parcel/di.py b/src/app/module/parcel/di.py index 735b3a8..51a5b07 100644 --- a/src/app/module/parcel/di.py +++ b/src/app/module/parcel/di.py @@ -54,12 +54,12 @@ def provide_delete_parcel_use_case( parcel_dependencies = { - "parcel_repository": Provide(provide_postgres_parcel_repository), - "polygon_service": Provide(provide_shapely_polygon_service), - "create_parcel_use_case": Provide(provide_create_parcel_use_case), - "get_parcel_use_case": Provide(provide_get_parcel_use_case), - "list_user_parcels_use_case": Provide(provide_list_user_parcels_use_case), - "delete_parcel_use_case": Provide(provide_delete_parcel_use_case), + "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/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), } From 821028fc567e85c9a60d95b6ba6cbcee97d73515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Wed, 15 Jul 2026 11:04:11 +0300 Subject: [PATCH 44/49] refactor: configure guards for controllers --- src/app/interface/http/__init__.py | 4 +- src/app/interface/http/asgi.py | 30 +---------- src/app/interface/http/lifespan.py | 32 ++++++++++++ .../interface/http/util/exception_handlers.py | 50 +++++++++++++++++++ .../application/use_case/authenticate_user.py | 9 +++- .../interface/http/controller/auth.py | 1 - .../interface/http/controller/user.py | 2 + .../parcel/domain/value_object/parcel_name.py | 6 +-- .../interface/http/controller/parcel.py | 2 + .../module/shared/interface/http/__init__.py | 7 ++- .../shared}/interface/http/guards.py | 0 11 files changed, 104 insertions(+), 39 deletions(-) create mode 100644 src/app/interface/http/lifespan.py rename src/app/{ => module/shared}/interface/http/guards.py (100%) diff --git a/src/app/interface/http/__init__.py b/src/app/interface/http/__init__.py index d2b6273..7762b29 100644 --- a/src/app/interface/http/__init__.py +++ b/src/app/interface/http/__init__.py @@ -1,10 +1,10 @@ -from . import asgi, controller, guards, middleware, schema, util +from . import asgi, controller, lifespan, middleware, schema, util __all__ = ( "asgi", "controller", - "guards", + "lifespan", "middleware", "schema", "util", diff --git a/src/app/interface/http/asgi.py b/src/app/interface/http/asgi.py index 2cc7426..2a6820e 100644 --- a/src/app/interface/http/asgi.py +++ b/src/app/interface/http/asgi.py @@ -2,16 +2,13 @@ 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.guards import require_authorization +from app.interface.http.lifespan import lifespan from app.interface.http.middleware import RequestLoggingMiddleware from app.interface.http.util import ( create_exception_handlers, @@ -22,30 +19,6 @@ from app.module.identity.interface.http.controller.auth import AuthController from app.module.identity.interface.http.controller.user import UserController from app.module.parcel.interface.http.controller.parcel import ParcelController -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) def create_asgi_application() -> Litestar: @@ -64,7 +37,6 @@ def create_asgi_application() -> Litestar: ParcelController, ], dependencies=get_all_dependencies(), - guards=[require_authorization], openapi_config=OpenAPIConfig( title="Land Sight API", version=__api_version__, 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/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/use_case/authenticate_user.py b/src/app/module/identity/application/use_case/authenticate_user.py index abc9fcd..c91fdbf 100644 --- a/src/app/module/identity/application/use_case/authenticate_user.py +++ b/src/app/module/identity/application/use_case/authenticate_user.py @@ -12,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 @@ -42,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) diff --git a/src/app/module/identity/interface/http/controller/auth.py b/src/app/module/identity/interface/http/controller/auth.py index fb7a180..d647fce 100644 --- a/src/app/module/identity/interface/http/controller/auth.py +++ b/src/app/module/identity/interface/http/controller/auth.py @@ -34,7 +34,6 @@ class AuthController(Controller): path = "/api/v1/auth" tags = ("auth",) - guards: list = [] # noqa: RUF012 @post( "/register", diff --git a/src/app/module/identity/interface/http/controller/user.py b/src/app/module/identity/interface/http/controller/user.py index afcb85d..6e4b617 100644 --- a/src/app/module/identity/interface/http/controller/user.py +++ b/src/app/module/identity/interface/http/controller/user.py @@ -9,6 +9,7 @@ from app.module.identity.application.use_case import GetUserUseCase from app.module.identity.interface.http.schema.user import UserResponse from app.module.shared.application.dto.response import CurrentUser +from app.module.shared.interface.http.guards import require_authorization class UserController(Controller): @@ -16,6 +17,7 @@ class UserController(Controller): path = "/api/v1/users" tags = ("users",) + guards = [require_authorization] # noqa: RUF012 @get( "/profile", diff --git a/src/app/module/parcel/domain/value_object/parcel_name.py b/src/app/module/parcel/domain/value_object/parcel_name.py index ce23d90..b83bf8a 100644 --- a/src/app/module/parcel/domain/value_object/parcel_name.py +++ b/src/app/module/parcel/domain/value_object/parcel_name.py @@ -17,7 +17,7 @@ class ParcelName(BaseValueObject[str]): - Allowed: letters, digits, spaces, hyphens, underscores - Must start with a letter - No leading/trailing whitespace - - Normalized: stripped and lowercased + - Normalized: stripped (case-sensitive for display) """ _MIN_LENGTH = 3 @@ -29,7 +29,7 @@ class ParcelName(BaseValueObject[str]): @override def _normalize(self, value: str) -> str: - return value.strip().lower() + return value.strip() @override def _validate(self) -> None: @@ -41,7 +41,7 @@ def _validate(self) -> None: raise ValidationError(message) if not self._PATTERN.match(self._value): - allowed = "alphanumeric, spaces, hyphens, and underscores" + allowed = "letters, digits, spaces, hyphens, and underscores" message = f"Parcel name must start with a letter and contain only {allowed} characters." raise ValidationError(message) diff --git a/src/app/module/parcel/interface/http/controller/parcel.py b/src/app/module/parcel/interface/http/controller/parcel.py index a5b7e6a..1c0c989 100644 --- a/src/app/module/parcel/interface/http/controller/parcel.py +++ b/src/app/module/parcel/interface/http/controller/parcel.py @@ -23,6 +23,7 @@ ParcelResponse, ) from app.module.shared.application.dto.response import CurrentUser +from app.module.shared.interface.http.guards import require_authorization class ParcelController(Controller): @@ -30,6 +31,7 @@ class ParcelController(Controller): path = "/api/v1/parcels" tags = ("parcels",) + guards = [require_authorization] # noqa: RUF012 @post( "/", 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/interface/http/guards.py b/src/app/module/shared/interface/http/guards.py similarity index 100% rename from src/app/interface/http/guards.py rename to src/app/module/shared/interface/http/guards.py From 062f221f416607c8e42fae7c293d70eaea2afdc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Wed, 15 Jul 2026 11:44:26 +0300 Subject: [PATCH 45/49] fix(parcel): incorrect owner id representation --- src/app/module/parcel/application/use_case/get_parcel.py | 2 +- src/app/module/parcel/application/use_case/list_user_parcels.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/module/parcel/application/use_case/get_parcel.py b/src/app/module/parcel/application/use_case/get_parcel.py index e8861bb..533e221 100644 --- a/src/app/module/parcel/application/use_case/get_parcel.py +++ b/src/app/module/parcel/application/use_case/get_parcel.py @@ -58,7 +58,7 @@ async def __call__(self, command: GetParcelCommand) -> ParcelResponse: id=str(parcel.id.unwrap()), name=parcel.name.unwrap(), polygon=self._polygon_service.from_domain(parcel.polygon), - owner_id=str(parcel.owner_id), + owner_id=str(parcel.owner_id.unwrap()), ) 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 index 8ba8845..d2fb369 100644 --- a/src/app/module/parcel/application/use_case/list_user_parcels.py +++ b/src/app/module/parcel/application/use_case/list_user_parcels.py @@ -42,7 +42,7 @@ async def __call__(self, command: ListUserParcelsCommand) -> list[ParcelResponse id=str(parcel.id.unwrap()), name=parcel.name.unwrap(), polygon=self._polygon_service.from_domain(parcel.polygon), - owner_id=str(parcel.owner_id), + owner_id=str(parcel.owner_id.unwrap()), ) for parcel in parcels ] From 954bff5d7c71aac546256e383b52bb15f89add75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Wed, 15 Jul 2026 13:33:00 +0300 Subject: [PATCH 46/49] feat(shared): add geojson schema --- .../shared/interface/http/schema/__init__.py | 12 +++ .../shared/interface/http/schema/geojson.py | 78 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 src/app/module/shared/interface/http/schema/__init__.py create mode 100644 src/app/module/shared/interface/http/schema/geojson.py 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", +) From aec7e0529ba7c27cc388a952eb3d4cb9fcd3f91f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Wed, 15 Jul 2026 13:34:44 +0300 Subject: [PATCH 47/49] feat(parcel): use geojson response format --- src/app/module/parcel/application/error.py | 8 +++ src/app/module/parcel/error_mappings.py | 2 + .../geo/shapely_polygon_service.py | 37 ++++++----- .../interface/http/controller/parcel.py | 65 ++++++++++--------- .../parcel/interface/http/schema/__init__.py | 10 +-- .../parcel/interface/http/schema/parcel.py | 41 ++++++++---- 6 files changed, 100 insertions(+), 63 deletions(-) diff --git a/src/app/module/parcel/application/error.py b/src/app/module/parcel/application/error.py index 20a65d9..9b3754a 100644 --- a/src/app/module/parcel/application/error.py +++ b/src/app/module/parcel/application/error.py @@ -17,6 +17,13 @@ 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.""" @@ -32,6 +39,7 @@ def __init__(self, parcel_id: str) -> None: __all__ = ( + "InvalidGeoJsonError", "InvalidPolygonError", "NotParcelOwnerError", "ParcelAlreadyExistsError", diff --git a/src/app/module/parcel/error_mappings.py b/src/app/module/parcel/error_mappings.py index 91396e3..321825f 100644 --- a/src/app/module/parcel/error_mappings.py +++ b/src/app/module/parcel/error_mappings.py @@ -12,6 +12,7 @@ ) from app.module.parcel.application.error import ( + InvalidGeoJsonError, InvalidPolygonError, NotParcelOwnerError, ParcelAlreadyExistsError, @@ -33,6 +34,7 @@ def get_parcel_application_error_mappings() -> 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, } diff --git a/src/app/module/parcel/infrastructure/geo/shapely_polygon_service.py b/src/app/module/parcel/infrastructure/geo/shapely_polygon_service.py index a08497c..f6f3c4b 100644 --- a/src/app/module/parcel/infrastructure/geo/shapely_polygon_service.py +++ b/src/app/module/parcel/infrastructure/geo/shapely_polygon_service.py @@ -4,14 +4,13 @@ from typing import Any, override -from shapely.geometry import Polygon as ShapelyPolygon, mapping, shape +from shapely.geometry import Polygon as ShapelyPolygon, mapping from shapely.validation import explain_validity -from app.module.parcel.application.error import InvalidPolygonError +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 -from app.module.shared.domain.error import ValidationError class ShapelyPolygonService(PolygonService): @@ -25,16 +24,24 @@ class ShapelyPolygonService(PolygonService): 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): - message = "GeoJSON must be a dict." - raise ValidationError(message) + reason = "GeoJSON must be a dict" + raise InvalidGeoJsonError(reason) - shapely_geom = shape(geojson) + if geojson.get("type") != "Polygon": + reason = f"Expected Polygon geometry, got '{geojson.get('type')}'" + raise InvalidGeoJsonError(reason) - if shapely_geom.geom_type != "Polygon": - message = f"Expected Polygon geometry, got '{shapely_geom.geom_type}'." - raise ValidationError(message) + 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) - points = [GeoPoint.create(float(coord[1]), float(coord[0])) for coord in shapely_geom.exterior.coords] + 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)) @@ -43,12 +50,7 @@ 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 = shape( - { - "type": "Polygon", - "coordinates": [coords], - } - ) + shapely_geom = ShapelyPolygon(coords) return mapping(shapely_geom) @@ -73,7 +75,8 @@ def calculate_area(self, polygon: Polygon) -> float: return area_m2 - def _to_shapely(self, polygon: Polygon) -> ShapelyPolygon: + @staticmethod + def _to_shapely(polygon: Polygon) -> ShapelyPolygon: """Convert domain Polygon to Shapely Polygon. Shapely uses (x, y) = (lon, lat) order. diff --git a/src/app/module/parcel/interface/http/controller/parcel.py b/src/app/module/parcel/interface/http/controller/parcel.py index 1c0c989..4e773f5 100644 --- a/src/app/module/parcel/interface/http/controller/parcel.py +++ b/src/app/module/parcel/interface/http/controller/parcel.py @@ -19,8 +19,9 @@ ) from app.module.parcel.interface.http.schema.parcel import ( CreateParcelRequest, - ParcelListResponse, - ParcelResponse, + ParcelFeature, + ParcelFeatureCollection, + ParcelFeatureProperties, ) from app.module.shared.application.dto.response import CurrentUser from app.module.shared.interface.http.guards import require_authorization @@ -43,7 +44,7 @@ async def create_parcel( data: CreateParcelRequest, create_parcel_use_case: NamedDependency[CreateParcelUseCase], current_user: CurrentUser, - ) -> ParcelResponse: + ) -> ParcelFeature: """Create a new parcel. Parameters @@ -57,21 +58,23 @@ async def create_parcel( Returns ------- - ParcelResponse - Created parcel data. + ParcelFeature + Created parcel as a GeoJSON Feature. """ command = CreateParcelCommand( name=data.name, - polygon=data.polygon, + polygon=data.polygon.model_dump(), owner_id=current_user.id, ) result = await create_parcel_use_case(command) - return ParcelResponse( - id=result.id, - name=result.name, - polygon=result.polygon, - owner_id=result.owner_id, + return ParcelFeature( + geometry=result.polygon, + properties=ParcelFeatureProperties( + id=result.id, + name=result.name, + owner_id=result.owner_id, + ), ) @get( @@ -83,7 +86,7 @@ async def list_user_parcels( self, list_user_parcels_use_case: NamedDependency[ListUserParcelsUseCase], current_user: CurrentUser, - ) -> ParcelListResponse: + ) -> ParcelFeatureCollection: """List all parcels owned by the currently authenticated user. Parameters @@ -95,23 +98,25 @@ async def list_user_parcels( Returns ------- - ParcelListResponse - List of parcels owned by the user. + ParcelFeatureCollection + List of parcels as a GeoJSON FeatureCollection. """ command = ListUserParcelsCommand(owner_id=current_user.id) results = await list_user_parcels_use_case(command) - parcels = [ - ParcelResponse( - id=r.id, - name=r.name, - polygon=r.polygon, - owner_id=r.owner_id, + features = [ + ParcelFeature( + geometry=r.polygon, + properties=ParcelFeatureProperties( + id=r.id, + name=r.name, + owner_id=r.owner_id, + ), ) for r in results ] - return ParcelListResponse(parcels=parcels, total=len(parcels)) + return ParcelFeatureCollection(features=features, total=len(features)) @get( "/{parcel_id:str}", @@ -123,7 +128,7 @@ async def get_parcel( parcel_id: str, get_parcel_use_case: NamedDependency[GetParcelUseCase], current_user: CurrentUser, - ) -> ParcelResponse: + ) -> ParcelFeature: """Get a parcel by its ID. Parameters @@ -137,8 +142,8 @@ async def get_parcel( Returns ------- - ParcelResponse - Parcel data. + ParcelFeature + Parcel as a GeoJSON Feature. """ command = GetParcelCommand( parcel_id=parcel_id, @@ -146,11 +151,13 @@ async def get_parcel( ) result = await get_parcel_use_case(command) - return ParcelResponse( - id=result.id, - name=result.name, - polygon=result.polygon, - owner_id=result.owner_id, + return ParcelFeature( + geometry=result.polygon, + properties=ParcelFeatureProperties( + id=result.id, + name=result.name, + owner_id=result.owner_id, + ), ) @delete( diff --git a/src/app/module/parcel/interface/http/schema/__init__.py b/src/app/module/parcel/interface/http/schema/__init__.py index 71b437f..e78b58e 100644 --- a/src/app/module/parcel/interface/http/schema/__init__.py +++ b/src/app/module/parcel/interface/http/schema/__init__.py @@ -1,12 +1,14 @@ from .parcel import ( CreateParcelRequest, - ParcelListResponse, - ParcelResponse, + ParcelFeature, + ParcelFeatureCollection, + ParcelFeatureProperties, ) __all__ = ( "CreateParcelRequest", - "ParcelListResponse", - "ParcelResponse", + "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 index 9f63c13..5fc7d22 100644 --- a/src/app/module/parcel/interface/http/schema/parcel.py +++ b/src/app/module/parcel/interface/http/schema/parcel.py @@ -1,37 +1,52 @@ """Parcel HTTP schemas.""" -from typing import Any +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.") - polygon: dict[str, Any] = Field( - description='GeoJSON Polygon geometry (e.g. ``{"type": "Polygon", "coordinates": [...]}``).', + 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 ParcelResponse(BaseModel): - """Response body for parcel data.""" +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.") - polygon: dict[str, Any] = Field(description="GeoJSON Polygon geometry.") owner_id: str = Field(description="ID of the user who owns this parcel.") -class ParcelListResponse(BaseModel): - """Response body for a list of parcels.""" +class ParcelFeature(GeoJSONFeature): + """GeoJSON Feature for a parcel.""" + + properties: ParcelFeatureProperties = Field(description="Parcel properties.") + + +class ParcelFeatureCollection(GeoJSONFeatureCollection): + """GeoJSON FeatureCollection for a list of parcels.""" - parcels: list[ParcelResponse] = Field(description="List of parcels.") - total: int = Field(description="Total number of parcels.") + features: list[ParcelFeature] = Field(description="List of parcel features.") __all__ = ( "CreateParcelRequest", - "ParcelListResponse", - "ParcelResponse", + "ParcelFeature", + "ParcelFeatureCollection", + "ParcelFeatureProperties", ) From eecd95ba013e38658c8d4e4ee63eac76b323bc44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Wed, 15 Jul 2026 13:35:36 +0300 Subject: [PATCH 48/49] feat(platform): add postgis extension for database --- ...0002_ef70c85c6229_add_postgis_extension.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/app/platform/database/migrations/versions/0002_ef70c85c6229_add_postgis_extension.py 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") From 6cf40dff253657792a6154d8e9f92ab091916593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=B2=D1=80=D0=B8=D0=BD=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B5=D0=B9?= Date: Wed, 15 Jul 2026 13:36:30 +0300 Subject: [PATCH 49/49] feat(parcel): add migrations --- .../module/parcel/infrastructure/alembic.ini | 8 ++ .../parcel/infrastructure/migrations/README | 1 + .../parcel/infrastructure/migrations/env.py | 82 +++++++++++++++++++ .../infrastructure/migrations/script.py.mako | 28 +++++++ ...1_15c4a02c5334_add_parcel_parcels_table.py | 73 +++++++++++++++++ 5 files changed, 192 insertions(+) create mode 100644 src/app/module/parcel/infrastructure/alembic.ini create mode 100644 src/app/module/parcel/infrastructure/migrations/README create mode 100644 src/app/module/parcel/infrastructure/migrations/env.py create mode 100644 src/app/module/parcel/infrastructure/migrations/script.py.mako create mode 100644 src/app/module/parcel/infrastructure/migrations/versions/0001_15c4a02c5334_add_parcel_parcels_table.py 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/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 ###