First implementation API for PIP (Proyecto: Pharmacy Integration Platform)
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from .audit_service import AuditService
|
||||
|
||||
__all__ = ["AuditService"]
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from src.domain.entities import AuditLogEntity
|
||||
from src.domain.interfaces import IAuditLogRepository
|
||||
from src.core.logging import get_logger
|
||||
|
||||
logger = get_logger("audit_service")
|
||||
|
||||
|
||||
class AuditService:
|
||||
def __init__(self, audit_repo: IAuditLogRepository) -> None:
|
||||
self._audit_repo = audit_repo
|
||||
|
||||
async def log_action(
|
||||
self,
|
||||
action: str,
|
||||
resource_type: str,
|
||||
*,
|
||||
actor_id: UUID | None = None,
|
||||
actor_type: str | None = None,
|
||||
resource_id: str | None = None,
|
||||
old_value: dict | None = None,
|
||||
new_value: dict | None = None,
|
||||
ip_address: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> AuditLogEntity:
|
||||
entity = AuditLogEntity(
|
||||
actor_id=actor_id,
|
||||
actor_type=actor_type,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id,
|
||||
)
|
||||
created = await self._audit_repo.add(entity)
|
||||
return created
|
||||
|
||||
async def list_logs(
|
||||
self,
|
||||
*,
|
||||
actor_id: UUID | None = None,
|
||||
action: str | None = None,
|
||||
resource_type: str | None = None,
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
) -> tuple[list[AuditLogEntity], int]:
|
||||
return await self._audit_repo.list_logs(
|
||||
actor_id=actor_id,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
from .security import (
|
||||
RBACGuard,
|
||||
TokenData,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
generate_api_key,
|
||||
hash_api_key,
|
||||
hash_password,
|
||||
validate_token_type,
|
||||
verify_api_key,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"RBACGuard",
|
||||
"TokenData",
|
||||
"create_access_token",
|
||||
"create_refresh_token",
|
||||
"decode_token",
|
||||
"generate_api_key",
|
||||
"hash_api_key",
|
||||
"hash_password",
|
||||
"validate_token_type",
|
||||
"verify_api_key",
|
||||
"verify_password",
|
||||
]
|
||||
@@ -0,0 +1,102 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.core.exceptions import ConflictException, UnauthorizedException, ValidationException
|
||||
from src.core.logging import get_logger
|
||||
from src.domain.entities import TechnicalUserEntity, UserRole
|
||||
from src.infrastructure.database.repositories import TechnicalUserRepository
|
||||
from src.services.auth.security import (
|
||||
RBACGuard,
|
||||
TokenData,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
hash_password,
|
||||
validate_token_type,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
logger = get_logger("auth_service")
|
||||
|
||||
|
||||
class AuthService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._user_repo = TechnicalUserRepository(session)
|
||||
self._session = session
|
||||
|
||||
async def register(self, username: str, password: str, email: str | None = None, full_name: str | None = None, role: UserRole = UserRole.VIEWER) -> TechnicalUserEntity:
|
||||
if not username or len(username) < 3:
|
||||
raise ValidationException("Username must be at least 3 characters")
|
||||
if not password or len(password) < 8:
|
||||
raise ValidationException("Password must be at least 8 characters")
|
||||
|
||||
existing = await self._user_repo.get_by_username(username)
|
||||
if existing:
|
||||
raise ConflictException(f"Username '{username}' already exists")
|
||||
|
||||
pw_hash = hash_password(password)
|
||||
entity = TechnicalUserEntity(
|
||||
username=username,
|
||||
email=email,
|
||||
full_name=full_name,
|
||||
role=role,
|
||||
is_active=True,
|
||||
scopes=self._default_scopes_for_role(role),
|
||||
)
|
||||
user = await self._user_repo.create(entity, pw_hash)
|
||||
logger.info("user_registered", extra={"username": username, "role": role.value})
|
||||
return user
|
||||
|
||||
async def authenticate(self, username: str, password: str) -> tuple[TechnicalUserEntity, str, str]:
|
||||
user = await self._user_repo.get_by_username(username)
|
||||
if not user:
|
||||
raise UnauthorizedException("Invalid credentials")
|
||||
if not user.is_active:
|
||||
raise UnauthorizedException("Account is disabled")
|
||||
|
||||
from src.infrastructure.database.models import TechnicalUserModel
|
||||
from sqlalchemy import select
|
||||
stmt = select(TechnicalUserModel.password_hash).where(TechnicalUserModel.id == user.id)
|
||||
result = await self._session.execute(stmt)
|
||||
pw_hash = result.scalar_one_or_none()
|
||||
if not pw_hash or not verify_password(password, pw_hash):
|
||||
raise UnauthorizedException("Invalid credentials")
|
||||
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
await self._user_repo.update(user)
|
||||
|
||||
access_token = create_access_token(subject=user.id, role=user.role, scopes=user.scopes)
|
||||
refresh_token = create_refresh_token(subject=user.id)
|
||||
|
||||
logger.info("user_authenticated", extra={"username": username})
|
||||
return user, access_token, refresh_token
|
||||
|
||||
async def refresh_access_token(self, refresh_token: str) -> tuple[str, str]:
|
||||
payload = decode_token(refresh_token)
|
||||
validate_token_type(payload, expected_type="refresh")
|
||||
|
||||
user_id = uuid.UUID(payload["sub"])
|
||||
user = await self._user_repo.get_by_id(user_id)
|
||||
if not user or not user.is_active:
|
||||
raise UnauthorizedException("User not found or inactive")
|
||||
|
||||
new_access = create_access_token(subject=user.id, role=user.role, scopes=user.scopes)
|
||||
new_refresh = create_refresh_token(subject=user.id)
|
||||
return new_access, new_refresh
|
||||
|
||||
async def validate_access_token(self, token: str) -> TokenData:
|
||||
payload = decode_token(token)
|
||||
validate_token_type(payload, expected_type="access")
|
||||
return TokenData(payload)
|
||||
|
||||
def _default_scopes_for_role(self, role: UserRole) -> list[str]:
|
||||
scopes_map: dict[UserRole, list[str]] = {
|
||||
UserRole.SUPERADMIN: ["*"],
|
||||
UserRole.ADMIN: ["pharmacies:read", "pharmacies:write", "medications:read", "medications:write", "inventory:read", "inventory:write", "connectors:read", "connectors:write", "sync:read", "sync:write", "reservations:read", "reservations:write", "audit:read", "users:read", "users:write", "api-keys:read", "api-keys:write"],
|
||||
UserRole.OPERATOR: ["pharmacies:read", "pharmacies:write", "medications:read", "inventory:read", "inventory:write", "connectors:read", "sync:read", "sync:write", "reservations:read", "reservations:write"],
|
||||
UserRole.VIEWER: ["pharmacies:read", "medications:read", "inventory:read", "reservations:read"],
|
||||
UserRole.API_CLIENT: ["pharmacies:read", "medications:read", "inventory:read", "reservations:read", "reservations:write"],
|
||||
}
|
||||
return scopes_map.get(role, ["pharmacies:read", "medications:read", "inventory:read"])
|
||||
@@ -0,0 +1,126 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from src.core.exceptions import ForbiddenException, UnauthorizedException
|
||||
from src.domain.entities import UserRole
|
||||
from src.infrastructure.config.settings import settings
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
import bcrypt as _bcrypt
|
||||
return _bcrypt.hashpw(password.encode("utf-8"), _bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
import bcrypt as _bcrypt
|
||||
return _bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
|
||||
|
||||
|
||||
def create_access_token(subject: str | UUID, role: UserRole, scopes: list[str], extra_claims: dict | None = None) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
expires = now + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
claims: dict[str, Any] = {
|
||||
"sub": str(subject),
|
||||
"role": role.value,
|
||||
"scopes": scopes,
|
||||
"iss": settings.JWT_ISSUER,
|
||||
"iat": now,
|
||||
"exp": expires,
|
||||
"type": "access",
|
||||
"jti": str(uuid4()),
|
||||
}
|
||||
if extra_claims:
|
||||
claims.update(extra_claims)
|
||||
return jwt.encode(claims, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(subject: str | UUID) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
expires = now + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
claims: dict[str, Any] = {
|
||||
"sub": str(subject),
|
||||
"iss": settings.JWT_ISSUER,
|
||||
"iat": now,
|
||||
"exp": expires,
|
||||
"type": "refresh",
|
||||
"jti": str(uuid4()),
|
||||
}
|
||||
return jwt.encode(claims, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any]:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM], issuer=settings.JWT_ISSUER)
|
||||
return payload
|
||||
except JWTError as e:
|
||||
raise UnauthorizedException(message=f"Invalid token: {e}") from e
|
||||
|
||||
|
||||
def validate_token_type(payload: dict[str, Any], expected_type: str = "access") -> dict[str, Any]:
|
||||
if payload.get("type") != expected_type:
|
||||
raise UnauthorizedException(message=f"Expected {expected_type} token, got {payload.get('type')}")
|
||||
return payload
|
||||
|
||||
|
||||
class TokenData:
|
||||
def __init__(self, payload: dict[str, Any]):
|
||||
self.subject: str = payload.get("sub", "")
|
||||
self.role: UserRole = UserRole(payload.get("role", "viewer"))
|
||||
self.scopes: list[str] = payload.get("scopes", [])
|
||||
self.issuer: str = payload.get("iss", "")
|
||||
self.token_type: str = payload.get("type", "access")
|
||||
self.jti: str = payload.get("jti", "")
|
||||
self.exp: datetime | None = None
|
||||
if exp := payload.get("exp"):
|
||||
self.exp = datetime.fromtimestamp(exp, tz=timezone.utc)
|
||||
|
||||
|
||||
class RBACGuard:
|
||||
ROLE_HIERARCHY: dict[UserRole, set[UserRole]] = {
|
||||
UserRole.SUPERADMIN: {UserRole.SUPERADMIN, UserRole.ADMIN, UserRole.OPERATOR, UserRole.VIEWER, UserRole.API_CLIENT},
|
||||
UserRole.ADMIN: {UserRole.ADMIN, UserRole.OPERATOR, UserRole.VIEWER, UserRole.API_CLIENT},
|
||||
UserRole.OPERATOR: {UserRole.OPERATOR, UserRole.VIEWER},
|
||||
UserRole.VIEWER: {UserRole.VIEWER},
|
||||
UserRole.API_CLIENT: {UserRole.API_CLIENT, UserRole.VIEWER},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def has_role(cls, token_data: TokenData, required_role: UserRole) -> bool:
|
||||
allowed = cls.ROLE_HIERARCHY.get(token_data.role, set())
|
||||
return required_role in allowed
|
||||
|
||||
@classmethod
|
||||
def require_role(cls, token_data: TokenData, required_role: UserRole) -> None:
|
||||
if not cls.has_role(token_data, required_role):
|
||||
raise ForbiddenException(required_role=required_role.value)
|
||||
|
||||
@classmethod
|
||||
def has_scope(cls, token_data: TokenData, required_scope: str) -> bool:
|
||||
if "*" in token_data.scopes:
|
||||
return True
|
||||
return required_scope in token_data.scopes
|
||||
|
||||
@classmethod
|
||||
def require_scope(cls, token_data: TokenData, required_scope: str) -> None:
|
||||
if not cls.has_scope(token_data, required_scope):
|
||||
raise ForbiddenException(message=f"Scope '{required_scope}' required")
|
||||
|
||||
|
||||
def generate_api_key() -> tuple[str, str, str]:
|
||||
raw_key = secrets.token_urlsafe(settings.API_KEY_LENGTH)
|
||||
prefix = raw_key[:8]
|
||||
key_hash = hashlib.sha256(raw_key.encode()).digest()
|
||||
return raw_key, prefix, key_hash
|
||||
|
||||
|
||||
def hash_api_key(raw_key: str) -> bytes:
|
||||
return hashlib.sha256(raw_key.encode()).digest()
|
||||
|
||||
|
||||
def verify_api_key(raw_key: str, stored_hash: bytes) -> bool:
|
||||
return hashlib.sha256(raw_key.encode()).digest() == stored_hash
|
||||
@@ -0,0 +1,71 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.core.exceptions import ConflictException, ForbiddenException, NotFoundException, ValidationException
|
||||
from src.core.logging import get_logger
|
||||
from src.domain.entities import TechnicalUserEntity, UserRole
|
||||
from src.infrastructure.database.models import TechnicalUserModel
|
||||
from src.infrastructure.database.repositories import TechnicalUserRepository
|
||||
from src.services.auth.security import RBACGuard, TokenData, hash_password, verify_password
|
||||
|
||||
logger = get_logger("user_service")
|
||||
|
||||
|
||||
class UserService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
self._repo = TechnicalUserRepository(session)
|
||||
|
||||
async def get_user(self, user_id: uuid.UUID) -> TechnicalUserEntity:
|
||||
user = await self._repo.get_by_id(user_id)
|
||||
if not user:
|
||||
raise NotFoundException("User", str(user_id))
|
||||
return user
|
||||
|
||||
async def list_users(self, *, role: str | None = None, is_active: bool | None = None, offset: int = 0, limit: int = 50, caller: TokenData | None = None) -> tuple[list[TechnicalUserEntity], int]:
|
||||
if caller and not RBACGuard.has_scope(caller, "users:read"):
|
||||
raise ForbiddenException("Scope 'users:read' required")
|
||||
return await self._repo.list_users(role=role, is_active=is_active, offset=offset, limit=limit)
|
||||
|
||||
async def create_user(self, username: str, password: str, email: str | None = None, full_name: str | None = None, role: UserRole = UserRole.VIEWER, caller: TokenData | None = None) -> TechnicalUserEntity:
|
||||
if caller:
|
||||
RBACGuard.require_role(caller, UserRole.ADMIN)
|
||||
existing = await self._repo.get_by_username(username)
|
||||
if existing:
|
||||
raise ConflictException(f"Username '{username}' already exists")
|
||||
pw_hash = hash_password(password)
|
||||
entity = TechnicalUserEntity(username=username, email=email, full_name=full_name, role=role, is_active=True, scopes=[])
|
||||
user = await self._repo.create(entity, pw_hash)
|
||||
logger.info("user_created", extra={"username": username, "role": role.value, "created_by": caller.subject if caller else "system"})
|
||||
return user
|
||||
|
||||
async def update_user(self, user_id: uuid.UUID, *, email: str | None = None, full_name: str | None = None, role: UserRole | None = None, is_active: bool | None = None, caller: TokenData | None = None) -> TechnicalUserEntity:
|
||||
if caller:
|
||||
RBACGuard.require_role(caller, UserRole.ADMIN)
|
||||
user = await self.get_user(user_id)
|
||||
if email is not None:
|
||||
user.email = email
|
||||
if full_name is not None:
|
||||
user.full_name = full_name
|
||||
if role is not None:
|
||||
user.role = role
|
||||
if is_active is not None:
|
||||
user.is_active = is_active
|
||||
user = await self._repo.update(user)
|
||||
logger.info("user_updated", extra={"user_id": str(user_id), "updated_by": caller.subject if caller else "system"})
|
||||
return user
|
||||
|
||||
async def change_password(self, user_id: uuid.UUID, current_password: str, new_password: str, caller: TokenData | None = None) -> bool:
|
||||
stmt = select(TechnicalUserModel.password_hash).where(TechnicalUserModel.id == user_id)
|
||||
result = await self._session.execute(stmt)
|
||||
pw_hash = result.scalar_one_or_none()
|
||||
if not pw_hash or not verify_password(current_password, pw_hash):
|
||||
raise ValidationException("Current password is incorrect")
|
||||
new_hash = hash_password(new_password)
|
||||
stmt = update(TechnicalUserModel).where(TechnicalUserModel.id == user_id).values(password_hash=new_hash)
|
||||
await self._session.execute(stmt)
|
||||
logger.info("password_changed", extra={"user_id": str(user_id)})
|
||||
return True
|
||||
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from src.connectors.base import ConnectorResult
|
||||
from src.connectors.registry import connector_registry
|
||||
from src.connectors.sdk.context import ConnectorContext
|
||||
from src.core.exceptions import ConnectorException
|
||||
from src.core.logging import get_logger
|
||||
from src.domain.entities import ConnectorConfigEntity, ConnectorEntity, ConnectorStatus
|
||||
from src.domain.interfaces import IConnectorConfigRepository, IConnectorRepository
|
||||
from src.infrastructure.messaging.rabbitmq import rabbitmq_client
|
||||
|
||||
logger = get_logger("connector_service")
|
||||
|
||||
|
||||
class ConnectorService:
|
||||
def __init__(self, connector_repo: IConnectorRepository, config_repo: IConnectorConfigRepository) -> None:
|
||||
self._connector_repo = connector_repo
|
||||
self._config_repo = config_repo
|
||||
|
||||
async def create_connector(self, entity: ConnectorEntity, configs: list[ConnectorConfigEntity] | None = None) -> ConnectorEntity:
|
||||
created = await self._connector_repo.create(entity)
|
||||
|
||||
if configs:
|
||||
for cfg in configs:
|
||||
cfg.connector_id = created.id
|
||||
await self._config_repo.upsert(cfg)
|
||||
|
||||
await self._publish_event("connector.created", created.id, {"connector_type": created.connector_type, "pharmacy_id": str(created.pharmacy_id), "erp_system_id": str(created.erp_system_id), "status": created.status})
|
||||
logger.info("connector_created", extra={"connector_id": str(created.id), "connector_type": created.connector_type})
|
||||
return created
|
||||
|
||||
async def get_connector(self, connector_id: UUID) -> ConnectorEntity | None:
|
||||
return await self._connector_repo.get_by_id(connector_id)
|
||||
|
||||
async def list_connectors(self, *, erp_system_id: UUID | None = None, pharmacy_id: UUID | None = None, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[list[ConnectorEntity], int]:
|
||||
return await self._connector_repo.list_connectors(erp_system_id=erp_system_id, pharmacy_id=pharmacy_id, status=status, offset=offset, limit=limit)
|
||||
|
||||
async def update_connector(self, entity: ConnectorEntity) -> ConnectorEntity:
|
||||
updated = await self._connector_repo.update(entity)
|
||||
await self._publish_event("connector.updated", updated.id, {"connector_type": updated.connector_type, "status": updated.status})
|
||||
return updated
|
||||
|
||||
async def delete_connector(self, connector_id: UUID) -> bool:
|
||||
connector = await self._connector_repo.get_by_id(connector_id)
|
||||
deleted = await self._connector_repo.delete(connector_id)
|
||||
if deleted:
|
||||
connector_registry.remove_instance(connector_id)
|
||||
if connector:
|
||||
await self._publish_event("connector.deleted", connector_id, {"connector_type": connector.connector_type, "pharmacy_id": str(connector.pharmacy_id)})
|
||||
return deleted
|
||||
|
||||
async def test_connection(self, connector_id: UUID) -> ConnectorResult:
|
||||
connector_entity = await self._connector_repo.get_by_id(connector_id)
|
||||
if not connector_entity:
|
||||
raise ConnectorException("unknown", f"Connector '{connector_id}' not found")
|
||||
|
||||
configs = await self._config_repo.list_by_connector(connector_id)
|
||||
config_dict = {c.key: c.value for c in configs}
|
||||
secrets_dict = {c.key: c.value for c in configs if c.encrypted}
|
||||
|
||||
context = ConnectorContext(
|
||||
connector_id=connector_entity.id,
|
||||
pharmacy_id=connector_entity.pharmacy_id,
|
||||
erp_system_id=connector_entity.erp_system_id,
|
||||
connector_type=connector_entity.connector_type,
|
||||
config=config_dict,
|
||||
secrets=secrets_dict,
|
||||
)
|
||||
|
||||
instance = connector_registry.get_or_create_instance(connector_id, connector_entity.connector_type, config_dict)
|
||||
if hasattr(instance, "set_context"):
|
||||
instance.set_context(context)
|
||||
|
||||
result = await instance.heartbeat(connector_entity.pharmacy_id)
|
||||
|
||||
if result.success:
|
||||
connector_entity.status = ConnectorStatus.ACTIVE
|
||||
connector_entity.last_heartbeat_at = datetime.now(timezone.utc)
|
||||
connector_entity.last_error = None
|
||||
await self._connector_repo.update(connector_entity)
|
||||
await self._publish_event("connector.heartbeat", connector_id, {"status": "healthy"})
|
||||
else:
|
||||
connector_entity.status = ConnectorStatus.ERROR
|
||||
connector_entity.last_error = result.error
|
||||
await self._connector_repo.update(connector_entity)
|
||||
await self._publish_event("connector.error", connector_id, {"error": result.error})
|
||||
|
||||
return result
|
||||
|
||||
async def deploy_connector(self, connector_id: UUID) -> ConnectorEntity:
|
||||
entity = await self._connector_repo.get_by_id(connector_id)
|
||||
if not entity:
|
||||
raise ConnectorException("unknown", f"Connector '{connector_id}' not found")
|
||||
|
||||
if entity.status == ConnectorStatus.ACTIVE:
|
||||
raise ConnectorException(entity.connector_type, "Connector is already active. Deactivate first to redeploy.")
|
||||
|
||||
configs = await self._config_repo.list_by_connector(connector_id)
|
||||
config_dict = {c.key: c.value for c in configs}
|
||||
|
||||
connector_registry.remove_instance(connector_id)
|
||||
connector_registry.get_or_create_instance(connector_id, entity.connector_type, config_dict)
|
||||
|
||||
entity.status = ConnectorStatus.ACTIVE
|
||||
entity.last_heartbeat_at = datetime.now(timezone.utc)
|
||||
entity.last_error = None
|
||||
updated = await self._connector_repo.update(entity)
|
||||
|
||||
await self._publish_event("connector.deployed", connector_id, {"connector_type": entity.connector_type, "status": "active"})
|
||||
logger.info("connector_deployed", extra={"connector_id": str(connector_id)})
|
||||
return updated
|
||||
|
||||
async def deactivate_connector(self, connector_id: UUID) -> ConnectorEntity:
|
||||
entity = await self._connector_repo.get_by_id(connector_id)
|
||||
if not entity:
|
||||
raise ConnectorException("unknown", f"Connector '{connector_id}' not found")
|
||||
|
||||
connector_registry.remove_instance(connector_id)
|
||||
|
||||
entity.status = ConnectorStatus.INACTIVE
|
||||
updated = await self._connector_repo.update(entity)
|
||||
|
||||
await self._publish_event("connector.deactivated", connector_id, {"connector_type": entity.connector_type, "status": "inactive"})
|
||||
return updated
|
||||
|
||||
async def get_configs(self, connector_id: UUID) -> list[ConnectorConfigEntity]:
|
||||
return await self._config_repo.list_by_connector(connector_id)
|
||||
|
||||
async def upsert_config(self, entity: ConnectorConfigEntity) -> ConnectorConfigEntity:
|
||||
return await self._config_repo.upsert(entity)
|
||||
|
||||
async def delete_config(self, config_id: UUID) -> bool:
|
||||
return await self._config_repo.delete(config_id)
|
||||
|
||||
async def _publish_event(self, event_type: str, connector_id: UUID, payload: dict[str, Any]) -> None:
|
||||
try:
|
||||
routing_key = f"connector.{event_type.split('.', 1)[1]}" if "." in event_type else f"connector.{event_type}"
|
||||
full_payload = {"event_type": event_type, "connector_id": str(connector_id), "timestamp": datetime.now(timezone.utc).isoformat(), **payload}
|
||||
await rabbitmq_client.publish(routing_key, full_payload)
|
||||
except Exception as exc:
|
||||
logger.warning("connector_event_publish_failed", extra={"event_type": event_type, "error": str(exc)})
|
||||
@@ -0,0 +1,210 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from src.core.exceptions import ConflictException, NotFoundException, ValidationException
|
||||
from src.core.logging import get_logger
|
||||
from src.domain.entities import (
|
||||
InventoryHistoryEntity,
|
||||
InventoryRecordEntity,
|
||||
ReservationEntity,
|
||||
ReservationStatus,
|
||||
)
|
||||
from src.domain.interfaces import IInventoryRepository, IReservationRepository
|
||||
from src.infrastructure.messaging.rabbitmq import rabbitmq_client
|
||||
|
||||
logger = get_logger("inventory_service")
|
||||
|
||||
RESERVATION_DEFAULT_TTL_MINUTES = 30
|
||||
|
||||
|
||||
class InventoryService:
|
||||
def __init__(
|
||||
self,
|
||||
inventory_repo: IInventoryRepository,
|
||||
reservation_repo: IReservationRepository,
|
||||
) -> None:
|
||||
self._inventory_repo = inventory_repo
|
||||
self._reservation_repo = reservation_repo
|
||||
|
||||
async def get_inventory_record(self, pharmacy_id: UUID, medication_id: UUID) -> InventoryRecordEntity | None:
|
||||
return await self._inventory_repo.get_record(pharmacy_id, medication_id)
|
||||
|
||||
async def list_inventory(self, pharmacy_id: UUID, *, offset: int = 0, limit: int = 50) -> tuple[list[InventoryRecordEntity], int]:
|
||||
return await self._inventory_repo.list_by_pharmacy(pharmacy_id, offset=offset, limit=limit)
|
||||
|
||||
async def upsert_inventory_record(self, entity: InventoryRecordEntity) -> InventoryRecordEntity:
|
||||
existing = await self._inventory_repo.get_record(entity.pharmacy_id, entity.medication_id)
|
||||
previous_stock = existing.stock if existing else 0
|
||||
|
||||
upserted = await self._inventory_repo.upsert(entity)
|
||||
|
||||
delta = entity.stock - previous_stock
|
||||
if delta != 0:
|
||||
history = InventoryHistoryEntity(
|
||||
inventory_record_id=upserted.id,
|
||||
pharmacy_id=entity.pharmacy_id,
|
||||
medication_id=entity.medication_id,
|
||||
previous_stock=previous_stock,
|
||||
new_stock=entity.stock,
|
||||
delta=delta,
|
||||
reason=entity.source or "upsert",
|
||||
source=entity.source,
|
||||
)
|
||||
await self._inventory_repo.add_history(history)
|
||||
|
||||
await self._publish_event("inventory.updated", upserted.id, {
|
||||
"pharmacy_id": str(entity.pharmacy_id),
|
||||
"medication_id": str(entity.medication_id),
|
||||
"previous_stock": previous_stock,
|
||||
"new_stock": entity.stock,
|
||||
"delta": delta,
|
||||
})
|
||||
return upserted
|
||||
|
||||
async def get_inventory_history(self, pharmacy_id: UUID, medication_id: UUID, *, offset: int = 0, limit: int = 50) -> list[InventoryHistoryEntity]:
|
||||
records = await self._inventory_repo.get_history(pharmacy_id, medication_id, offset=offset, limit=limit)
|
||||
return list(records)
|
||||
|
||||
async def create_reservation(
|
||||
self,
|
||||
pharmacy_id: UUID,
|
||||
medication_id: UUID,
|
||||
quantity: int,
|
||||
*,
|
||||
user_id: UUID | None = None,
|
||||
external_reference: str | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
ttl_minutes: int = RESERVATION_DEFAULT_TTL_MINUTES,
|
||||
) -> ReservationEntity:
|
||||
if idempotency_key:
|
||||
existing = await self._reservation_repo.get_by_idempotency_key(idempotency_key)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
record = await self._inventory_repo.get_record(pharmacy_id, medication_id)
|
||||
if not record:
|
||||
raise NotFoundException("InventoryRecord", f"{pharmacy_id}/{medication_id}")
|
||||
|
||||
if record.available_stock < quantity:
|
||||
raise ValidationException(
|
||||
f"Insufficient available stock. Requested: {quantity}, Available: {record.available_stock}",
|
||||
details={"available_stock": record.available_stock, "requested": quantity},
|
||||
)
|
||||
|
||||
record.reserved_stock += quantity
|
||||
record.available_stock = record.stock - record.reserved_stock
|
||||
await self._inventory_repo.upsert(record)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
entity = ReservationEntity(
|
||||
pharmacy_id=pharmacy_id,
|
||||
medication_id=medication_id,
|
||||
user_id=user_id,
|
||||
external_reference=external_reference,
|
||||
quantity=quantity,
|
||||
status=ReservationStatus.PENDING,
|
||||
expires_at=now + __import__("datetime").timedelta(minutes=ttl_minutes),
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
created = await self._reservation_repo.create(entity)
|
||||
|
||||
await self._publish_event("reservation.created", created.id, {
|
||||
"pharmacy_id": str(pharmacy_id),
|
||||
"medication_id": str(medication_id),
|
||||
"quantity": quantity,
|
||||
})
|
||||
return created
|
||||
|
||||
async def confirm_reservation(self, reservation_id: UUID) -> ReservationEntity:
|
||||
entity = await self._reservation_repo.get_by_id(reservation_id)
|
||||
if not entity:
|
||||
raise NotFoundException("Reservation", str(reservation_id))
|
||||
|
||||
if entity.status != ReservationStatus.PENDING:
|
||||
raise ValidationException(f"Reservation cannot be confirmed. Current status: {entity.status}")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
if entity.expires_at and entity.expires_at < now:
|
||||
entity.status = ReservationStatus.EXPIRED
|
||||
await self._reservation_repo.update(entity)
|
||||
await self._release_reserved_stock(entity)
|
||||
await self._publish_event("reservation.expired", entity.id, {"pharmacy_id": str(entity.pharmacy_id)})
|
||||
raise ValidationException("Reservation has expired")
|
||||
|
||||
entity.status = ReservationStatus.CONFIRMED
|
||||
entity.confirmed_at = now
|
||||
updated = await self._reservation_repo.update(entity)
|
||||
|
||||
await self._publish_event("reservation.confirmed", updated.id, {
|
||||
"pharmacy_id": str(entity.pharmacy_id),
|
||||
"medication_id": str(entity.medication_id),
|
||||
"quantity": entity.quantity,
|
||||
})
|
||||
return updated
|
||||
|
||||
async def cancel_reservation(self, reservation_id: UUID) -> ReservationEntity:
|
||||
entity = await self._reservation_repo.get_by_id(reservation_id)
|
||||
if not entity:
|
||||
raise NotFoundException("Reservation", str(reservation_id))
|
||||
|
||||
if entity.status not in (ReservationStatus.PENDING, ReservationStatus.CONFIRMED):
|
||||
raise ValidationException(f"Reservation cannot be cancelled. Current status: {entity.status}")
|
||||
|
||||
entity.status = ReservationStatus.CANCELLED
|
||||
entity.cancelled_at = datetime.now(timezone.utc)
|
||||
updated = await self._reservation_repo.update(entity)
|
||||
|
||||
await self._release_reserved_stock(entity)
|
||||
|
||||
await self._publish_event("reservation.cancelled", updated.id, {
|
||||
"pharmacy_id": str(entity.pharmacy_id),
|
||||
"medication_id": str(entity.medication_id),
|
||||
"quantity": entity.quantity,
|
||||
})
|
||||
return updated
|
||||
|
||||
async def get_reservation(self, reservation_id: UUID) -> ReservationEntity | None:
|
||||
return await self._reservation_repo.get_by_id(reservation_id)
|
||||
|
||||
async def list_reservations(self, pharmacy_id: UUID, *, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[list[ReservationEntity], int]:
|
||||
return await self._reservation_repo.list_by_pharmacy(pharmacy_id, status=status, offset=offset, limit=limit)
|
||||
|
||||
async def expire_pending_reservations(self) -> int:
|
||||
pass
|
||||
|
||||
async def _release_reserved_stock(self, reservation: ReservationEntity) -> None:
|
||||
record = await self._inventory_repo.get_record(reservation.pharmacy_id, reservation.medication_id)
|
||||
if not record:
|
||||
return
|
||||
|
||||
record.reserved_stock = max(0, record.reserved_stock - reservation.quantity)
|
||||
record.available_stock = record.stock - record.reserved_stock
|
||||
updated = await self._inventory_repo.upsert(record)
|
||||
|
||||
history = InventoryHistoryEntity(
|
||||
inventory_record_id=updated.id,
|
||||
pharmacy_id=reservation.pharmacy_id,
|
||||
medication_id=reservation.medication_id,
|
||||
previous_stock=record.stock + reservation.quantity if reservation.status == ReservationStatus.PENDING else record.stock,
|
||||
new_stock=record.stock,
|
||||
delta=-reservation.quantity,
|
||||
reason=f"reservation_{reservation.status}",
|
||||
source="inventory_service",
|
||||
)
|
||||
await self._inventory_repo.add_history(history)
|
||||
|
||||
async def _publish_event(self, event_type: str, resource_id: UUID, payload: dict[str, Any]) -> None:
|
||||
try:
|
||||
routing_key = event_type.replace(".", "_")
|
||||
full_payload = {
|
||||
"event_type": event_type,
|
||||
"resource_id": str(resource_id),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
**payload,
|
||||
}
|
||||
await rabbitmq_client.publish(routing_key, full_payload)
|
||||
except Exception as exc:
|
||||
logger.warning("inventory_event_publish_failed", extra={"event_type": event_type, "error": str(exc)})
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from src.core.exceptions import ConflictException, NotFoundException, ValidationException
|
||||
from src.core.logging import get_logger
|
||||
from src.domain.entities import MedicationCatalogEntryEntity, MedicationEntity
|
||||
from src.domain.interfaces import IMedicationCatalogRepository, IMedicationRepository
|
||||
from src.infrastructure.messaging.rabbitmq import rabbitmq_client
|
||||
|
||||
logger = get_logger("medication_service")
|
||||
|
||||
|
||||
class MedicationService:
|
||||
def __init__(
|
||||
self,
|
||||
medication_repo: IMedicationRepository,
|
||||
catalog_repo: IMedicationCatalogRepository,
|
||||
) -> None:
|
||||
self._medication_repo = medication_repo
|
||||
self._catalog_repo = catalog_repo
|
||||
|
||||
async def create_medication(self, entity: MedicationEntity) -> MedicationEntity:
|
||||
existing = await self._medication_repo.get_by_nregistro(entity.nregistro)
|
||||
if existing:
|
||||
raise ConflictException(f"Medication with nregistro '{entity.nregistro}' already exists")
|
||||
created = await self._medication_repo.create(entity)
|
||||
await self._publish_event("medication.created", created.id, {"nregistro": created.nregistro, "name": created.name})
|
||||
return created
|
||||
|
||||
async def get_medication(self, medication_id: UUID) -> MedicationEntity | None:
|
||||
return await self._medication_repo.get_by_id(medication_id)
|
||||
|
||||
async def get_by_nregistro(self, nregistro: str) -> MedicationEntity | None:
|
||||
return await self._medication_repo.get_by_nregistro(nregistro)
|
||||
|
||||
async def search_medications(self, query: str, *, offset: int = 0, limit: int = 50) -> tuple[list[MedicationEntity], int]:
|
||||
return await self._medication_repo.search(query, offset=offset, limit=limit)
|
||||
|
||||
async def update_medication(self, entity: MedicationEntity) -> MedicationEntity:
|
||||
existing = await self._medication_repo.get_by_id(entity.id) # type: ignore[arg-type]
|
||||
if not existing:
|
||||
raise NotFoundException("Medication", str(entity.id))
|
||||
updated = await self._medication_repo.update(entity)
|
||||
await self._publish_event("medication.updated", updated.id, {"nregistro": updated.nregistro})
|
||||
return updated
|
||||
|
||||
async def delete_medication(self, medication_id: UUID) -> bool:
|
||||
deleted = await self._medication_repo.delete(medication_id)
|
||||
if deleted:
|
||||
await self._publish_event("medication.deleted", medication_id, {})
|
||||
return deleted
|
||||
|
||||
async def get_catalog_entry(self, pharmacy_id: UUID, medication_id: UUID) -> MedicationCatalogEntryEntity | None:
|
||||
return await self._catalog_repo.get_entry(pharmacy_id, medication_id)
|
||||
|
||||
async def list_catalog(self, pharmacy_id: UUID, *, offset: int = 0, limit: int = 50) -> tuple[list[MedicationCatalogEntryEntity], int]:
|
||||
return await self._catalog_repo.list_by_pharmacy(pharmacy_id, offset=offset, limit=limit)
|
||||
|
||||
async def upsert_catalog_entry(self, entity: MedicationCatalogEntryEntity) -> MedicationCatalogEntryEntity:
|
||||
upserted = await self._catalog_repo.upsert(entity)
|
||||
await self._publish_event("catalog.upserted", upserted.id, {
|
||||
"pharmacy_id": str(entity.pharmacy_id),
|
||||
"medication_id": str(entity.medication_id),
|
||||
"is_available": entity.is_available,
|
||||
"stock": entity.stock,
|
||||
})
|
||||
return upserted
|
||||
|
||||
async def _publish_event(self, event_type: str, resource_id: UUID, payload: dict[str, Any]) -> None:
|
||||
try:
|
||||
routing_key = event_type.replace(".", "_")
|
||||
full_payload = {
|
||||
"event_type": event_type,
|
||||
"resource_id": str(resource_id),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
**payload,
|
||||
}
|
||||
await rabbitmq_client.publish(routing_key, full_payload)
|
||||
except Exception as exc:
|
||||
logger.warning("medication_event_publish_failed", extra={"event_type": event_type, "error": str(exc)})
|
||||
@@ -0,0 +1,3 @@
|
||||
from .sync_service import SyncService
|
||||
|
||||
__all__ = ["SyncService"]
|
||||
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from src.core.exceptions import NotFoundException, ValidationException
|
||||
from src.core.logging import get_logger
|
||||
from src.domain.entities import SyncJobEntity, SyncJobStatus, SyncStrategy
|
||||
from src.domain.interfaces import ISyncJobRepository, ISyncLogRepository
|
||||
from src.infrastructure.messaging.rabbitmq import rabbitmq_client
|
||||
|
||||
logger = get_logger("sync_service")
|
||||
|
||||
|
||||
class SyncService:
|
||||
def __init__(
|
||||
self,
|
||||
sync_job_repo: ISyncJobRepository,
|
||||
sync_log_repo: ISyncLogRepository,
|
||||
) -> None:
|
||||
self._sync_job_repo = sync_job_repo
|
||||
self._sync_log_repo = sync_log_repo
|
||||
|
||||
async def create_sync_job(
|
||||
self,
|
||||
pharmacy_id: UUID,
|
||||
connector_id: UUID,
|
||||
strategy: SyncStrategy,
|
||||
) -> SyncJobEntity:
|
||||
entity = SyncJobEntity(
|
||||
pharmacy_id=pharmacy_id,
|
||||
connector_id=connector_id,
|
||||
strategy=strategy,
|
||||
status=SyncJobStatus.PENDING,
|
||||
)
|
||||
created = await self._sync_job_repo.create(entity)
|
||||
await self._publish_event("sync.created", created.id, {
|
||||
"pharmacy_id": str(pharmacy_id),
|
||||
"connector_id": str(connector_id),
|
||||
"strategy": strategy,
|
||||
})
|
||||
return created
|
||||
|
||||
async def get_sync_job(self, job_id: UUID) -> SyncJobEntity | None:
|
||||
return await self._sync_job_repo.get_by_id(job_id)
|
||||
|
||||
async def list_sync_jobs(
|
||||
self,
|
||||
pharmacy_id: UUID,
|
||||
*,
|
||||
status: str | None = None,
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
) -> tuple[list[SyncJobEntity], int]:
|
||||
return await self._sync_job_repo.list_by_pharmacy(
|
||||
pharmacy_id, status=status, offset=offset, limit=limit,
|
||||
)
|
||||
|
||||
async def trigger_sync(self, job_id: UUID) -> SyncJobEntity:
|
||||
entity = await self._sync_job_repo.get_by_id(job_id)
|
||||
if not entity:
|
||||
raise NotFoundException("SyncJob", str(job_id))
|
||||
|
||||
if entity.status not in (SyncJobStatus.PENDING, SyncJobStatus.FAILED):
|
||||
raise ValidationException(
|
||||
f"Cannot trigger sync job. Current status: {entity.status}",
|
||||
details={"current_status": entity.status},
|
||||
)
|
||||
|
||||
entity.status = SyncJobStatus.RUNNING
|
||||
entity.started_at = datetime.now(timezone.utc)
|
||||
entity.error_message = None
|
||||
updated = await self._sync_job_repo.update(entity)
|
||||
|
||||
await self._add_log(job_id, "INFO", "Sync job triggered")
|
||||
await self._publish_event("sync.started", updated.id, {
|
||||
"pharmacy_id": str(entity.pharmacy_id),
|
||||
"strategy": entity.strategy,
|
||||
})
|
||||
return updated
|
||||
|
||||
async def cancel_sync(self, job_id: UUID) -> SyncJobEntity:
|
||||
entity = await self._sync_job_repo.get_by_id(job_id)
|
||||
if not entity:
|
||||
raise NotFoundException("SyncJob", str(job_id))
|
||||
|
||||
if entity.status != SyncJobStatus.RUNNING:
|
||||
raise ValidationException(
|
||||
f"Cannot cancel sync job. Current status: {entity.status}",
|
||||
details={"current_status": entity.status},
|
||||
)
|
||||
|
||||
entity.status = SyncJobStatus.CANCELLED
|
||||
entity.completed_at = datetime.now(timezone.utc)
|
||||
updated = await self._sync_job_repo.update(entity)
|
||||
|
||||
await self._add_log(job_id, "WARNING", "Sync job cancelled by user")
|
||||
await self._publish_event("sync.cancelled", updated.id, {
|
||||
"pharmacy_id": str(entity.pharmacy_id),
|
||||
})
|
||||
return updated
|
||||
|
||||
async def complete_sync(
|
||||
self,
|
||||
job_id: UUID,
|
||||
*,
|
||||
items_total: int | None = None,
|
||||
items_processed: int | None = None,
|
||||
items_failed: int | None = None,
|
||||
) -> SyncJobEntity:
|
||||
entity = await self._sync_job_repo.get_by_id(job_id)
|
||||
if not entity:
|
||||
raise NotFoundException("SyncJob", str(job_id))
|
||||
|
||||
entity.status = SyncJobStatus.COMPLETED
|
||||
entity.completed_at = datetime.now(timezone.utc)
|
||||
if items_total is not None:
|
||||
entity.items_total = items_total
|
||||
if items_processed is not None:
|
||||
entity.items_processed = items_processed
|
||||
if items_failed is not None:
|
||||
entity.items_failed = items_failed
|
||||
updated = await self._sync_job_repo.update(entity)
|
||||
|
||||
await self._add_log(job_id, "INFO", f"Sync completed. Processed: {items_processed}, Failed: {items_failed}")
|
||||
await self._publish_event("sync.completed", updated.id, {
|
||||
"pharmacy_id": str(entity.pharmacy_id),
|
||||
"items_total": items_total,
|
||||
"items_processed": items_processed,
|
||||
"items_failed": items_failed,
|
||||
})
|
||||
return updated
|
||||
|
||||
async def fail_sync(self, job_id: UUID, error_message: str) -> SyncJobEntity:
|
||||
entity = await self._sync_job_repo.get_by_id(job_id)
|
||||
if not entity:
|
||||
raise NotFoundException("SyncJob", str(job_id))
|
||||
|
||||
entity.status = SyncJobStatus.FAILED
|
||||
entity.completed_at = datetime.now(timezone.utc)
|
||||
entity.error_message = error_message
|
||||
updated = await self._sync_job_repo.update(entity)
|
||||
|
||||
await self._add_log(job_id, "ERROR", f"Sync failed: {error_message}")
|
||||
await self._publish_event("sync.failed", updated.id, {
|
||||
"pharmacy_id": str(entity.pharmacy_id),
|
||||
"error_message": error_message,
|
||||
})
|
||||
return updated
|
||||
|
||||
async def get_sync_logs(
|
||||
self,
|
||||
job_id: UUID,
|
||||
*,
|
||||
level: str | None = None,
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
):
|
||||
return await self._sync_log_repo.list_by_job(job_id, level=level, offset=offset, limit=limit)
|
||||
|
||||
async def _add_log(self, job_id: UUID, level: str, message: str, details: dict | None = None) -> None:
|
||||
from src.domain.entities import SyncLogEntity
|
||||
log = SyncLogEntity(sync_job_id=job_id, level=level, message=message, details=details)
|
||||
await self._sync_log_repo.add(log)
|
||||
|
||||
async def _publish_event(self, event_type: str, resource_id: UUID, payload: dict[str, Any]) -> None:
|
||||
try:
|
||||
routing_key = event_type.replace(".", "_")
|
||||
full_payload = {
|
||||
"event_type": event_type,
|
||||
"resource_id": str(resource_id),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
**payload,
|
||||
}
|
||||
await rabbitmq_client.publish(routing_key, full_payload)
|
||||
except Exception as exc:
|
||||
logger.warning("sync_event_publish_failed", extra={"event_type": event_type, "error": str(exc)})
|
||||
Reference in New Issue
Block a user