Restructure with Turborepo
Run Tests on Branches / Backend Tests (push) Successful in 3m38s
Run Tests on Branches / Frontend Tests (push) Successful in 3m28s

This commit is contained in:
Antoni Nuñez Romeu
2026-07-06 15:51:53 +02:00
parent f66cafbbc3
commit 190b3d163d
277 changed files with 53253 additions and 0 deletions
@@ -0,0 +1,3 @@
from .redis_cache import redis_cache
__all__ = ["redis_cache"]
@@ -0,0 +1,73 @@
import json
from typing import Any
import redis.asyncio as aioredis
from src.infrastructure.config.settings import settings
class RedisCache:
def __init__(self) -> None:
self._client: aioredis.Redis | None = None
async def connect(self) -> None:
self._client = aioredis.from_url(
settings.REDIS_URL,
decode_responses=True,
max_connections=20,
)
async def disconnect(self) -> None:
if self._client:
await self._client.close()
@property
def client(self) -> aioredis.Redis:
if self._client is None:
raise RuntimeError("Redis client not connected. Call connect() first.")
return self._client
async def get(self, key: str) -> Any | None:
data = await self.client.get(key)
if data is None:
return None
try:
return json.loads(data)
except (json.JSONDecodeError, TypeError):
return data
async def set(self, key: str, value: Any, ttl: int | None = None) -> None:
ttl = ttl or settings.REDIS_CACHE_TTL
if isinstance(value, (dict, list, tuple)):
value = json.dumps(value)
await self.client.set(key, value, ex=ttl)
async def delete(self, key: str) -> None:
await self.client.delete(key)
async def exists(self, key: str) -> bool:
return bool(await self.client.exists(key))
async def get_with_lock(self, key: str, lock_timeout: int = 10) -> Any | None:
async with self.client.lock(f"{key}:lock", timeout=lock_timeout):
return await self.get(key)
async def set_with_lock(self, key: str, value: Any, ttl: int | None = None, lock_timeout: int = 10) -> None:
async with self.client.lock(f"{key}:lock", timeout=lock_timeout):
await self.set(key, value, ttl)
async def invalidate_pattern(self, pattern: str) -> None:
keys = []
async for key in self.client.scan_iter(match=pattern):
keys.append(key)
if keys:
await self.client.delete(*keys)
async def health_check(self) -> bool:
try:
return await self.client.ping()
except Exception:
return False
redis_cache = RedisCache()
@@ -0,0 +1,58 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
env_nested_delimiter="__",
extra="ignore",
)
APP_NAME: str = "PIP - Pharmacy Integration Platform"
APP_VERSION: str = "0.1.0"
DEBUG: bool = False
DATABASE_URL: str = "postgresql+asyncpg://pip:pip@localhost:5432/pip"
DATABASE_POOL_SIZE: int = 20
DATABASE_MAX_OVERFLOW: int = 10
DATABASE_POOL_RECYCLE: int = 3600
REDIS_URL: str = "redis://localhost:6379/0"
REDIS_CACHE_TTL: int = 300
RABBITMQ_URL: str = "amqp://pip:pip@localhost:5672/pip"
JWT_SECRET_KEY: str = "change-me-in-production"
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
JWT_ISSUER: str = "pip-platform"
OAUTH2_TOKEN_URL: str = "/api/v1/auth/token"
RATE_LIMIT_PER_MINUTE: int = 60
RATE_LIMIT_AUTH_PER_MINUTE: int = 10
API_KEY_PREFIX: str = "pip"
API_KEY_LENGTH: int = 32
OTEL_EXPORTER_OTLP_ENDPOINT: str = "http://localhost:4317"
OTEL_SERVICE_NAME: str = "pip-platform"
OTEL_TRACES_ENABLED: bool = True
OTEL_METRICS_ENABLED: bool = True
LOG_LEVEL: str = "INFO"
LOG_JSON_FORMAT: bool = True
CORS_ORIGINS: list[str] = ["*"]
CORS_ALLOW_CREDENTIALS: bool = True
HEALTH_CHECK_CACHE_TTL: int = 10
@property
def DATABASE_URL_SYNC(self) -> str:
return self.DATABASE_URL.replace("+asyncpg", "+psycopg2", 1)
settings = Settings()
@@ -0,0 +1,9 @@
from .models import *
from .session import async_session_factory, engine, get_db_session, get_db_session_no_commit
__all__ = [
"async_session_factory",
"engine",
"get_db_session",
"get_db_session_no_commit",
]
@@ -0,0 +1,41 @@
from .models import (
APIClientModel,
APIKeyModel,
AuditLogModel,
Base,
BaseMixin,
ConnectorConfigModel,
ConnectorModel,
ConnectorVersionModel,
ERPSystemModel,
InventoryHistoryModel,
InventoryRecordModel,
MedicationCatalogEntryModel,
MedicationModel,
PharmacyModel,
ReservationModel,
SyncJobModel,
SyncLogModel,
TechnicalUserModel,
)
__all__ = [
"APIClientModel",
"APIKeyModel",
"AuditLogModel",
"Base",
"BaseMixin",
"ConnectorConfigModel",
"ConnectorModel",
"ConnectorVersionModel",
"ERPSystemModel",
"InventoryHistoryModel",
"InventoryRecordModel",
"MedicationCatalogEntryModel",
"MedicationModel",
"PharmacyModel",
"ReservationModel",
"SyncJobModel",
"SyncLogModel",
"TechnicalUserModel",
]
@@ -0,0 +1,349 @@
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
Enum,
Float,
ForeignKey,
Index,
Integer,
LargeBinary,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class BaseMixin:
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
class PharmacyModel(Base, BaseMixin):
__tablename__ = "pharmacies"
code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
address: Mapped[str | None] = mapped_column(String(500))
city: Mapped[str | None] = mapped_column(String(100))
province: Mapped[str | None] = mapped_column(String(100))
postal_code: Mapped[str | None] = mapped_column(String(20))
country: Mapped[str] = mapped_column(String(2), default="ES")
latitude: Mapped[float | None] = mapped_column(Float)
longitude: Mapped[float | None] = mapped_column(Float)
phone: Mapped[str | None] = mapped_column(String(50))
email: Mapped[str | None] = mapped_column(String(255))
opening_hours: Mapped[dict | None] = mapped_column(JSONB)
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
erp_type: Mapped[str | None] = mapped_column(String(50))
erp_version: Mapped[str | None] = mapped_column(String(50))
connector_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("connectors.id"), nullable=True)
sync_strategy: Mapped[str] = mapped_column(String(20), default="incremental")
sync_interval_minutes: Mapped[int] = mapped_column(Integer, default=30)
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_sync_status: Mapped[str | None] = mapped_column(String(20))
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
connector = relationship("ConnectorModel", back_populates="pharmacies", foreign_keys=[connector_id])
inventory_records = relationship("InventoryRecordModel", back_populates="pharmacy", cascade="all, delete-orphan")
reservations = relationship("ReservationModel", back_populates="pharmacy", cascade="all, delete-orphan")
catalog_entries = relationship("MedicationCatalogEntryModel", back_populates="pharmacy", cascade="all, delete-orphan")
sync_jobs = relationship("SyncJobModel", back_populates="pharmacy", cascade="all, delete-orphan")
__table_args__ = (
Index("ix_pharmacies_location", "latitude", "longitude"),
Index("ix_pharmacies_status_erp", "status", "erp_type"),
)
class ERPSystemModel(Base, BaseMixin):
__tablename__ = "erp_systems"
name: Mapped[str] = mapped_column(String(255), nullable=False)
erp_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
version: Mapped[str | None] = mapped_column(String(50))
base_url: Mapped[str | None] = mapped_column(String(500))
auth_config: Mapped[dict | None] = mapped_column(JSONB)
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
connectors = relationship("ConnectorModel", back_populates="erp_system", cascade="all, delete-orphan")
class ConnectorModel(Base, BaseMixin):
__tablename__ = "connectors"
erp_system_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("erp_systems.id"), nullable=False, index=True)
pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
connector_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
version: Mapped[str] = mapped_column(String(20), default="1.0.0")
config: Mapped[dict | None] = mapped_column(JSONB)
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
last_heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_error: Mapped[str | None] = mapped_column(Text)
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
erp_system = relationship("ERPSystemModel", back_populates="connectors")
pharmacies = relationship("PharmacyModel", back_populates="connector", foreign_keys="PharmacyModel.connector_id")
configs = relationship("ConnectorConfigModel", back_populates="connector", cascade="all, delete-orphan")
connector_versions = relationship("ConnectorVersionModel", back_populates="connector", cascade="all, delete-orphan")
__table_args__ = (
UniqueConstraint("erp_system_id", "pharmacy_id", name="uq_connector_erp_pharmacy"),
)
class ConnectorVersionModel(Base, BaseMixin):
__tablename__ = "connector_versions"
connector_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("connectors.id"), nullable=False, index=True)
connector_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
version: Mapped[str] = mapped_column(String(20), nullable=False)
changelog: Mapped[str | None] = mapped_column(Text)
download_url: Mapped[str | None] = mapped_column(String(500))
checksum: Mapped[str | None] = mapped_column(String(128))
is_latest: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
is_required: Mapped[bool] = mapped_column(Boolean, default=False)
released_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
connector = relationship("ConnectorModel", back_populates="connector_versions")
__table_args__ = (
UniqueConstraint("connector_type", "version", name="uq_connector_version"),
)
class ConnectorConfigModel(Base, BaseMixin):
__tablename__ = "connector_configs"
connector_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("connectors.id"), nullable=False, index=True)
key: Mapped[str] = mapped_column(String(100), nullable=False)
value: Mapped[str] = mapped_column(Text, nullable=False)
encrypted: Mapped[bool] = mapped_column(Boolean, default=False)
description: Mapped[str | None] = mapped_column(String(500))
connector = relationship("ConnectorModel", back_populates="configs")
__table_args__ = (
UniqueConstraint("connector_id", "key", name="uq_connector_config_key"),
)
class MedicationModel(Base, BaseMixin):
__tablename__ = "medications"
nregistro: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
active_ingredient: Mapped[str | None] = mapped_column(String(500))
dosage: Mapped[str | None] = mapped_column(String(100))
form: Mapped[str | None] = mapped_column(String(100))
atc_code: Mapped[str | None] = mapped_column(String(20), index=True)
prescription_required: Mapped[bool] = mapped_column(Boolean, default=False)
manufacturer: Mapped[str | None] = mapped_column(String(255))
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
catalog_entries = relationship("MedicationCatalogEntryModel", back_populates="medication", cascade="all, delete-orphan")
inventory_records = relationship("InventoryRecordModel", back_populates="medication", cascade="all, delete-orphan")
reservations = relationship("ReservationModel", back_populates="medication", cascade="all, delete-orphan")
class MedicationCatalogEntryModel(Base, BaseMixin):
__tablename__ = "medication_catalog"
pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True)
medication_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("medications.id"), nullable=False, index=True)
price: Mapped[float | None] = mapped_column(Float)
stock: Mapped[int] = mapped_column(Integer, default=0)
is_available: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
last_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
pharmacy = relationship("PharmacyModel", back_populates="catalog_entries")
medication = relationship("MedicationModel", back_populates="catalog_entries")
__table_args__ = (
UniqueConstraint("pharmacy_id", "medication_id", name="uq_catalog_pharmacy_medication"),
)
class InventoryRecordModel(Base, BaseMixin):
__tablename__ = "inventory_records"
pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True)
medication_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("medications.id"), nullable=False, index=True)
stock: Mapped[int] = mapped_column(Integer, default=0)
reserved_stock: Mapped[int] = mapped_column(Integer, default=0)
available_stock: Mapped[int] = mapped_column(Integer, default=0)
min_stock: Mapped[int | None] = mapped_column(Integer)
max_stock: Mapped[int | None] = mapped_column(Integer)
last_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
source: Mapped[str | None] = mapped_column(String(100))
batch_id: Mapped[str | None] = mapped_column(String(100))
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
pharmacy = relationship("PharmacyModel", back_populates="inventory_records")
medication = relationship("MedicationModel", back_populates="inventory_records")
history = relationship("InventoryHistoryModel", back_populates="inventory_record", cascade="all, delete-orphan")
__table_args__ = (
UniqueConstraint("pharmacy_id", "medication_id", name="uq_inventory_pharmacy_medication"),
)
class InventoryHistoryModel(Base):
__tablename__ = "inventory_history"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
inventory_record_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("inventory_records.id"), nullable=False, index=True)
pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True)
medication_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("medications.id"), nullable=False, index=True)
previous_stock: Mapped[int] = mapped_column(Integer, nullable=False)
new_stock: Mapped[int] = mapped_column(Integer, nullable=False)
delta: Mapped[int] = mapped_column(Integer, nullable=False)
reason: Mapped[str | None] = mapped_column(String(255))
source: Mapped[str | None] = mapped_column(String(100))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
inventory_record = relationship("InventoryRecordModel", back_populates="history")
class ReservationModel(Base, BaseMixin):
__tablename__ = "reservations"
pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True)
medication_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("medications.id"), nullable=False, index=True)
user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("technical_users.id"), nullable=True, index=True)
external_reference: Mapped[str | None] = mapped_column(String(255))
quantity: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
confirmed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
idempotency_key: Mapped[str | None] = mapped_column(String(255), unique=True, index=True)
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
pharmacy = relationship("PharmacyModel", back_populates="reservations")
medication = relationship("MedicationModel", back_populates="reservations")
user = relationship("TechnicalUserModel", back_populates="reservations")
__table_args__ = (
Index("ix_reservations_pharmacy_status", "pharmacy_id", "status"),
Index("ix_reservations_expires", "expires_at"),
)
class SyncJobModel(Base, BaseMixin):
__tablename__ = "sync_jobs"
pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True)
connector_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("connectors.id"), nullable=False, index=True)
strategy: Mapped[str] = mapped_column(String(20), nullable=False)
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
items_total: Mapped[int | None] = mapped_column(Integer)
items_processed: Mapped[int | None] = mapped_column(Integer)
items_failed: Mapped[int | None] = mapped_column(Integer)
error_message: Mapped[str | None] = mapped_column(Text)
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
pharmacy = relationship("PharmacyModel", back_populates="sync_jobs")
logs = relationship("SyncLogModel", back_populates="sync_job", cascade="all, delete-orphan")
class SyncLogModel(Base):
__tablename__ = "sync_logs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
sync_job_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sync_jobs.id"), nullable=False, index=True)
level: Mapped[str] = mapped_column(String(20), default="INFO", nullable=False)
message: Mapped[str] = mapped_column(Text, nullable=False)
details: Mapped[dict | None] = mapped_column(JSONB)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
sync_job = relationship("SyncJobModel", back_populates="logs")
class AuditLogModel(Base):
__tablename__ = "audit_logs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
actor_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True)
actor_type: Mapped[str | None] = mapped_column(String(50))
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
resource_type: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
resource_id: Mapped[str | None] = mapped_column(String(255), index=True)
old_value: Mapped[dict | None] = mapped_column(JSONB)
new_value: Mapped[dict | None] = mapped_column(JSONB)
ip_address: Mapped[str | None] = mapped_column(String(45))
user_agent: Mapped[str | None] = mapped_column(String(500))
request_id: Mapped[str | None] = mapped_column(String(100), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
__table_args__ = (
Index("ix_audit_logs_action_resource", "action", "resource_type"),
Index("ix_audit_logs_actor_created", "actor_id", "created_at"),
)
class TechnicalUserModel(Base, BaseMixin):
__tablename__ = "technical_users"
username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
email: Mapped[str | None] = mapped_column(String(255))
full_name: Mapped[str | None] = mapped_column(String(255))
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(20), default="viewer", index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
scopes: Mapped[dict | None] = mapped_column(JSONB, default=list)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
reservations = relationship("ReservationModel", back_populates="user")
api_clients = relationship("APIClientModel", back_populates="owner")
class APIClientModel(Base, BaseMixin):
__tablename__ = "api_clients"
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("technical_users.id"), nullable=True, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
scopes: Mapped[dict | None] = mapped_column(JSONB, default=list)
rate_limit: Mapped[int | None] = mapped_column(Integer)
allowed_ips: Mapped[dict | None] = mapped_column(JSONB, default=list)
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
owner = relationship("TechnicalUserModel", back_populates="api_clients")
api_keys = relationship("APIKeyModel", back_populates="api_client", cascade="all, delete-orphan")
class APIKeyModel(Base):
__tablename__ = "api_keys"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
api_client_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("api_clients.id"), nullable=False, index=True)
key_prefix: Mapped[str] = mapped_column(String(10), nullable=False, index=True)
key_hash: Mapped[bytes] = mapped_column(LargeBinary(64), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
api_client = relationship("APIClientModel", back_populates="api_keys")
__table_args__ = (
Index("ix_api_keys_prefix_active", "key_prefix", "is_active"),
)
@@ -0,0 +1,32 @@
from .pharmacy_repository import PharmacyRepository
from .technical_user_repository import TechnicalUserRepository
from .audit_log_repository import AuditLogRepository
from .api_client_repository import APIClientRepository, APIKeyRepository
from .connector_repository import ConnectorRepository
from .erp_system_repository import ERPSystemRepository
from .connector_config_repository import ConnectorConfigRepository
from .connector_version_repository import ConnectorVersionRepository
from .medication_repository import MedicationRepository
from .medication_catalog_repository import MedicationCatalogRepository
from .inventory_repository import InventoryRepository
from .reservation_repository import ReservationRepository
from .sync_job_repository import SyncJobRepository
from .sync_log_repository import SyncLogRepository
__all__ = [
"APIClientRepository",
"APIKeyRepository",
"AuditLogRepository",
"ConnectorConfigRepository",
"ConnectorRepository",
"ConnectorVersionRepository",
"ERPSystemRepository",
"InventoryRepository",
"MedicationCatalogRepository",
"MedicationRepository",
"PharmacyRepository",
"ReservationRepository",
"SyncJobRepository",
"SyncLogRepository",
"TechnicalUserRepository",
]
@@ -0,0 +1,116 @@
import uuid
from collections.abc import Sequence
from typing import Any
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.entities import APIClientEntity, APIKeyEntity
from src.domain.interfaces import IAPIClientRepository, IAPIKeyRepository
from src.infrastructure.database.models import APIClientModel, APIKeyModel
class APIClientRepository(IAPIClientRepository):
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_id(self, client_id: uuid.UUID) -> APIClientEntity | None:
stmt = select(APIClientModel).where(APIClientModel.id == client_id)
result = await self._session.execute(stmt)
m = result.scalar_one_or_none()
if not m:
return None
return APIClientEntity(
id=m.id, name=m.name, description=m.description, owner_id=m.owner_id,
is_active=m.is_active, scopes=m.scopes or [], rate_limit=m.rate_limit,
allowed_ips=m.allowed_ips or [], metadata_=m.metadata_, created_at=m.created_at, updated_at=m.updated_at,
)
async def list_clients(self, *, is_active: bool | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[APIClientEntity], int]:
filters: list[Any] = []
if is_active is not None:
filters.append(APIClientModel.is_active == is_active)
count_stmt = select(func.count()).select_from(APIClientModel)
if filters:
count_stmt = count_stmt.where(*filters)
total = (await self._session.execute(count_stmt)).scalar() or 0
stmt = select(APIClientModel).order_by(APIClientModel.name).offset(offset).limit(limit)
if filters:
stmt = stmt.where(*filters)
result = await self._session.execute(stmt)
models = result.scalars().all()
entities = [
APIClientEntity(id=m.id, name=m.name, description=m.description, owner_id=m.owner_id,
is_active=m.is_active, scopes=m.scopes or [], rate_limit=m.rate_limit,
allowed_ips=m.allowed_ips or [], metadata_=m.metadata_, created_at=m.created_at, updated_at=m.updated_at)
for m in models
]
return entities, total
async def create(self, entity: APIClientEntity) -> APIClientEntity:
model = APIClientModel(
name=entity.name, description=entity.description, owner_id=entity.owner_id,
is_active=entity.is_active, scopes=entity.scopes, rate_limit=entity.rate_limit,
allowed_ips=entity.allowed_ips, metadata_=entity.metadata_,
)
self._session.add(model)
await self._session.flush()
return await self.get_by_id(model.id) # type: ignore[return-value]
async def update(self, entity: APIClientEntity) -> APIClientEntity:
stmt = update(APIClientModel).where(APIClientModel.id == entity.id).values(
name=entity.name, description=entity.description, is_active=entity.is_active,
scopes=entity.scopes, rate_limit=entity.rate_limit, allowed_ips=entity.allowed_ips,
metadata_=entity.metadata_,
)
await self._session.execute(stmt)
await self._session.flush()
return await self.get_by_id(entity.id) # type: ignore[return-value]
class APIKeyRepository(IAPIKeyRepository):
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_prefix(self, key_prefix: str) -> APIKeyEntity | None:
stmt = select(APIKeyModel).where(APIKeyModel.key_prefix == key_prefix, APIKeyModel.is_active == True) # noqa: E712
result = await self._session.execute(stmt)
m = result.scalar_one_or_none()
if not m:
return None
return APIKeyEntity(
id=m.id, api_client_id=m.api_client_id, key_prefix=m.key_prefix,
key_hash=m.key_hash, name=m.name, is_active=m.is_active,
expires_at=m.expires_at, last_used_at=m.last_used_at, created_at=m.created_at,
)
async def list_by_client(self, api_client_id: uuid.UUID) -> Sequence[APIKeyEntity]:
stmt = select(APIKeyModel).where(APIKeyModel.api_client_id == api_client_id).order_by(APIKeyModel.created_at.desc())
result = await self._session.execute(stmt)
models = result.scalars().all()
return [
APIKeyEntity(
id=m.id, api_client_id=m.api_client_id, key_prefix=m.key_prefix,
key_hash=m.key_hash, name=m.name, is_active=m.is_active,
expires_at=m.expires_at, last_used_at=m.last_used_at, created_at=m.created_at,
)
for m in models
]
async def create(self, entity: APIKeyEntity) -> APIKeyEntity:
model = APIKeyModel(
api_client_id=entity.api_client_id, key_prefix=entity.key_prefix,
key_hash=entity.key_hash, name=entity.name, is_active=entity.is_active,
expires_at=entity.expires_at,
)
self._session.add(model)
await self._session.flush()
return APIKeyEntity(
id=model.id, api_client_id=model.api_client_id, key_prefix=model.key_prefix,
key_hash=model.key_hash, name=model.name, is_active=model.is_active,
expires_at=model.expires_at, created_at=model.created_at,
)
async def update_last_used(self, key_id: uuid.UUID) -> None:
stmt = update(APIKeyModel).where(APIKeyModel.id == key_id).values(last_used_at=func.now())
await self._session.execute(stmt)
@@ -0,0 +1,78 @@
import uuid
from collections.abc import Sequence
from typing import Any
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.entities import AuditLogEntity
from src.domain.interfaces import IAuditLogRepository
from src.infrastructure.database.models import AuditLogModel
def _model_to_entity(m: AuditLogModel) -> AuditLogEntity:
return AuditLogEntity(
id=m.id,
actor_id=m.actor_id,
actor_type=m.actor_type,
action=m.action,
resource_type=m.resource_type,
resource_id=m.resource_id,
old_value=m.old_value,
new_value=m.new_value,
ip_address=m.ip_address,
user_agent=m.user_agent,
request_id=m.request_id,
created_at=m.created_at,
)
class AuditLogRepository(IAuditLogRepository):
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def add(self, entity: AuditLogEntity) -> AuditLogEntity:
model = AuditLogModel(
actor_id=entity.actor_id,
actor_type=entity.actor_type,
action=entity.action,
resource_type=entity.resource_type,
resource_id=entity.resource_id,
old_value=entity.old_value,
new_value=entity.new_value,
ip_address=entity.ip_address,
user_agent=entity.user_agent,
request_id=entity.request_id,
)
self._session.add(model)
await self._session.flush()
return _model_to_entity(model)
async def list_logs(
self,
*,
actor_id: uuid.UUID | None = None,
action: str | None = None,
resource_type: str | None = None,
offset: int = 0,
limit: int = 50,
) -> tuple[Sequence[AuditLogEntity], int]:
filters: list[Any] = []
if actor_id:
filters.append(AuditLogModel.actor_id == actor_id)
if action:
filters.append(AuditLogModel.action == action)
if resource_type:
filters.append(AuditLogModel.resource_type == resource_type)
count_stmt = select(func.count()).select_from(AuditLogModel)
if filters:
count_stmt = count_stmt.where(*filters)
total = (await self._session.execute(count_stmt)).scalar() or 0
stmt = select(AuditLogModel).order_by(AuditLogModel.created_at.desc()).offset(offset).limit(limit)
if filters:
stmt = stmt.where(*filters)
result = await self._session.execute(stmt)
models = result.scalars().all()
return [_model_to_entity(m) for m in models], total
@@ -0,0 +1,71 @@
import uuid
from collections.abc import Sequence
from typing import Any
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.entities import ConnectorConfigEntity
from src.domain.interfaces import IConnectorConfigRepository
from src.infrastructure.database.models import ConnectorConfigModel
def _model_to_entity(m: ConnectorConfigModel) -> ConnectorConfigEntity:
return ConnectorConfigEntity(
id=m.id,
connector_id=m.connector_id,
key=m.key,
value=m.value,
encrypted=m.encrypted,
description=m.description,
created_at=m.created_at,
updated_at=m.updated_at,
)
class ConnectorConfigRepository(IConnectorConfigRepository):
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def list_by_connector(self, connector_id: uuid.UUID) -> Sequence[ConnectorConfigEntity]:
stmt = select(ConnectorConfigModel).where(ConnectorConfigModel.connector_id == connector_id).order_by(ConnectorConfigModel.key)
result = await self._session.execute(stmt)
models = result.scalars().all()
return [_model_to_entity(m) for m in models]
async def get_by_key(self, connector_id: uuid.UUID, key: str) -> ConnectorConfigEntity | None:
stmt = select(ConnectorConfigModel).where(
ConnectorConfigModel.connector_id == connector_id,
ConnectorConfigModel.key == key,
)
result = await self._session.execute(stmt)
model = result.scalar_one_or_none()
return _model_to_entity(model) if model else None
async def upsert(self, entity: ConnectorConfigEntity) -> ConnectorConfigEntity:
existing = await self.get_by_key(entity.connector_id, entity.key)
if existing:
stmt = (
ConnectorConfigModel.__table__.update()
.where(ConnectorConfigModel.id == existing.id)
.values(value=entity.value, encrypted=entity.encrypted, description=entity.description)
)
await self._session.execute(stmt)
await self._session.flush()
return await self.get_by_key(entity.connector_id, entity.key) # type: ignore[return-value]
else:
model = ConnectorConfigModel(
connector_id=entity.connector_id,
key=entity.key,
value=entity.value,
encrypted=entity.encrypted,
description=entity.description,
)
self._session.add(model)
await self._session.flush()
return _model_to_entity(model)
async def delete(self, config_id: uuid.UUID) -> bool:
stmt = delete(ConnectorConfigModel).where(ConnectorConfigModel.id == config_id)
result = await self._session.execute(stmt)
return result.rowcount > 0
@@ -0,0 +1,101 @@
import uuid
from collections.abc import Sequence
from typing import Any
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.entities import ConnectorEntity, ConnectorStatus
from src.domain.interfaces import IConnectorRepository
from src.infrastructure.database.models import ConnectorModel
def _model_to_entity(m: ConnectorModel) -> ConnectorEntity:
return ConnectorEntity(
id=m.id,
erp_system_id=m.erp_system_id,
pharmacy_id=m.pharmacy_id,
name=m.name,
connector_type=m.connector_type,
version=m.version,
config=m.config,
status=m.status,
last_heartbeat_at=m.last_heartbeat_at,
last_error=m.last_error,
metadata_=m.metadata_,
created_at=m.created_at,
updated_at=m.updated_at,
)
def _entity_to_model_data(e: ConnectorEntity) -> dict[str, Any]:
return {
"erp_system_id": e.erp_system_id,
"pharmacy_id": e.pharmacy_id,
"name": e.name,
"connector_type": e.connector_type if isinstance(e.connector_type, str) else e.connector_type.value,
"version": e.version,
"config": e.config,
"status": e.status if isinstance(e.status, str) else e.status.value,
"last_heartbeat_at": e.last_heartbeat_at,
"last_error": e.last_error,
"metadata_": e.metadata_,
}
class ConnectorRepository(IConnectorRepository):
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_id(self, connector_id: uuid.UUID) -> ConnectorEntity | None:
stmt = select(ConnectorModel).where(ConnectorModel.id == connector_id)
result = await self._session.execute(stmt)
model = result.scalar_one_or_none()
return _model_to_entity(model) if model else None
async def list_connectors(
self,
*,
erp_system_id: uuid.UUID | None = None,
pharmacy_id: uuid.UUID | None = None,
status: str | None = None,
offset: int = 0,
limit: int = 50,
) -> tuple[Sequence[ConnectorEntity], int]:
filters: list[Any] = []
if erp_system_id:
filters.append(ConnectorModel.erp_system_id == erp_system_id)
if pharmacy_id:
filters.append(ConnectorModel.pharmacy_id == pharmacy_id)
if status:
filters.append(ConnectorModel.status == status)
count_stmt = select(func.count()).select_from(ConnectorModel)
if filters:
count_stmt = count_stmt.where(*filters)
total = (await self._session.execute(count_stmt)).scalar() or 0
stmt = select(ConnectorModel).order_by(ConnectorModel.name).offset(offset).limit(limit)
if filters:
stmt = stmt.where(*filters)
result = await self._session.execute(stmt)
models = result.scalars().all()
return [_model_to_entity(m) for m in models], total
async def create(self, entity: ConnectorEntity) -> ConnectorEntity:
model = ConnectorModel(**_entity_to_model_data(entity))
self._session.add(model)
await self._session.flush()
return _model_to_entity(model)
async def update(self, entity: ConnectorEntity) -> ConnectorEntity:
data = _entity_to_model_data(entity)
stmt = update(ConnectorModel).where(ConnectorModel.id == entity.id).values(**data)
await self._session.execute(stmt)
await self._session.flush()
return await self.get_by_id(entity.id) # type: ignore[return-value]
async def delete(self, connector_id: uuid.UUID) -> bool:
stmt = delete(ConnectorModel).where(ConnectorModel.id == connector_id)
result = await self._session.execute(stmt)
return result.rowcount > 0
@@ -0,0 +1,82 @@
from collections.abc import Sequence
from typing import Any
import uuid
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.entities import ConnectorVersionEntity
from src.domain.interfaces import IConnectorVersionRepository
from src.infrastructure.database.models import ConnectorVersionModel
def _model_to_entity(m: ConnectorVersionModel) -> ConnectorVersionEntity:
return ConnectorVersionEntity(
id=m.id,
connector_id=m.connector_id,
connector_type=m.connector_type,
version=m.version,
changelog=m.changelog,
download_url=m.download_url,
checksum=m.checksum,
is_latest=m.is_latest,
is_required=m.is_required,
released_at=m.released_at,
created_at=m.created_at,
)
class ConnectorVersionRepository(IConnectorVersionRepository):
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_latest(self, connector_type: str) -> ConnectorVersionEntity | None:
stmt = (
select(ConnectorVersionModel)
.where(ConnectorVersionModel.connector_type == connector_type, ConnectorVersionModel.is_latest == True) # noqa: E712
.order_by(ConnectorVersionModel.released_at.desc())
.limit(1)
)
result = await self._session.execute(stmt)
model = result.scalar_one_or_none()
return _model_to_entity(model) if model else None
async def list_versions(
self,
*,
connector_type: str | None = None,
offset: int = 0,
limit: int = 50,
) -> tuple[Sequence[ConnectorVersionEntity], int]:
filters: list[Any] = []
if connector_type:
filters.append(ConnectorVersionModel.connector_type == connector_type)
count_stmt = select(func.count()).select_from(ConnectorVersionModel)
if filters:
count_stmt = count_stmt.where(*filters)
total = (await self._session.execute(count_stmt)).scalar() or 0
stmt = select(ConnectorVersionModel).order_by(ConnectorVersionModel.released_at.desc()).offset(offset).limit(limit)
if filters:
stmt = stmt.where(*filters)
result = await self._session.execute(stmt)
models = result.scalars().all()
return [_model_to_entity(m) for m in models], total
async def create(self, entity: ConnectorVersionEntity) -> ConnectorVersionEntity:
model = ConnectorVersionModel(
connector_id=entity.connector_id or uuid.uuid4(),
connector_type=entity.connector_type if isinstance(entity.connector_type, str) else entity.connector_type.value,
version=entity.version,
changelog=entity.changelog,
download_url=entity.download_url,
checksum=entity.checksum,
is_latest=entity.is_latest,
is_required=entity.is_required,
released_at=entity.released_at,
)
self._session.add(model)
await self._session.flush()
return _model_to_entity(model)
@@ -0,0 +1,89 @@
import uuid
from collections.abc import Sequence
from typing import Any
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.entities import ERPSystemEntity
from src.domain.interfaces import IERPSystemRepository
from src.infrastructure.database.models import ERPSystemModel
def _model_to_entity(m: ERPSystemModel) -> ERPSystemEntity:
return ERPSystemEntity(
id=m.id,
name=m.name,
erp_type=m.erp_type,
version=m.version,
base_url=m.base_url,
auth_config=m.auth_config,
status=m.status,
metadata_=m.metadata_,
created_at=m.created_at,
updated_at=m.updated_at,
)
def _entity_to_model_data(e: ERPSystemEntity) -> dict[str, Any]:
return {
"name": e.name,
"erp_type": e.erp_type if isinstance(e.erp_type, str) else e.erp_type.value,
"version": e.version,
"base_url": e.base_url,
"auth_config": e.auth_config,
"status": e.status if isinstance(e.status, str) else e.status.value,
"metadata_": e.metadata_,
}
class ERPSystemRepository(IERPSystemRepository):
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_id(self, erp_id: uuid.UUID) -> ERPSystemEntity | None:
stmt = select(ERPSystemModel).where(ERPSystemModel.id == erp_id)
result = await self._session.execute(stmt)
model = result.scalar_one_or_none()
return _model_to_entity(model) if model else None
async def list_erp_systems(
self,
*,
erp_type: str | None = None,
offset: int = 0,
limit: int = 50,
) -> tuple[Sequence[ERPSystemEntity], int]:
filters: list[Any] = []
if erp_type:
filters.append(ERPSystemModel.erp_type == erp_type)
count_stmt = select(func.count()).select_from(ERPSystemModel)
if filters:
count_stmt = count_stmt.where(*filters)
total = (await self._session.execute(count_stmt)).scalar() or 0
stmt = select(ERPSystemModel).order_by(ERPSystemModel.name).offset(offset).limit(limit)
if filters:
stmt = stmt.where(*filters)
result = await self._session.execute(stmt)
models = result.scalars().all()
return [_model_to_entity(m) for m in models], total
async def create(self, entity: ERPSystemEntity) -> ERPSystemEntity:
model = ERPSystemModel(**_entity_to_model_data(entity))
self._session.add(model)
await self._session.flush()
return _model_to_entity(model)
async def update(self, entity: ERPSystemEntity) -> ERPSystemEntity:
data = _entity_to_model_data(entity)
stmt = update(ERPSystemModel).where(ERPSystemModel.id == entity.id).values(**data)
await self._session.execute(stmt)
await self._session.flush()
return await self.get_by_id(entity.id) # type: ignore[return-value]
async def delete(self, erp_id: uuid.UUID) -> bool:
stmt = delete(ERPSystemModel).where(ERPSystemModel.id == erp_id)
result = await self._session.execute(stmt)
return result.rowcount > 0
@@ -0,0 +1,142 @@
import uuid
from collections.abc import Sequence
from typing import Any
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.entities import InventoryHistoryEntity, InventoryRecordEntity
from src.domain.interfaces import IInventoryRepository
from src.infrastructure.database.models import InventoryHistoryModel, InventoryRecordModel
def _record_model_to_entity(m: InventoryRecordModel) -> InventoryRecordEntity:
return InventoryRecordEntity(
id=m.id,
pharmacy_id=m.pharmacy_id,
medication_id=m.medication_id,
stock=m.stock,
reserved_stock=m.reserved_stock,
available_stock=m.available_stock,
min_stock=m.min_stock,
max_stock=m.max_stock,
last_updated_at=m.last_updated_at,
source=m.source,
batch_id=m.batch_id,
metadata_=m.metadata_,
created_at=m.created_at,
updated_at=m.updated_at,
)
def _record_entity_to_model_data(e: InventoryRecordEntity) -> dict[str, Any]:
return {
"pharmacy_id": e.pharmacy_id,
"medication_id": e.medication_id,
"stock": e.stock,
"reserved_stock": e.reserved_stock,
"available_stock": e.available_stock,
"min_stock": e.min_stock,
"max_stock": e.max_stock,
"last_updated_at": e.last_updated_at,
"source": e.source,
"batch_id": e.batch_id,
"metadata_": e.metadata_,
}
def _history_model_to_entity(m: InventoryHistoryModel) -> InventoryHistoryEntity:
return InventoryHistoryEntity(
id=m.id,
pharmacy_id=m.pharmacy_id,
medication_id=m.medication_id,
previous_stock=m.previous_stock,
new_stock=m.new_stock,
delta=m.delta,
reason=m.reason,
source=m.source,
created_at=m.created_at,
)
class InventoryRepository(IInventoryRepository):
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_record(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID) -> InventoryRecordEntity | None:
stmt = select(InventoryRecordModel).where(
InventoryRecordModel.pharmacy_id == pharmacy_id,
InventoryRecordModel.medication_id == medication_id,
)
result = await self._session.execute(stmt)
model = result.scalar_one_or_none()
return _record_model_to_entity(model) if model else None
async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[InventoryRecordEntity], int]:
count_stmt = select(func.count()).select_from(InventoryRecordModel).where(
InventoryRecordModel.pharmacy_id == pharmacy_id
)
total = (await self._session.execute(count_stmt)).scalar() or 0
stmt = (
select(InventoryRecordModel)
.where(InventoryRecordModel.pharmacy_id == pharmacy_id)
.order_by(InventoryRecordModel.last_updated_at.desc().nullslast())
.offset(offset)
.limit(limit)
)
result = await self._session.execute(stmt)
models = result.scalars().all()
return [_record_model_to_entity(m) for m in models], total
async def upsert(self, entity: InventoryRecordEntity) -> InventoryRecordEntity:
existing = await self.get_record(entity.pharmacy_id, entity.medication_id)
if existing:
data = _record_entity_to_model_data(entity)
stmt = (
update(InventoryRecordModel)
.where(
InventoryRecordModel.pharmacy_id == entity.pharmacy_id,
InventoryRecordModel.medication_id == entity.medication_id,
)
.values(**data)
)
await self._session.execute(stmt)
await self._session.flush()
return await self.get_record(entity.pharmacy_id, entity.medication_id) # type: ignore[return-value]
else:
model = InventoryRecordModel(**_record_entity_to_model_data(entity))
self._session.add(model)
await self._session.flush()
return _record_model_to_entity(model)
async def add_history(self, entity: InventoryHistoryEntity) -> InventoryHistoryEntity:
model = InventoryHistoryModel(
inventory_record_id=entity.inventory_record_id,
pharmacy_id=entity.pharmacy_id,
medication_id=entity.medication_id,
previous_stock=entity.previous_stock,
new_stock=entity.new_stock,
delta=entity.delta,
reason=entity.reason,
source=entity.source,
)
self._session.add(model)
await self._session.flush()
return _history_model_to_entity(model)
async def get_history(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> Sequence[InventoryHistoryEntity]:
record = await self.get_record(pharmacy_id, medication_id)
if not record:
return []
stmt = (
select(InventoryHistoryModel)
.where(InventoryHistoryModel.inventory_record_id == record.id)
.order_by(InventoryHistoryModel.created_at.desc())
.offset(offset)
.limit(limit)
)
result = await self._session.execute(stmt)
models = result.scalars().all()
return [_history_model_to_entity(m) for m in models]
@@ -0,0 +1,90 @@
import uuid
from collections.abc import Sequence
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.entities import MedicationCatalogEntryEntity
from src.domain.interfaces import IMedicationCatalogRepository
from src.infrastructure.database.models import MedicationCatalogEntryModel
def _model_to_entity(m: MedicationCatalogEntryModel) -> MedicationCatalogEntryEntity:
return MedicationCatalogEntryEntity(
id=m.id,
pharmacy_id=m.pharmacy_id,
medication_id=m.medication_id,
price=m.price,
stock=m.stock,
is_available=m.is_available,
last_verified_at=m.last_verified_at,
metadata_=m.metadata_,
created_at=m.created_at,
updated_at=m.updated_at,
)
def _entity_to_model_data(e: MedicationCatalogEntryEntity) -> dict[str, Any]:
return {
"pharmacy_id": e.pharmacy_id,
"medication_id": e.medication_id,
"price": e.price,
"stock": e.stock,
"is_available": e.is_available,
"last_verified_at": e.last_verified_at,
"metadata_": e.metadata_,
}
class MedicationCatalogRepository(IMedicationCatalogRepository):
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_entry(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID) -> MedicationCatalogEntryEntity | None:
stmt = select(MedicationCatalogEntryModel).where(
MedicationCatalogEntryModel.pharmacy_id == pharmacy_id,
MedicationCatalogEntryModel.medication_id == medication_id,
)
result = await self._session.execute(stmt)
model = result.scalar_one_or_none()
return _model_to_entity(model) if model else None
async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[MedicationCatalogEntryEntity], int]:
count_stmt = select(func.count()).select_from(MedicationCatalogEntryModel).where(
MedicationCatalogEntryModel.pharmacy_id == pharmacy_id
)
total = (await self._session.execute(count_stmt)).scalar() or 0
stmt = (
select(MedicationCatalogEntryModel)
.where(MedicationCatalogEntryModel.pharmacy_id == pharmacy_id)
.order_by(MedicationCatalogEntryModel.is_available.desc(), MedicationCatalogEntryModel.price.asc())
.offset(offset)
.limit(limit)
)
result = await self._session.execute(stmt)
models = result.scalars().all()
return [_model_to_entity(m) for m in models], total
async def upsert(self, entity: MedicationCatalogEntryEntity) -> MedicationCatalogEntryEntity:
existing = await self.get_entry(entity.pharmacy_id, entity.medication_id)
if existing:
from sqlalchemy import update
data = _entity_to_model_data(entity)
stmt = (
update(MedicationCatalogEntryModel)
.where(
MedicationCatalogEntryModel.pharmacy_id == entity.pharmacy_id,
MedicationCatalogEntryModel.medication_id == entity.medication_id,
)
.values(**data)
)
await self._session.execute(stmt)
await self._session.flush()
return await self.get_entry(entity.pharmacy_id, entity.medication_id) # type: ignore[return-value]
else:
model = MedicationCatalogEntryModel(**_entity_to_model_data(entity))
self._session.add(model)
await self._session.flush()
return _model_to_entity(model)
@@ -0,0 +1,101 @@
import uuid
from collections.abc import Sequence
from typing import Any
from sqlalchemy import delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.entities import MedicationEntity
from src.domain.interfaces import IMedicationRepository
from src.infrastructure.database.models import MedicationModel
def _model_to_entity(m: MedicationModel) -> MedicationEntity:
return MedicationEntity(
id=m.id,
nregistro=m.nregistro,
name=m.name,
active_ingredient=m.active_ingredient,
dosage=m.dosage,
form=m.form,
atc_code=m.atc_code,
prescription_required=m.prescription_required,
manufacturer=m.manufacturer,
metadata_=m.metadata_,
created_at=m.created_at,
updated_at=m.updated_at,
)
def _entity_to_model_data(e: MedicationEntity) -> dict[str, Any]:
return {
"nregistro": e.nregistro,
"name": e.name,
"active_ingredient": e.active_ingredient,
"dosage": e.dosage,
"form": e.form,
"atc_code": e.atc_code,
"prescription_required": e.prescription_required,
"manufacturer": e.manufacturer,
"metadata_": e.metadata_,
}
class MedicationRepository(IMedicationRepository):
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_id(self, medication_id: uuid.UUID) -> MedicationEntity | None:
stmt = select(MedicationModel).where(MedicationModel.id == medication_id)
result = await self._session.execute(stmt)
model = result.scalar_one_or_none()
return _model_to_entity(model) if model else None
async def get_by_nregistro(self, nregistro: str) -> MedicationEntity | None:
stmt = select(MedicationModel).where(MedicationModel.nregistro == nregistro)
result = await self._session.execute(stmt)
model = result.scalar_one_or_none()
return _model_to_entity(model) if model else None
async def search(self, query: str, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[MedicationEntity], int]:
pattern = f"%{query}%"
filters = [
or_(
MedicationModel.name.ilike(pattern),
MedicationModel.active_ingredient.ilike(pattern),
MedicationModel.nregistro.ilike(pattern),
MedicationModel.atc_code.ilike(pattern),
)
]
count_stmt = select(func.count()).select_from(MedicationModel).where(*filters)
total = (await self._session.execute(count_stmt)).scalar() or 0
stmt = (
select(MedicationModel)
.where(*filters)
.order_by(MedicationModel.name)
.offset(offset)
.limit(limit)
)
result = await self._session.execute(stmt)
models = result.scalars().all()
return [_model_to_entity(m) for m in models], total
async def create(self, entity: MedicationEntity) -> MedicationEntity:
model = MedicationModel(**_entity_to_model_data(entity))
self._session.add(model)
await self._session.flush()
return _model_to_entity(model)
async def update(self, entity: MedicationEntity) -> MedicationEntity:
data = _entity_to_model_data(entity)
stmt = update(MedicationModel).where(MedicationModel.id == entity.id).values(**data)
await self._session.execute(stmt)
await self._session.flush()
return await self.get_by_id(entity.id) # type: ignore[return-value]
async def delete(self, medication_id: uuid.UUID) -> bool:
stmt = delete(MedicationModel).where(MedicationModel.id == medication_id)
result = await self._session.execute(stmt)
return result.rowcount > 0
@@ -0,0 +1,126 @@
import uuid
from collections.abc import Sequence
from typing import Any
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.entities import PharmacyEntity
from src.domain.interfaces import IPharmacyRepository
from src.infrastructure.database.models import PharmacyModel
def _model_to_entity(m: PharmacyModel) -> PharmacyEntity:
return PharmacyEntity(
id=m.id,
code=m.code,
name=m.name,
address=m.address,
city=m.city,
province=m.province,
postal_code=m.postal_code,
country=m.country,
latitude=m.latitude,
longitude=m.longitude,
phone=m.phone,
email=m.email,
opening_hours=m.opening_hours,
status=m.status,
erp_type=m.erp_type,
erp_version=m.erp_version,
connector_id=m.connector_id,
sync_strategy=m.sync_strategy,
sync_interval_minutes=m.sync_interval_minutes,
last_sync_at=m.last_sync_at,
last_sync_status=m.last_sync_status,
metadata_=m.metadata_,
created_at=m.created_at,
updated_at=m.updated_at,
)
def _entity_to_model_data(e: PharmacyEntity) -> dict[str, Any]:
return {
"code": e.code,
"name": e.name,
"address": e.address,
"city": e.city,
"province": e.province,
"postal_code": e.postal_code,
"country": e.country,
"latitude": e.latitude,
"longitude": e.longitude,
"phone": e.phone,
"email": e.email,
"opening_hours": e.opening_hours,
"status": e.status,
"erp_type": e.erp_type,
"erp_version": e.erp_version,
"connector_id": e.connector_id,
"sync_strategy": e.sync_strategy,
"sync_interval_minutes": e.sync_interval_minutes,
"last_sync_at": e.last_sync_at,
"last_sync_status": e.last_sync_status,
"metadata_": e.metadata_,
}
class PharmacyRepository(IPharmacyRepository):
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_id(self, pharmacy_id: uuid.UUID) -> PharmacyEntity | None:
stmt = select(PharmacyModel).where(PharmacyModel.id == pharmacy_id)
result = await self._session.execute(stmt)
model = result.scalar_one_or_none()
return _model_to_entity(model) if model else None
async def get_by_code(self, code: str) -> PharmacyEntity | None:
stmt = select(PharmacyModel).where(PharmacyModel.code == code)
result = await self._session.execute(stmt)
model = result.scalar_one_or_none()
return _model_to_entity(model) if model else None
async def list_pharmacies(
self,
*,
status: str | None = None,
erp_type: str | None = None,
offset: int = 0,
limit: int = 50,
) -> tuple[Sequence[PharmacyEntity], int]:
filters: list[Any] = []
if status:
filters.append(PharmacyModel.status == status)
if erp_type:
filters.append(PharmacyModel.erp_type == erp_type)
count_stmt = select(func.count()).select_from(PharmacyModel)
if filters:
count_stmt = count_stmt.where(*filters)
total = (await self._session.execute(count_stmt)).scalar() or 0
stmt = select(PharmacyModel).order_by(PharmacyModel.name).offset(offset).limit(limit)
if filters:
stmt = stmt.where(*filters)
result = await self._session.execute(stmt)
models = result.scalars().all()
return [_model_to_entity(m) for m in models], total
async def create(self, entity: PharmacyEntity) -> PharmacyEntity:
model = PharmacyModel(**_entity_to_model_data(entity))
self._session.add(model)
await self._session.flush()
return _model_to_entity(model)
async def update(self, entity: PharmacyEntity) -> PharmacyEntity:
data = _entity_to_model_data(entity)
stmt = update(PharmacyModel).where(PharmacyModel.id == entity.id).values(**data)
await self._session.execute(stmt)
await self._session.flush()
return await self.get_by_id(entity.id) # type: ignore[return-value]
async def delete(self, pharmacy_id: uuid.UUID) -> bool:
stmt = delete(PharmacyModel).where(PharmacyModel.id == pharmacy_id)
result = await self._session.execute(stmt)
return result.rowcount > 0
@@ -0,0 +1,94 @@
import uuid
from collections.abc import Sequence
from typing import Any
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.entities import ReservationEntity
from src.domain.interfaces import IReservationRepository
from src.infrastructure.database.models import ReservationModel
def _model_to_entity(m: ReservationModel) -> ReservationEntity:
return ReservationEntity(
id=m.id,
pharmacy_id=m.pharmacy_id,
medication_id=m.medication_id,
user_id=m.user_id,
external_reference=m.external_reference,
quantity=m.quantity,
status=m.status,
expires_at=m.expires_at,
confirmed_at=m.confirmed_at,
cancelled_at=m.cancelled_at,
idempotency_key=m.idempotency_key,
metadata_=m.metadata_,
created_at=m.created_at,
updated_at=m.updated_at,
)
def _entity_to_model_data(e: ReservationEntity) -> dict[str, Any]:
return {
"pharmacy_id": e.pharmacy_id,
"medication_id": e.medication_id,
"user_id": e.user_id,
"external_reference": e.external_reference,
"quantity": e.quantity,
"status": e.status,
"expires_at": e.expires_at,
"confirmed_at": e.confirmed_at,
"cancelled_at": e.cancelled_at,
"idempotency_key": e.idempotency_key,
"metadata_": e.metadata_,
}
class ReservationRepository(IReservationRepository):
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_id(self, reservation_id: uuid.UUID) -> ReservationEntity | None:
stmt = select(ReservationModel).where(ReservationModel.id == reservation_id)
result = await self._session.execute(stmt)
model = result.scalar_one_or_none()
return _model_to_entity(model) if model else None
async def get_by_idempotency_key(self, key: str) -> ReservationEntity | None:
stmt = select(ReservationModel).where(ReservationModel.idempotency_key == key)
result = await self._session.execute(stmt)
model = result.scalar_one_or_none()
return _model_to_entity(model) if model else None
async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[ReservationEntity], int]:
filters: list[Any] = [ReservationModel.pharmacy_id == pharmacy_id]
if status:
filters.append(ReservationModel.status == status)
count_stmt = select(func.count()).select_from(ReservationModel).where(*filters)
total = (await self._session.execute(count_stmt)).scalar() or 0
stmt = (
select(ReservationModel)
.where(*filters)
.order_by(ReservationModel.created_at.desc())
.offset(offset)
.limit(limit)
)
result = await self._session.execute(stmt)
models = result.scalars().all()
return [_model_to_entity(m) for m in models], total
async def create(self, entity: ReservationEntity) -> ReservationEntity:
model = ReservationModel(**_entity_to_model_data(entity))
self._session.add(model)
await self._session.flush()
return _model_to_entity(model)
async def update(self, entity: ReservationEntity) -> ReservationEntity:
data = _entity_to_model_data(entity)
stmt = update(ReservationModel).where(ReservationModel.id == entity.id).values(**data)
await self._session.execute(stmt)
await self._session.flush()
return await self.get_by_id(entity.id) # type: ignore[return-value]
@@ -0,0 +1,95 @@
import uuid
from collections.abc import Sequence
from typing import Any
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.entities import SyncJobEntity, SyncJobStatus
from src.domain.interfaces import ISyncJobRepository
from src.infrastructure.database.models import SyncJobModel
def _model_to_entity(m: SyncJobModel) -> SyncJobEntity:
return SyncJobEntity(
id=m.id,
pharmacy_id=m.pharmacy_id,
connector_id=m.connector_id,
strategy=m.strategy,
status=m.status,
started_at=m.started_at,
completed_at=m.completed_at,
items_total=m.items_total,
items_processed=m.items_processed,
items_failed=m.items_failed,
error_message=m.error_message,
metadata_=m.metadata_,
created_at=m.created_at,
updated_at=m.updated_at,
)
def _entity_to_model_data(e: SyncJobEntity) -> dict[str, Any]:
return {
"pharmacy_id": e.pharmacy_id,
"connector_id": e.connector_id,
"strategy": e.strategy,
"status": e.status,
"started_at": e.started_at,
"completed_at": e.completed_at,
"items_total": e.items_total,
"items_processed": e.items_processed,
"items_failed": e.items_failed,
"error_message": e.error_message,
"metadata_": e.metadata_,
}
class SyncJobRepository(ISyncJobRepository):
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_id(self, job_id: uuid.UUID) -> SyncJobEntity | None:
stmt = select(SyncJobModel).where(SyncJobModel.id == job_id)
result = await self._session.execute(stmt)
model = result.scalar_one_or_none()
return _model_to_entity(model) if model else None
async def list_by_pharmacy(
self,
pharmacy_id: uuid.UUID,
*,
status: str | None = None,
offset: int = 0,
limit: int = 50,
) -> tuple[Sequence[SyncJobEntity], int]:
filters: list[Any] = [SyncJobModel.pharmacy_id == pharmacy_id]
if status:
filters.append(SyncJobModel.status == status)
count_stmt = select(func.count()).select_from(SyncJobModel).where(*filters)
total = (await self._session.execute(count_stmt)).scalar() or 0
stmt = (
select(SyncJobModel)
.where(*filters)
.order_by(SyncJobModel.created_at.desc())
.offset(offset)
.limit(limit)
)
result = await self._session.execute(stmt)
models = result.scalars().all()
return [_model_to_entity(m) for m in models], total
async def create(self, entity: SyncJobEntity) -> SyncJobEntity:
model = SyncJobModel(**_entity_to_model_data(entity))
self._session.add(model)
await self._session.flush()
return _model_to_entity(model)
async def update(self, entity: SyncJobEntity) -> SyncJobEntity:
data = _entity_to_model_data(entity)
stmt = update(SyncJobModel).where(SyncJobModel.id == entity.id).values(**data)
await self._session.execute(stmt)
await self._session.flush()
return await self.get_by_id(entity.id) # type: ignore[return-value]
@@ -0,0 +1,53 @@
import uuid
from collections.abc import Sequence
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.entities import SyncLogEntity
from src.domain.interfaces import ISyncLogRepository
from src.infrastructure.database.models import SyncLogModel
def _model_to_entity(m: SyncLogModel) -> SyncLogEntity:
return SyncLogEntity(
id=m.id,
sync_job_id=m.sync_job_id,
level=m.level,
message=m.message,
details=m.details,
created_at=m.created_at,
)
class SyncLogRepository(ISyncLogRepository):
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def add(self, entity: SyncLogEntity) -> SyncLogEntity:
model = SyncLogModel(
sync_job_id=entity.sync_job_id,
level=entity.level,
message=entity.message,
details=entity.details,
)
self._session.add(model)
await self._session.flush()
return _model_to_entity(model)
async def list_by_job(
self,
job_id: uuid.UUID,
*,
level: str | None = None,
offset: int = 0,
limit: int = 100,
) -> Sequence[SyncLogEntity]:
stmt = select(SyncLogModel).where(SyncLogModel.sync_job_id == job_id)
if level:
stmt = stmt.where(SyncLogModel.level == level)
stmt = stmt.order_by(SyncLogModel.created_at.desc()).offset(offset).limit(limit)
result = await self._session.execute(stmt)
models = result.scalars().all()
return [_model_to_entity(m) for m in models]
@@ -0,0 +1,88 @@
import uuid
from collections.abc import Sequence
from typing import Any
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.entities import TechnicalUserEntity
from src.domain.interfaces import ITechnicalUserRepository
from src.infrastructure.database.models import TechnicalUserModel
def _model_to_entity(m: TechnicalUserModel) -> TechnicalUserEntity:
return TechnicalUserEntity(
id=m.id,
username=m.username,
email=m.email,
full_name=m.full_name,
role=m.role,
is_active=m.is_active,
scopes=m.scopes or [],
last_login_at=m.last_login_at,
created_at=m.created_at,
updated_at=m.updated_at,
)
class TechnicalUserRepository(ITechnicalUserRepository):
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_id(self, user_id: uuid.UUID) -> TechnicalUserEntity | None:
stmt = select(TechnicalUserModel).where(TechnicalUserModel.id == user_id)
result = await self._session.execute(stmt)
model = result.scalar_one_or_none()
return _model_to_entity(model) if model else None
async def get_by_username(self, username: str) -> TechnicalUserEntity | None:
stmt = select(TechnicalUserModel).where(TechnicalUserModel.username == username)
result = await self._session.execute(stmt)
model = result.scalar_one_or_none()
return _model_to_entity(model) if model else None
async def list_users(self, *, role: str | None = None, is_active: bool | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[TechnicalUserEntity], int]:
filters: list[Any] = []
if role:
filters.append(TechnicalUserModel.role == role)
if is_active is not None:
filters.append(TechnicalUserModel.is_active == is_active)
count_stmt = select(func.count()).select_from(TechnicalUserModel)
if filters:
count_stmt = count_stmt.where(*filters)
total = (await self._session.execute(count_stmt)).scalar() or 0
stmt = select(TechnicalUserModel).order_by(TechnicalUserModel.username).offset(offset).limit(limit)
if filters:
stmt = stmt.where(*filters)
result = await self._session.execute(stmt)
models = result.scalars().all()
return [_model_to_entity(m) for m in models], total
async def create(self, entity: TechnicalUserEntity, password_hash: str) -> TechnicalUserEntity:
model = TechnicalUserModel(
username=entity.username,
email=entity.email,
full_name=entity.full_name,
password_hash=password_hash,
role=entity.role,
is_active=entity.is_active,
scopes=entity.scopes,
)
self._session.add(model)
await self._session.flush()
return _model_to_entity(model)
async def update(self, entity: TechnicalUserEntity) -> TechnicalUserEntity:
stmt = update(TechnicalUserModel).where(TechnicalUserModel.id == entity.id).values(
email=entity.email,
full_name=entity.full_name,
role=entity.role,
is_active=entity.is_active,
scopes=entity.scopes,
last_login_at=entity.last_login_at,
)
await self._session.execute(stmt)
await self._session.flush()
return await self.get_by_id(entity.id) # type: ignore[return-value]
@@ -0,0 +1,40 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from src.infrastructure.config.settings import settings
engine = create_async_engine(
settings.DATABASE_URL,
pool_size=settings.DATABASE_POOL_SIZE,
max_overflow=settings.DATABASE_MAX_OVERFLOW,
pool_recycle=settings.DATABASE_POOL_RECYCLE,
echo=settings.DEBUG,
pool_pre_ping=True,
)
async_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def get_db_session_no_commit() -> AsyncGenerator[AsyncSession, None]:
async with async_session_factory() as session:
try:
yield session
finally:
await session.close()
@@ -0,0 +1,3 @@
from .rabbitmq import rabbitmq_client
__all__ = ["rabbitmq_client"]
@@ -0,0 +1,131 @@
import json
import logging
from typing import Any
import aio_pika
from aio_pika import ExchangeType, Message, RobustConnection
from src.infrastructure.config.settings import settings
logger = logging.getLogger(__name__)
class RabbitMQClient:
PIP_EXCHANGE = "pip.events"
PIP_DLX = "pip.dlx"
ROUTING_KEYS = {
"inventory_updated": "inventory.updated",
"reservation_created": "reservation.created",
"reservation_confirmed": "reservation.confirmed",
"reservation_cancelled": "reservation.cancelled",
"reservation_expired": "reservation.expired",
"sync_started": "sync.started",
"sync_completed": "sync.completed",
"sync_failed": "sync.failed",
"sync_cancelled": "sync.cancelled",
"sync_created": "sync.created",
"connector_heartbeat": "connector.heartbeat",
"connector_error": "connector.error",
"connector_created": "connector.created",
"connector_updated": "connector.updated",
"connector_deleted": "connector.deleted",
"connector_deployed": "connector.deployed",
"connector_deactivated": "connector.deactivated",
"pharmacy_updated": "pharmacy.updated",
"medication_created": "medication.created",
"medication_updated": "medication.updated",
"medication_deleted": "medication.deleted",
"catalog_upserted": "catalog.upserted",
}
def __init__(self) -> None:
self._connection: RobustConnection | None = None
self._channel: aio_pika.RobustChannel | None = None
self._exchange: aio_pika.RobustExchange | None = None
self._dlx: aio_pika.RobustExchange | None = None
async def connect(self) -> None:
self._connection = await aio_pika.connect_robust(settings.RABBITMQ_URL)
self._channel = await self._connection.channel()
await self._channel.set_qos(prefetch_count=100)
self._exchange = await self._channel.declare_exchange(
self.PIP_EXCHANGE,
ExchangeType.TOPIC,
durable=True,
)
self._dlx = await self._channel.declare_exchange(
self.PIP_DLX,
ExchangeType.TOPIC,
durable=True,
)
logger.info("rabbitmq_connected", extra={"url": settings.RABBITMQ_URL})
async def disconnect(self) -> None:
if self._connection:
await self._connection.close()
async def publish(self, routing_key: str, payload: dict[str, Any], headers: dict[str, str] | None = None) -> None:
if not self._exchange:
raise RuntimeError("RabbitMQ not connected. Call connect() first.")
message = Message(
body=json.dumps(payload, default=str).encode(),
content_type="application/json",
delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
headers=headers or {},
)
await self._exchange.publish(message, routing_key=routing_key)
logger.debug(
"message_published",
extra={"routing_key": routing_key},
)
async def consume(
self,
queue_name: str,
routing_keys: list[str],
callback: Any,
dlq: bool = True,
) -> aio_pika.RobustQueue:
if not self._channel or not self._exchange:
raise RuntimeError("RabbitMQ not connected. Call connect() first.")
queue_arguments = {
"x-dead-letter-exchange": self.PIP_DLX,
"x-dead-letter-routing-key": queue_name,
}
queue = await self._channel.declare_queue(
queue_name,
durable=True,
arguments=queue_arguments,
)
for routing_key in routing_keys:
await queue.bind(self._exchange, routing_key=routing_key)
if dlq and self._dlx:
dlq_name = f"{queue_name}.dlq"
dlq = await self._channel.declare_queue(dlq_name, durable=True)
await dlq.bind(self._dlx, routing_key=queue_name)
await queue.consume(callback)
return queue
async def health_check(self) -> bool:
try:
if not self._connection or self._connection.is_closed:
return False
return True
except Exception:
return False
rabbitmq_client = RabbitMQClient()