First implementation API for PIP (Proyecto: Pharmacy Integration Platform)
Run Tests on Branches / Backend Tests (push) Successful in 3m32s
Run Tests on Branches / Frontend Tests (push) Successful in 3m27s

This commit is contained in:
Antoni Nuñez Romeu
2026-07-01 16:46:52 +02:00
parent d935db4b3e
commit c5df0ec0b2
121 changed files with 9575 additions and 0 deletions
@@ -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"])
+126
View File
@@ -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)})