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
+3
View File
@@ -0,0 +1,3 @@
from .main import app
__all__ = ["app"]
@@ -0,0 +1,91 @@
from __future__ import annotations
import time
import uuid
from typing import Any
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response
from src.core.logging import get_logger
from src.domain.entities import AuditLogEntity
from src.domain.interfaces import IAuditLogRepository
from src.infrastructure.database.session import async_session_factory
logger = get_logger("audit_middleware")
WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
IGNORED_PATHS = {"/docs", "/redoc", "/openapi.json", "/metrics", "/api/v1/system/health"}
class AuditMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
response = await call_next(request)
if request.method in WRITE_METHODS and not self._should_ignore(request.url.path):
try:
await self._record_audit(request, response)
except Exception as exc:
logger.warning("audit_log_failed", extra={"error": str(exc)})
return response
def _should_ignore(self, path: str) -> bool:
return any(path.startswith(p) for p in IGNORED_PATHS)
async def _record_audit(self, request: Request, response: Response) -> None:
actor_id = None
actor_type = "anonymous"
if hasattr(request.state, "user") and request.state.user:
user = request.state.user
try:
actor_id = uuid.UUID(user.subject)
except (ValueError, TypeError):
pass
actor_type = user.role.value if hasattr(user.role, "value") else str(user.role)
resource_type = self._extract_resource_type(request.url.path)
action = f"{request.method.lower()}_{resource_type}"
resource_id = self._extract_resource_id(request.url.path)
ip_address = request.client.host if request.client else None
user_agent = request.headers.get("user-agent", "")
request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
async with async_session_factory() as session:
try:
from src.infrastructure.database.repositories import AuditLogRepository
repo = AuditLogRepository(session)
entity = AuditLogEntity(
actor_id=actor_id,
actor_type=actor_type,
action=action,
resource_type=resource_type,
resource_id=resource_id,
ip_address=ip_address,
user_agent=user_agent,
request_id=request_id,
)
await repo.add(entity)
await session.commit()
except Exception as exc:
logger.warning("audit_db_write_failed", extra={"error": str(exc)})
def _extract_resource_type(self, path: str) -> str:
parts = path.strip("/").split("/")
if len(parts) >= 3:
return parts[2]
if len(parts) >= 1:
return parts[-1]
return "unknown"
def _extract_resource_id(self, path: str) -> str | None:
parts = path.strip("/").split("/")
for part in reversed(parts[3:] if len(parts) > 3 else parts):
try:
uuid.UUID(part)
return part
except ValueError:
continue
return None
@@ -0,0 +1,36 @@
import time
import uuid
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response
from src.core.logging import get_logger
logger = get_logger("request_logging")
class RequestLoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
request.state.request_id = request_id
start = time.perf_counter()
response = await call_next(request)
duration_ms = (time.perf_counter() - start) * 1000
logger.info(
"request_completed",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"status_code": response.status_code,
"duration_ms": round(duration_ms, 2),
"client_ip": request.client.host if request.client else None,
"user_agent": request.headers.get("user-agent", ""),
},
)
response.headers["X-Request-ID"] = request_id
return response
@@ -0,0 +1,53 @@
import time
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from src.infrastructure.config.settings import settings
class RateLimitMiddleware(BaseHTTPMiddleware):
def __init__(self, app, requests_per_minute: int | None = None):
super().__init__(app)
self._rpm = requests_per_minute or settings.RATE_LIMIT_PER_MINUTE
self._clients: dict[str, list[float]] = {}
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
if request.method == "OPTIONS":
return await call_next(request)
path = request.url.path
if path.startswith("/docs") or path.startswith("/redoc") or path.startswith("/openapi") or path.startswith("/metrics"):
return await call_next(request)
client_key = self._get_client_key(request)
now = time.monotonic()
window = self._clients.setdefault(client_key, [])
window[:] = [t for t in window if now - t < 60]
if len(window) >= self._rpm:
return Response(
content='{"error":{"code":"RATE_LIMIT_EXCEEDED","message":"Rate limit exceeded. Try again later."}}',
status_code=429,
media_type="application/json",
headers={"Retry-After": "60"},
)
window.append(now)
response = await call_next(request)
remaining = self._rpm - len(window)
response.headers["X-RateLimit-Limit"] = str(self._rpm)
response.headers["X-RateLimit-Remaining"] = str(max(0, remaining))
response.headers["X-RateLimit-Reset"] = str(int(time.time()) + 60)
return response
@staticmethod
def _get_client_key(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client:
return request.client.host
return "unknown"
@@ -0,0 +1,107 @@
import uuid
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer
from src.core.exceptions import ForbiddenException, UnauthorizedException
from src.domain.entities import UserRole
from src.infrastructure.database.session import AsyncSession, get_db_session
from src.services.auth.auth_service import AuthService
from src.services.auth.security import RBACGuard, TokenData, decode_token, validate_token_type
from src.infrastructure.database.repositories import APIClientRepository, APIKeyRepository
from src.services.auth.security import verify_api_key
_bearer_scheme = HTTPBearer(auto_error=False)
_api_key_scheme = APIKeyHeader(name="X-API-Key", auto_error=False)
async def get_auth_service(session: AsyncSession = Depends(get_db_session)) -> AuthService:
return AuthService(session)
async def get_api_key_user(
api_key: str | None = Depends(_api_key_scheme),
session: AsyncSession = Depends(get_db_session),
) -> TokenData | None:
if not api_key:
return None
prefix = api_key[:8]
key_repo = APIKeyRepository(session)
key_entity = await key_repo.get_by_prefix(prefix)
if not key_entity or not key_entity.is_active:
return None
from datetime import datetime, timezone
if key_entity.expires_at and key_entity.expires_at < datetime.now(timezone.utc):
return None
if not verify_api_key(api_key, key_entity.key_hash):
return None
client_repo = APIClientRepository(session)
client = await client_repo.get_by_id(key_entity.api_client_id)
if not client or not client.is_active:
return None
await key_repo.update_last_used(key_entity.id)
return TokenData({
"sub": str(client.id),
"role": "api_client",
"scopes": client.scopes or [],
"iss": "pip-platform",
"type": "access",
"jti": str(key_entity.id),
})
async def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
api_key_user: TokenData | None = Depends(get_api_key_user),
) -> TokenData:
if credentials:
try:
payload = decode_token(credentials.credentials)
validate_token_type(payload, expected_type="access")
user = TokenData(payload)
request.state.user = user
return user
except UnauthorizedException:
raise
except Exception as e:
raise UnauthorizedException(f"Invalid token: {e}") from e
if api_key_user:
request.state.user = api_key_user
return api_key_user
raise UnauthorizedException("Authentication required. Provide Bearer token or X-API-Key header.")
def get_current_user_optional(
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
) -> TokenData | None:
if not credentials:
return None
try:
payload = decode_token(credentials.credentials)
validate_token_type(payload, expected_type="access")
return TokenData(payload)
except Exception:
return None
def require_role(role: UserRole):
def _checker(current_user: TokenData = Depends(get_current_user)) -> TokenData:
RBACGuard.require_role(current_user, role)
return current_user
return _checker
def require_scope(scope: str):
def _checker(current_user: TokenData = Depends(get_current_user)) -> TokenData:
RBACGuard.require_scope(current_user, scope)
return current_user
return _checker
@@ -0,0 +1,157 @@
from datetime import datetime
from uuid import UUID
import hashlib
import secrets
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.exceptions import ConflictException, NotFoundException
from src.domain.entities import APIClientEntity, APIKeyEntity
from src.infrastructure.database.repositories import APIClientRepository, APIKeyRepository
from src.infrastructure.database.session import get_db_session
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.domain.entities import UserRole
from src.services.auth.security import TokenData, generate_api_key
router = APIRouter(prefix="/api-clients", tags=["API Clients"])
class APIClientCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
description: str | None = None
scopes: list[str] = Field(default_factory=lambda: ["pharmacies:read", "medications:read", "inventory:read"])
rate_limit: int | None = None
allowed_ips: list[str] = Field(default_factory=list)
class APIClientResponse(BaseModel):
id: UUID
name: str
description: str | None
owner_id: UUID | None
is_active: bool
scopes: list[str]
rate_limit: int | None
allowed_ips: list[str]
created_at: datetime | None
updated_at: datetime | None
class APIKeyCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
expires_at: datetime | None = None
class APIKeyResponse(BaseModel):
id: UUID
name: str
key_prefix: str
is_active: bool
expires_at: datetime | None
last_used_at: datetime | None
created_at: datetime | None
class APIKeyCreateResponse(BaseModel):
id: UUID
name: str
key: str
key_prefix: str
expires_at: datetime | None
class APIClientListResponse(BaseModel):
items: list[APIClientResponse]
total: int
offset: int
limit: int
@router.get("", response_model=APIClientListResponse)
async def list_api_clients(
is_active: bool | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = APIClientRepository(session)
entities, total = await repo.list_clients(is_active=is_active, offset=offset, limit=limit)
items = [
APIClientResponse(
id=e.id, name=e.name, description=e.description, owner_id=e.owner_id,
is_active=e.is_active, scopes=e.scopes, rate_limit=e.rate_limit,
allowed_ips=e.allowed_ips, created_at=e.created_at, updated_at=e.updated_at,
)
for e in entities
]
return APIClientListResponse(items=items, total=total, offset=offset, limit=limit)
@router.post("", response_model=APIClientResponse, status_code=201)
async def create_api_client(
body: APIClientCreateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = APIClientRepository(session)
entity = APIClientEntity(
name=body.name, description=body.description,
owner_id=UUID(current_user.subject), is_active=True,
scopes=body.scopes, rate_limit=body.rate_limit,
allowed_ips=body.allowed_ips,
)
created = await repo.create(entity)
return APIClientResponse(
id=created.id, name=created.name, description=created.description,
owner_id=created.owner_id, is_active=created.is_active,
scopes=created.scopes, rate_limit=created.rate_limit,
allowed_ips=created.allowed_ips, created_at=created.created_at,
updated_at=created.updated_at,
)
@router.post("/{client_id}/keys", response_model=APIKeyCreateResponse, status_code=201)
async def create_api_key(
client_id: UUID,
body: APIKeyCreateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
client_repo = APIClientRepository(session)
client = await client_repo.get_by_id(client_id)
if not client:
raise NotFoundException("API Client", str(client_id))
raw_key, prefix, key_hash = generate_api_key()
key_repo = APIKeyRepository(session)
key_entity = APIKeyEntity(
api_client_id=client_id, key_prefix=prefix,
key_hash=key_hash, name=body.name,
is_active=True, expires_at=body.expires_at,
)
created = await key_repo.create(key_entity)
return APIKeyCreateResponse(
id=created.id, name=created.name, key=raw_key,
key_prefix=created.key_prefix, expires_at=created.expires_at,
)
@router.get("/{client_id}/keys", response_model=list[APIKeyResponse])
async def list_api_keys(
client_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
key_repo = APIKeyRepository(session)
keys = await key_repo.list_by_client(client_id)
return [
APIKeyResponse(
id=k.id, name=k.name, key_prefix=k.key_prefix,
is_active=k.is_active, expires_at=k.expires_at,
last_used_at=k.last_used_at, created_at=k.created_at,
)
for k in keys
]
@@ -0,0 +1,70 @@
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.domain.entities import UserRole
from src.infrastructure.database.repositories import AuditLogRepository
from src.infrastructure.database.session import get_db_session
from src.services.auth.security import TokenData
from src.services.audit.audit_service import AuditService
router = APIRouter(prefix="/audit", tags=["Audit"])
class AuditLogResponse(BaseModel):
id: UUID
actor_id: UUID | None
actor_type: str | None
action: str
resource_type: str
resource_id: str | None
old_value: dict | None
new_value: dict | None
ip_address: str | None
user_agent: str | None
request_id: str | None
created_at: datetime | None
class AuditLogListResponse(BaseModel):
items: list[AuditLogResponse]
total: int
offset: int
limit: int
def _get_service(session: AsyncSession) -> AuditService:
return AuditService(AuditLogRepository(session))
@router.get("/logs", response_model=AuditLogListResponse)
async def list_audit_logs(
actor_id: UUID | None = None,
action: str | None = None,
resource_type: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
entities, total = await service.list_logs(
actor_id=actor_id, action=action, resource_type=resource_type,
offset=offset, limit=limit,
)
items = [
AuditLogResponse(
id=e.id, actor_id=e.actor_id, actor_type=e.actor_type,
action=e.action, resource_type=e.resource_type,
resource_id=e.resource_id, old_value=e.old_value,
new_value=e.new_value, ip_address=e.ip_address,
user_agent=e.user_agent, request_id=e.request_id,
created_at=e.created_at,
)
for e in entities
]
return AuditLogListResponse(items=items, total=total, offset=offset, limit=limit)
@@ -0,0 +1,116 @@
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.entities import UserRole
from src.infrastructure.database.session import get_db_session
from src.services.auth.auth_service import AuthService
from src.api.v1.dependencies.auth import get_auth_service, get_current_user, require_role
from src.services.auth.security import TokenData
router = APIRouter(prefix="/auth", tags=["Authentication"])
class LoginRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=100)
password: str = Field(..., min_length=8)
class RegisterRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=100, pattern=r"^[A-Za-z0-9_]+$")
password: str = Field(..., min_length=8)
email: EmailStr | None = None
full_name: str | None = None
role: UserRole = UserRole.VIEWER
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
class RefreshRequest(BaseModel):
refresh_token: str
class UserResponse(BaseModel):
id: UUID
username: str
email: str | None
full_name: str | None
role: str
is_active: bool
scopes: list[str]
last_login_at: datetime | None
created_at: datetime | None
class MeResponse(BaseModel):
id: UUID
username: str
email: str | None
full_name: str | None
role: str
is_active: bool
scopes: list[str]
@router.post("/register", response_model=UserResponse, status_code=201)
async def register(body: RegisterRequest, service: AuthService = Depends(get_auth_service)):
user = await service.register(
username=body.username,
password=body.password,
email=body.email,
full_name=body.full_name,
role=body.role,
)
return UserResponse(
id=user.id,
username=user.username,
email=user.email,
full_name=user.full_name,
role=user.role.value,
is_active=user.is_active,
scopes=user.scopes,
last_login_at=user.last_login_at,
created_at=user.created_at,
)
@router.post("/token", response_model=TokenResponse)
async def login(body: LoginRequest, service: AuthService = Depends(get_auth_service)):
user, access_token, refresh_token = await service.authenticate(body.username, body.password)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=30 * 60,
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(body: RefreshRequest, service: AuthService = Depends(get_auth_service)):
access_token, refresh_token = await service.refresh_access_token(body.refresh_token)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=30 * 60,
)
@router.get("/me", response_model=MeResponse)
async def get_me(current_user: TokenData = Depends(get_current_user)):
return MeResponse(
id=UUID(current_user.subject),
username="",
email=None,
full_name=None,
role=current_user.role.value,
is_active=True,
scopes=current_user.scopes,
)
@@ -0,0 +1,106 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.core.exceptions import NotFoundException
from src.domain.entities import MedicationCatalogEntryEntity, UserRole
from src.infrastructure.database.repositories import MedicationCatalogRepository, MedicationRepository
from src.infrastructure.database.session import get_db_session
from src.services.auth.security import TokenData
from src.services.medication.medication_service import MedicationService
router = APIRouter(prefix="/pharmacies/{pharmacy_id}/catalog", tags=["Medication Catalog"])
class CatalogEntryUpsertRequest(BaseModel):
medication_id: UUID
price: float | None = None
stock: int = 0
is_available: bool = False
last_verified_at: datetime | None = None
metadata: dict[str, Any] | None = None
class CatalogEntryResponse(BaseModel):
id: UUID
pharmacy_id: UUID
medication_id: UUID
price: float | None
stock: int
is_available: bool
last_verified_at: datetime | None
metadata: dict[str, Any] | None
created_at: datetime | None
updated_at: datetime | None
class CatalogListResponse(BaseModel):
items: list[CatalogEntryResponse]
total: int
offset: int
limit: int
def _entity_to_response(e: MedicationCatalogEntryEntity) -> CatalogEntryResponse:
return CatalogEntryResponse(
id=e.id, 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_,
created_at=e.created_at, updated_at=e.updated_at,
)
def _get_service(session: AsyncSession) -> MedicationService:
return MedicationService(
medication_repo=MedicationRepository(session),
catalog_repo=MedicationCatalogRepository(session),
)
@router.get("", response_model=CatalogListResponse)
async def list_catalog(
pharmacy_id: UUID,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entities, total = await service.list_catalog(pharmacy_id, offset=offset, limit=limit)
return CatalogListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/{medication_id}", response_model=CatalogEntryResponse)
async def get_catalog_entry(
pharmacy_id: UUID,
medication_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entity = await service.get_catalog_entry(pharmacy_id, medication_id)
if not entity:
raise NotFoundException("CatalogEntry", f"{pharmacy_id}/{medication_id}")
return _entity_to_response(entity)
@router.put("", response_model=CatalogEntryResponse)
async def upsert_catalog_entry(
pharmacy_id: UUID,
body: CatalogEntryUpsertRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
entity = MedicationCatalogEntryEntity(
pharmacy_id=pharmacy_id, medication_id=body.medication_id,
price=body.price, stock=body.stock, is_available=body.is_available,
last_verified_at=body.last_verified_at, metadata_=body.metadata,
)
upserted = await service.upsert_catalog_entry(entity)
return _entity_to_response(upserted)
@@ -0,0 +1,280 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.connectors.base import ConnectorResult
from src.core.exceptions import NotFoundException, ValidationException
from src.domain.entities import ConnectorConfigEntity, ConnectorEntity, ConnectorStatus, ERPType, UserRole
from src.infrastructure.database.repositories import ConnectorConfigRepository, ConnectorRepository
from src.infrastructure.database.session import get_db_session
from src.services.auth.security import TokenData
from src.services.connector_service import ConnectorService
router = APIRouter(prefix="/connectors", tags=["Connectors"])
class ConnectorCreateRequest(BaseModel):
erp_system_id: UUID
pharmacy_id: UUID
name: str = Field(..., min_length=1, max_length=255)
connector_type: str
version: str = "1.0.0"
config: dict[str, Any] | None = None
metadata: dict[str, Any] | None = None
class ConnectorUpdateRequest(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
version: str | None = None
config: dict[str, Any] | None = None
status: str | None = None
metadata: dict[str, Any] | None = None
class ConnectorConfigRequest(BaseModel):
key: str = Field(..., min_length=1, max_length=100)
value: str
encrypted: bool = False
description: str | None = None
class ConnectorResponse(BaseModel):
id: UUID
erp_system_id: UUID
pharmacy_id: UUID
name: str
connector_type: str
version: str
config: dict[str, Any] | None
status: str
last_heartbeat_at: datetime | None
last_error: str | None
metadata: dict[str, Any] | None
created_at: datetime | None
updated_at: datetime | None
class ConnectorListResponse(BaseModel):
items: list[ConnectorResponse]
total: int
offset: int
limit: int
class ConnectorConfigResponse(BaseModel):
id: UUID
connector_id: UUID
key: str
value: str
encrypted: bool
description: str | None
created_at: datetime | None
updated_at: datetime | None
class ConnectorTestResponse(BaseModel):
success: bool
data: dict[str, Any] | None = None
error: str | None = None
class ConnectorDeployResponse(BaseModel):
id: UUID
status: str
message: str
def _entity_to_response(e: ConnectorEntity) -> ConnectorResponse:
return ConnectorResponse(
id=e.id, erp_system_id=e.erp_system_id, pharmacy_id=e.pharmacy_id,
name=e.name, connector_type=e.connector_type, version=e.version,
config=e.config, status=e.status, last_heartbeat_at=e.last_heartbeat_at,
last_error=e.last_error, metadata=e.metadata_, created_at=e.created_at,
updated_at=e.updated_at,
)
def _config_entity_to_response(e: ConnectorConfigEntity) -> ConnectorConfigResponse:
return ConnectorConfigResponse(
id=e.id, connector_id=e.connector_id, key=e.key, value=e.value,
encrypted=e.encrypted, description=e.description,
created_at=e.created_at, updated_at=e.updated_at,
)
def _get_service(session: AsyncSession) -> ConnectorService:
return ConnectorService(
connector_repo=ConnectorRepository(session),
config_repo=ConnectorConfigRepository(session),
)
@router.get("", response_model=ConnectorListResponse)
async def list_connectors(
erp_system_id: UUID | None = None,
pharmacy_id: UUID | None = None,
status: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entities, total = await service.list_connectors(erp_system_id=erp_system_id, pharmacy_id=pharmacy_id, status=status, offset=offset, limit=limit)
return ConnectorListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/types", response_model=list[str])
async def list_connector_types(
current_user: TokenData = Depends(get_current_user),
):
from src.connectors.registry import connector_registry
return connector_registry.list_registered_types()
@router.get("/{connector_id}", response_model=ConnectorResponse)
async def get_connector(
connector_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entity = await service.get_connector(connector_id)
if not entity:
raise NotFoundException("Connector", str(connector_id))
return _entity_to_response(entity)
@router.post("", response_model=ConnectorResponse, status_code=201)
async def create_connector(
body: ConnectorCreateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
entity = ConnectorEntity(
erp_system_id=body.erp_system_id, pharmacy_id=body.pharmacy_id,
name=body.name, connector_type=body.connector_type,
version=body.version, config=body.config,
status=ConnectorStatus.PENDING, metadata_=body.metadata,
)
created = await service.create_connector(entity)
return _entity_to_response(created)
@router.put("/{connector_id}", response_model=ConnectorResponse)
async def update_connector(
connector_id: UUID,
body: ConnectorUpdateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
entity = await service.get_connector(connector_id)
if not entity:
raise NotFoundException("Connector", str(connector_id))
update_data = body.model_dump(exclude_unset=True)
if "metadata" in update_data:
entity.metadata_ = update_data.pop("metadata")
for key, value in update_data.items():
setattr(entity, key, value)
updated = await service.update_connector(entity)
return _entity_to_response(updated)
@router.delete("/{connector_id}", status_code=204)
async def delete_connector(
connector_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
deleted = await service.delete_connector(connector_id)
if not deleted:
raise NotFoundException("Connector", str(connector_id))
@router.post("/{connector_id}/test", response_model=ConnectorTestResponse)
async def test_connector(
connector_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
result = await service.test_connection(connector_id)
return ConnectorTestResponse(success=result.success, data=result.data, error=result.error)
@router.post("/{connector_id}/deploy", response_model=ConnectorDeployResponse)
async def deploy_connector(
connector_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
entity = await service.deploy_connector(connector_id)
return ConnectorDeployResponse(id=entity.id, status=entity.status, message="Connector deployed successfully")
@router.post("/{connector_id}/deactivate", response_model=ConnectorDeployResponse)
async def deactivate_connector(
connector_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
entity = await service.deactivate_connector(connector_id)
return ConnectorDeployResponse(id=entity.id, status=entity.status, message="Connector deactivated successfully")
@router.get("/{connector_id}/configs", response_model=list[ConnectorConfigResponse])
async def list_connector_configs(
connector_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
connector = await service.get_connector(connector_id)
if not connector:
raise NotFoundException("Connector", str(connector_id))
configs = await service.get_configs(connector_id)
return [_config_entity_to_response(c) for c in configs]
@router.put("/{connector_id}/configs", response_model=ConnectorConfigResponse)
async def upsert_connector_config(
connector_id: UUID,
body: ConnectorConfigRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
connector = await service.get_connector(connector_id)
if not connector:
raise NotFoundException("Connector", str(connector_id))
cfg_entity = ConnectorConfigEntity(
connector_id=connector_id, key=body.key, value=body.value,
encrypted=body.encrypted, description=body.description,
)
created = await service.upsert_config(cfg_entity)
return _config_entity_to_response(created)
@router.delete("/{connector_id}/configs/{config_id}", status_code=204)
async def delete_connector_config(
connector_id: UUID,
config_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
await service.delete_config(config_id)
@@ -0,0 +1,143 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.core.exceptions import NotFoundException
from src.domain.entities import ERPSystemEntity, UserRole
from src.domain.interfaces import IERPSystemRepository
from src.infrastructure.database.repositories import ERPSystemRepository
from src.infrastructure.database.session import get_db_session
from src.services.auth.security import TokenData
router = APIRouter(prefix="/erp-systems", tags=["ERP Systems"])
class ERPSystemCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
erp_type: str
version: str | None = None
base_url: str | None = None
auth_config: dict[str, Any] | None = None
metadata: dict[str, Any] | None = None
class ERPSystemUpdateRequest(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
version: str | None = None
base_url: str | None = None
auth_config: dict[str, Any] | None = None
status: str | None = None
metadata: dict[str, Any] | None = None
class ERPSystemResponse(BaseModel):
id: UUID
name: str
erp_type: str
version: str | None
base_url: str | None
auth_config: dict[str, Any] | None
status: str
metadata: dict[str, Any] | None
created_at: datetime | None
updated_at: datetime | None
class ERPSystemListResponse(BaseModel):
items: list[ERPSystemResponse]
total: int
offset: int
limit: int
def _entity_to_response(e: ERPSystemEntity) -> ERPSystemResponse:
return ERPSystemResponse(
id=e.id, name=e.name, erp_type=e.erp_type, version=e.version,
base_url=e.base_url, auth_config=e.auth_config, status=e.status,
metadata=e.metadata_, created_at=e.created_at, updated_at=e.updated_at,
)
def _get_repo(session: AsyncSession) -> IERPSystemRepository:
return ERPSystemRepository(session)
@router.get("", response_model=ERPSystemListResponse)
async def list_erp_systems(
erp_type: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
repo = _get_repo(session)
entities, total = await repo.list_erp_systems(erp_type=erp_type, offset=offset, limit=limit)
return ERPSystemListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/{erp_id}", response_model=ERPSystemResponse)
async def get_erp_system(
erp_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
repo = _get_repo(session)
entity = await repo.get_by_id(erp_id)
if not entity:
raise NotFoundException("ERP System", str(erp_id))
return _entity_to_response(entity)
@router.post("", response_model=ERPSystemResponse, status_code=201)
async def create_erp_system(
body: ERPSystemCreateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = _get_repo(session)
entity = ERPSystemEntity(
name=body.name, erp_type=body.erp_type, version=body.version,
base_url=body.base_url, auth_config=body.auth_config,
metadata_=body.metadata,
)
created = await repo.create(entity)
return _entity_to_response(created)
@router.put("/{erp_id}", response_model=ERPSystemResponse)
async def update_erp_system(
erp_id: UUID,
body: ERPSystemUpdateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = _get_repo(session)
entity = await repo.get_by_id(erp_id)
if not entity:
raise NotFoundException("ERP System", str(erp_id))
update_data = body.model_dump(exclude_unset=True)
if "metadata" in update_data:
entity.metadata_ = update_data.pop("metadata")
for key, value in update_data.items():
setattr(entity, key, value)
updated = await repo.update(entity)
return _entity_to_response(updated)
@router.delete("/{erp_id}", status_code=204)
async def delete_erp_system(
erp_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = _get_repo(session)
deleted = await repo.delete(erp_id)
if not deleted:
raise NotFoundException("ERP System", str(erp_id))
@@ -0,0 +1,150 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.core.exceptions import NotFoundException
from src.domain.entities import InventoryRecordEntity, UserRole
from src.infrastructure.database.repositories import InventoryRepository, ReservationRepository
from src.infrastructure.database.session import get_db_session
from src.services.auth.security import TokenData
from src.services.inventory.inventory_service import InventoryService
router = APIRouter(prefix="/pharmacies/{pharmacy_id}/inventory", tags=["Inventory"])
class InventoryUpsertRequest(BaseModel):
medication_id: UUID
stock: int = Field(..., ge=0)
reserved_stock: int = Field(0, ge=0)
available_stock: int = Field(0, ge=0)
min_stock: int | None = Field(None, ge=0)
max_stock: int | None = Field(None, ge=0)
last_updated_at: datetime | None = None
source: str | None = Field(None, max_length=100)
batch_id: str | None = Field(None, max_length=100)
metadata: dict[str, Any] | None = None
class InventoryRecordResponse(BaseModel):
id: UUID
pharmacy_id: UUID
medication_id: UUID
stock: int
reserved_stock: int
available_stock: int
min_stock: int | None
max_stock: int | None
last_updated_at: datetime | None
source: str | None
batch_id: str | None
metadata: dict[str, Any] | None
created_at: datetime | None
updated_at: datetime | None
class InventoryListResponse(BaseModel):
items: list[InventoryRecordResponse]
total: int
offset: int
limit: int
class InventoryHistoryResponse(BaseModel):
id: UUID
pharmacy_id: UUID
medication_id: UUID
previous_stock: int
new_stock: int
delta: int
reason: str | None
source: str | None
created_at: datetime | None
def _record_entity_to_response(e: InventoryRecordEntity) -> InventoryRecordResponse:
return InventoryRecordResponse(
id=e.id, 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_, created_at=e.created_at, updated_at=e.updated_at,
)
def _get_service(session: AsyncSession) -> InventoryService:
return InventoryService(
inventory_repo=InventoryRepository(session),
reservation_repo=ReservationRepository(session),
)
@router.get("", response_model=InventoryListResponse)
async def list_inventory(
pharmacy_id: UUID,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entities, total = await service.list_inventory(pharmacy_id, offset=offset, limit=limit)
return InventoryListResponse(items=[_record_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/{medication_id}", response_model=InventoryRecordResponse)
async def get_inventory_record(
pharmacy_id: UUID,
medication_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entity = await service.get_inventory_record(pharmacy_id, medication_id)
if not entity:
raise NotFoundException("InventoryRecord", f"{pharmacy_id}/{medication_id}")
return _record_entity_to_response(entity)
@router.put("", response_model=InventoryRecordResponse)
async def upsert_inventory_record(
pharmacy_id: UUID,
body: InventoryUpsertRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
entity = InventoryRecordEntity(
pharmacy_id=pharmacy_id, medication_id=body.medication_id,
stock=body.stock, reserved_stock=body.reserved_stock,
available_stock=body.available_stock, min_stock=body.min_stock,
max_stock=body.max_stock, last_updated_at=body.last_updated_at,
source=body.source, batch_id=body.batch_id, metadata_=body.metadata,
)
upserted = await service.upsert_inventory_record(entity)
return _record_entity_to_response(upserted)
@router.get("/{medication_id}/history", response_model=list[InventoryHistoryResponse])
async def get_inventory_history(
pharmacy_id: UUID,
medication_id: UUID,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
history = await service.get_inventory_history(pharmacy_id, medication_id, offset=offset, limit=limit)
return [
InventoryHistoryResponse(
id=h.id, pharmacy_id=h.pharmacy_id, medication_id=h.medication_id,
previous_stock=h.previous_stock, new_stock=h.new_stock, delta=h.delta,
reason=h.reason, source=h.source, created_at=h.created_at,
)
for h in history
]
@@ -0,0 +1,189 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.core.exceptions import ConflictException, NotFoundException
from src.domain.entities import MedicationEntity, UserRole
from src.infrastructure.database.repositories import MedicationCatalogRepository, MedicationRepository
from src.infrastructure.database.session import get_db_session
from src.services.auth.security import TokenData
from src.services.medication.medication_service import MedicationService
router = APIRouter(prefix="/medications", tags=["Medications"])
class MedicationCreateRequest(BaseModel):
nregistro: str = Field(..., min_length=1, max_length=50)
name: str = Field(..., min_length=1, max_length=500)
active_ingredient: str | None = Field(None, max_length=500)
dosage: str | None = Field(None, max_length=100)
form: str | None = Field(None, max_length=100)
atc_code: str | None = Field(None, max_length=20)
prescription_required: bool = False
manufacturer: str | None = Field(None, max_length=255)
metadata: dict[str, Any] | None = None
class MedicationUpdateRequest(BaseModel):
name: str | None = Field(None, min_length=1, max_length=500)
active_ingredient: str | None = Field(None, max_length=500)
dosage: str | None = Field(None, max_length=100)
form: str | None = Field(None, max_length=100)
atc_code: str | None = Field(None, max_length=20)
prescription_required: bool | None = None
manufacturer: str | None = Field(None, max_length=255)
metadata: dict[str, Any] | None = None
class MedicationResponse(BaseModel):
id: UUID
nregistro: str
name: str
active_ingredient: str | None
dosage: str | None
form: str | None
atc_code: str | None
prescription_required: bool
manufacturer: str | None
metadata: dict[str, Any] | None
created_at: datetime | None
updated_at: datetime | None
class MedicationListResponse(BaseModel):
items: list[MedicationResponse]
total: int
offset: int
limit: int
def _entity_to_response(e: MedicationEntity) -> MedicationResponse:
return MedicationResponse(
id=e.id, 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_,
created_at=e.created_at, updated_at=e.updated_at,
)
def _get_service(session: AsyncSession) -> MedicationService:
return MedicationService(
medication_repo=MedicationRepository(session),
catalog_repo=MedicationCatalogRepository(session),
)
@router.get("", response_model=MedicationListResponse)
async def list_medications(
q: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
if q:
entities, total = await service.search_medications(q, offset=offset, limit=limit)
else:
from src.domain.entities import MedicationEntity
all_entities, total = await service.search_medications("", offset=offset, limit=limit)
entities = all_entities
return MedicationListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/search", response_model=MedicationListResponse)
async def search_medications(
q: str = Query(..., min_length=1),
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entities, total = await service.search_medications(q, offset=offset, limit=limit)
return MedicationListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/nregistro/{nregistro}", response_model=MedicationResponse)
async def get_medication_by_nregistro(
nregistro: str,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entity = await service.get_by_nregistro(nregistro)
if not entity:
raise NotFoundException("Medication", nregistro)
return _entity_to_response(entity)
@router.get("/{medication_id}", response_model=MedicationResponse)
async def get_medication(
medication_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entity = await service.get_medication(medication_id)
if not entity:
raise NotFoundException("Medication", str(medication_id))
return _entity_to_response(entity)
@router.post("", response_model=MedicationResponse, status_code=201)
async def create_medication(
body: MedicationCreateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
entity = MedicationEntity(
nregistro=body.nregistro, name=body.name,
active_ingredient=body.active_ingredient, dosage=body.dosage,
form=body.form, atc_code=body.atc_code,
prescription_required=body.prescription_required,
manufacturer=body.manufacturer, metadata_=body.metadata,
)
created = await service.create_medication(entity)
return _entity_to_response(created)
@router.put("/{medication_id}", response_model=MedicationResponse)
async def update_medication(
medication_id: UUID,
body: MedicationUpdateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
entity = await service.get_medication(medication_id)
if not entity:
raise NotFoundException("Medication", str(medication_id))
update_data = body.model_dump(exclude_unset=True)
if "metadata" in update_data:
entity.metadata_ = update_data.pop("metadata")
for key, value in update_data.items():
setattr(entity, key, value)
updated = await service.update_medication(entity)
return _entity_to_response(updated)
@router.delete("/{medication_id}", status_code=204)
async def delete_medication(
medication_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
deleted = await service.delete_medication(medication_id)
if not deleted:
raise NotFoundException("Medication", str(medication_id))
@@ -0,0 +1,185 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.exceptions import NotFoundException
from src.domain.entities import PharmacyEntity, PharmacyStatus
from src.domain.interfaces import IPharmacyRepository
from src.infrastructure.database.session import get_db_session
from src.infrastructure.database.repositories import PharmacyRepository
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.domain.entities import UserRole
from src.services.auth.security import TokenData
router = APIRouter(prefix="/pharmacies", tags=["Pharmacies"])
class PharmacyCreateRequest(BaseModel):
code: str = Field(..., min_length=1, max_length=50)
name: str = Field(..., min_length=1, max_length=255)
address: str | None = None
city: str | None = None
province: str | None = None
postal_code: str | None = None
country: str = "ES"
latitude: float | None = None
longitude: float | None = None
phone: str | None = None
email: str | None = None
opening_hours: dict | None = None
erp_type: str | None = None
erp_version: str | None = None
sync_strategy: str = "incremental"
sync_interval_minutes: int = 30
class PharmacyUpdateRequest(BaseModel):
name: str | None = None
address: str | None = None
city: str | None = None
province: str | None = None
postal_code: str | None = None
country: str | None = None
latitude: float | None = None
longitude: float | None = None
phone: str | None = None
email: str | None = None
opening_hours: dict | None = None
status: str | None = None
erp_type: str | None = None
erp_version: str | None = None
sync_strategy: str | None = None
sync_interval_minutes: int | None = None
class PharmacyResponse(BaseModel):
id: UUID
code: str
name: str
address: str | None
city: str | None
province: str | None
postal_code: str | None
country: str
latitude: float | None
longitude: float | None
phone: str | None
email: str | None
opening_hours: dict | None
status: str
erp_type: str | None
erp_version: str | None
connector_id: UUID | None
sync_strategy: str
sync_interval_minutes: int
last_sync_at: datetime | None
last_sync_status: str | None
created_at: datetime | None
updated_at: datetime | None
class PharmacyListResponse(BaseModel):
items: list[PharmacyResponse]
total: int
offset: int
limit: int
def _entity_to_response(e: PharmacyEntity) -> PharmacyResponse:
return PharmacyResponse(
id=e.id, 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,
created_at=e.created_at, updated_at=e.updated_at,
)
def _get_repo(session: AsyncSession) -> IPharmacyRepository:
return PharmacyRepository(session)
@router.get("", response_model=PharmacyListResponse)
async def list_pharmacies(
status: str | None = None,
erp_type: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
repo = _get_repo(session)
entities, total = await repo.list_pharmacies(status=status, erp_type=erp_type, offset=offset, limit=limit)
return PharmacyListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/{pharmacy_id}", response_model=PharmacyResponse)
async def get_pharmacy(
pharmacy_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
repo = _get_repo(session)
entity = await repo.get_by_id(pharmacy_id)
if not entity:
raise NotFoundException("Pharmacy", str(pharmacy_id))
return _entity_to_response(entity)
@router.post("", response_model=PharmacyResponse, status_code=201)
async def create_pharmacy(
body: PharmacyCreateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = _get_repo(session)
entity = PharmacyEntity(
code=body.code, name=body.name, address=body.address, city=body.city,
province=body.province, postal_code=body.postal_code, country=body.country,
latitude=body.latitude, longitude=body.longitude, phone=body.phone,
email=body.email, opening_hours=body.opening_hours,
status=PharmacyStatus.ACTIVE.value, erp_type=body.erp_type,
erp_version=body.erp_version, sync_strategy=body.sync_strategy,
sync_interval_minutes=body.sync_interval_minutes,
)
created = await repo.create(entity)
return _entity_to_response(created)
@router.put("/{pharmacy_id}", response_model=PharmacyResponse)
async def update_pharmacy(
pharmacy_id: UUID,
body: PharmacyUpdateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = _get_repo(session)
entity = await repo.get_by_id(pharmacy_id)
if not entity:
raise NotFoundException("Pharmacy", str(pharmacy_id))
update_data = body.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(entity, key, value)
updated = await repo.update(entity)
return _entity_to_response(updated)
@router.delete("/{pharmacy_id}", status_code=204)
async def delete_pharmacy(
pharmacy_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = _get_repo(session)
deleted = await repo.delete(pharmacy_id)
if not deleted:
raise NotFoundException("Pharmacy", str(pharmacy_id))
@@ -0,0 +1,149 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.core.exceptions import NotFoundException, ValidationException
from src.domain.entities import ReservationEntity, ReservationStatus, UserRole
from src.infrastructure.database.repositories import InventoryRepository, ReservationRepository
from src.infrastructure.database.session import get_db_session
from src.services.auth.security import TokenData
from src.services.inventory.inventory_service import InventoryService
router = APIRouter(prefix="/pharmacies/{pharmacy_id}/reservations", tags=["Reservations"])
class ReservationCreateRequest(BaseModel):
medication_id: UUID
quantity: int = Field(..., gt=0)
external_reference: str | None = Field(None, max_length=255)
idempotency_key: str | None = Field(None, max_length=255)
ttl_minutes: int = Field(30, ge=1, le=1440)
metadata: dict[str, Any] | None = None
class ReservationResponse(BaseModel):
id: UUID
pharmacy_id: UUID
medication_id: UUID
user_id: UUID | None
external_reference: str | None
quantity: int
status: str
expires_at: datetime | None
confirmed_at: datetime | None
cancelled_at: datetime | None
idempotency_key: str | None
metadata: dict[str, Any] | None
created_at: datetime | None
updated_at: datetime | None
class ReservationListResponse(BaseModel):
items: list[ReservationResponse]
total: int
offset: int
limit: int
def _entity_to_response(e: ReservationEntity) -> ReservationResponse:
return ReservationResponse(
id=e.id, 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_,
created_at=e.created_at, updated_at=e.updated_at,
)
def _get_service(session: AsyncSession) -> InventoryService:
return InventoryService(
inventory_repo=InventoryRepository(session),
reservation_repo=ReservationRepository(session),
)
@router.get("", response_model=ReservationListResponse)
async def list_reservations(
pharmacy_id: UUID,
status: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entities, total = await service.list_reservations(pharmacy_id, status=status, offset=offset, limit=limit)
return ReservationListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/{reservation_id}", response_model=ReservationResponse)
async def get_reservation(
pharmacy_id: UUID,
reservation_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entity = await service.get_reservation(reservation_id)
if not entity or entity.pharmacy_id != pharmacy_id:
raise NotFoundException("Reservation", str(reservation_id))
return _entity_to_response(entity)
@router.post("", response_model=ReservationResponse, status_code=201)
async def create_reservation(
pharmacy_id: UUID,
body: ReservationCreateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
user_id = None
if hasattr(current_user, "user_id"):
user_id = current_user.user_id
elif hasattr(current_user, "sub"):
try:
user_id = UUID(current_user.sub)
except (ValueError, TypeError):
pass
created = await service.create_reservation(
pharmacy_id=pharmacy_id,
medication_id=body.medication_id,
quantity=body.quantity,
user_id=user_id,
external_reference=body.external_reference,
idempotency_key=body.idempotency_key,
ttl_minutes=body.ttl_minutes,
)
return _entity_to_response(created)
@router.post("/{reservation_id}/confirm", response_model=ReservationResponse)
async def confirm_reservation(
pharmacy_id: UUID,
reservation_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
updated = await service.confirm_reservation(reservation_id)
return _entity_to_response(updated)
@router.post("/{reservation_id}/cancel", response_model=ReservationResponse)
async def cancel_reservation(
pharmacy_id: UUID,
reservation_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
updated = await service.cancel_reservation(reservation_id)
return _entity_to_response(updated)
@@ -0,0 +1,199 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.core.exceptions import NotFoundException
from src.domain.entities import SyncJobStatus, SyncStrategy, UserRole
from src.infrastructure.database.repositories import SyncJobRepository, SyncLogRepository
from src.infrastructure.database.session import get_db_session
from src.services.auth.security import TokenData
from src.services.synchronization.sync_service import SyncService
router = APIRouter(prefix="/pharmacies/{pharmacy_id}/sync-jobs", tags=["Sync Jobs"])
class SyncJobCreateRequest(BaseModel):
connector_id: UUID
strategy: SyncStrategy = SyncStrategy.INCREMENTAL
class SyncJobResponse(BaseModel):
id: UUID
pharmacy_id: UUID
connector_id: UUID
strategy: str
status: str
started_at: datetime | None
completed_at: datetime | None
items_total: int | None
items_processed: int | None
items_failed: int | None
error_message: str | None
metadata: dict[str, Any] | None
created_at: datetime | None
updated_at: datetime | None
class SyncJobListResponse(BaseModel):
items: list[SyncJobResponse]
total: int
offset: int
limit: int
class SyncLogResponse(BaseModel):
id: UUID
sync_job_id: UUID
level: str
message: str
details: dict[str, Any] | None
created_at: datetime | None
def _entity_to_response(e: Any) -> SyncJobResponse:
return SyncJobResponse(
id=e.id, 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_,
created_at=e.created_at, updated_at=e.updated_at,
)
def _get_service(session: AsyncSession) -> SyncService:
return SyncService(
sync_job_repo=SyncJobRepository(session),
sync_log_repo=SyncLogRepository(session),
)
@router.get("", response_model=SyncJobListResponse)
async def list_sync_jobs(
pharmacy_id: UUID,
status: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entities, total = await service.list_sync_jobs(pharmacy_id, status=status, offset=offset, limit=limit)
return SyncJobListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/{job_id}", response_model=SyncJobResponse)
async def get_sync_job(
pharmacy_id: UUID,
job_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entity = await service.get_sync_job(job_id)
if not entity or entity.pharmacy_id != pharmacy_id:
raise NotFoundException("SyncJob", str(job_id))
return _entity_to_response(entity)
@router.post("", response_model=SyncJobResponse, status_code=201)
async def create_sync_job(
pharmacy_id: UUID,
body: SyncJobCreateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
created = await service.create_sync_job(
pharmacy_id=pharmacy_id,
connector_id=body.connector_id,
strategy=body.strategy,
)
return _entity_to_response(created)
@router.post("/{job_id}/trigger", response_model=SyncJobResponse)
async def trigger_sync(
pharmacy_id: UUID,
job_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
updated = await service.trigger_sync(job_id)
return _entity_to_response(updated)
@router.post("/{job_id}/cancel", response_model=SyncJobResponse)
async def cancel_sync(
pharmacy_id: UUID,
job_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
updated = await service.cancel_sync(job_id)
return _entity_to_response(updated)
class SyncCompleteRequest(BaseModel):
items_total: int | None = None
items_processed: int | None = None
items_failed: int | None = None
duration_ms: int = 0
class SyncFailRequest(BaseModel):
error_message: str
@router.post("/{job_id}/complete", response_model=SyncJobResponse)
async def complete_sync(
pharmacy_id: UUID,
job_id: UUID,
body: SyncCompleteRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
updated = await service.complete_sync(job_id, items_total=body.items_total, items_processed=body.items_processed, items_failed=body.items_failed)
return _entity_to_response(updated)
@router.post("/{job_id}/fail", response_model=SyncJobResponse)
async def fail_sync(
pharmacy_id: UUID,
job_id: UUID,
body: SyncFailRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
updated = await service.fail_sync(job_id, error_message=body.error_message)
return _entity_to_response(updated)
@router.get("/{job_id}/logs", response_model=list[SyncLogResponse])
async def get_sync_logs(
pharmacy_id: UUID,
job_id: UUID,
level: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
logs = await service.get_sync_logs(job_id, level=level, offset=offset, limit=limit)
return [
SyncLogResponse(
id=l.id, sync_job_id=l.sync_job_id, level=l.level,
message=l.message, details=l.details, created_at=l.created_at,
)
for l in logs
]
@@ -0,0 +1,77 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from src.infrastructure.cache.redis_cache import redis_cache
from src.infrastructure.database.session import get_db_session
from src.infrastructure.messaging.rabbitmq import rabbitmq_client
from src.api.v1.dependencies.auth import get_current_user
from src.services.auth.security import TokenData
router = APIRouter(prefix="/system", tags=["System"])
class HealthStatus(BaseModel):
status: str
version: str
services: dict[str, str]
class ReadinessStatus(BaseModel):
status: str
checks: dict[str, bool]
@router.get("/health", response_model=HealthStatus)
async def health_check():
return HealthStatus(
status="healthy",
version="0.1.0",
services={
"api": "healthy",
},
)
@router.get("/ready", response_model=ReadinessStatus)
async def readiness_check(db: AsyncSession = Depends(get_db_session)):
checks: dict[str, bool] = {}
try:
# Check database connection
await db.execute(text("SELECT 1"))
db_ok = True
except Exception:
db_ok = False
checks["database"] = db_ok
redis_ok = await redis_cache.health_check()
checks["redis"] = redis_ok
mq_ok = await rabbitmq_client.health_check()
checks["rabbitmq"] = mq_ok
overall = all(checks.values())
return ReadinessStatus(
status="ready" if overall else "degraded",
checks=checks,
)
@router.get("/status")
async def system_status(current_user: TokenData = Depends(get_current_user)):
return {
"platform": "Pharmacy Integration Platform",
"version": "0.1.0",
"environment": "production",
"services": {
"auth": "active",
"pharmacy": "active",
"medication": "active",
"connector": "active",
"synchronization": "active",
"audit": "active",
"notification": "active",
},
}
@@ -0,0 +1,70 @@
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.exceptions import NotFoundException
from src.domain.entities import TechnicalUserEntity, UserRole
from src.domain.interfaces import ITechnicalUserRepository
from src.infrastructure.database.repositories import TechnicalUserRepository
from src.infrastructure.database.session import get_db_session
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.services.auth.security import TokenData
router = APIRouter(prefix="/users", tags=["Technical Users"])
class UserResponse(BaseModel):
id: UUID
username: str
email: str | None
full_name: str | None
role: str
is_active: bool
scopes: list[str]
last_login_at: datetime | None
created_at: datetime | None
class UserListResponse(BaseModel):
items: list[UserResponse]
total: int
offset: int
limit: int
def _entity_to_response(e: TechnicalUserEntity) -> UserResponse:
return UserResponse(
id=e.id, username=e.username, email=e.email, full_name=e.full_name,
role=e.role, is_active=e.is_active, scopes=e.scopes,
last_login_at=e.last_login_at, created_at=e.created_at,
)
@router.get("", response_model=UserListResponse)
async def list_users(
role: str | None = None,
is_active: bool | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = TechnicalUserRepository(session)
entities, total = await repo.list_users(role=role, is_active=is_active, offset=offset, limit=limit)
return UserListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = TechnicalUserRepository(session)
entity = await repo.get_by_id(user_id)
if not entity:
raise NotFoundException("User", str(user_id))
return _entity_to_response(entity)
+31
View File
@@ -0,0 +1,31 @@
from fastapi import APIRouter
from src.api.v1.endpoints.auth import router as auth_router
from src.api.v1.endpoints.pharmacies import router as pharmacies_router
from src.api.v1.endpoints.users import router as users_router
from src.api.v1.endpoints.api_clients import router as api_clients_router
from src.api.v1.endpoints.audit import router as audit_router
from src.api.v1.endpoints.system import router as system_router
from src.api.v1.endpoints.connectors import router as connectors_router
from src.api.v1.endpoints.erp_systems import router as erp_systems_router
from src.api.v1.endpoints.medications import router as medications_router
from src.api.v1.endpoints.catalog import router as catalog_router
from src.api.v1.endpoints.inventory import router as inventory_router
from src.api.v1.endpoints.reservations import router as reservations_router
from src.api.v1.endpoints.sync_jobs import router as sync_jobs_router
v1_router = APIRouter(prefix="/api/v1")
v1_router.include_router(auth_router)
v1_router.include_router(pharmacies_router)
v1_router.include_router(users_router)
v1_router.include_router(api_clients_router)
v1_router.include_router(audit_router)
v1_router.include_router(system_router)
v1_router.include_router(connectors_router)
v1_router.include_router(erp_systems_router)
v1_router.include_router(medications_router)
v1_router.include_router(catalog_router)
v1_router.include_router(inventory_router)
v1_router.include_router(reservations_router)
v1_router.include_router(sync_jobs_router)
+117
View File
@@ -0,0 +1,117 @@
"""CLI entrypoints for the PIP platform."""
import asyncio
import sys
import click
from src.core.logging import configure_logging, get_logger
from src.infrastructure.cache.redis_cache import redis_cache
from src.infrastructure.config.settings import settings
from src.infrastructure.database.session import async_session_factory, engine
from src.infrastructure.messaging.rabbitmq import rabbitmq_client
from src.services.auth.auth_service import AuthService
from src.domain.entities import UserRole
@click.group()
def cli():
"""PIP platform CLI."""
configure_logging()
@cli.command()
def run():
"""Start the API server."""
import uvicorn
uvicorn.run(
"src.main:app",
host="0.0.0.0",
port=8000,
reload=True if settings.DEBUG else False,
log_level=settings.LOG_LEVEL.lower(),
)
@cli.command()
def migrate_upgrade():
"""Apply database migrations."""
from alembic.config import Config
from alembic import command
cfg = Config("alembic.ini")
command.upgrade(cfg, "head")
click.echo("Migrations applied.")
@cli.command()
def migrate_downgrade():
"""Roll back the last database migration."""
from alembic.config import Config
from alembic import command
cfg = Config("alembic.ini")
command.downgrade(cfg, "-1")
click.echo("Rollback complete.")
@cli.command()
def migrate_revision():
"""Create a new database revision."""
from alembic.config import Config
from alembic import command
message = click.prompt("Revision message")
cfg = Config("alembic.ini")
command.revision(cfg, autogenerate=True, message=message)
click.echo("Revision created.")
@cli.command()
@click.option("--username", required=True)
@click.option("--password", required=True)
@click.option("--email", default=None)
@click.option("--role", default="admin", type=click.Choice([r.value for r in UserRole]))
def create_user(username: str, password: str, email: str | None, role: str):
"""Create a technical user."""
asyncio.run(_create_user(username, password, email, role))
async def _create_user(username: str, password: str, email: str | None, role: str):
async with async_session_factory() as session:
service = AuthService(session)
user = await service.register(
username=username,
password=password,
email=email,
role=UserRole(role),
)
click.echo(f"Created user '{user.username}' with role '{user.role.value}'.")
@cli.command()
def health():
"""Check platform health across services."""
asyncio.run(_health())
async def _health():
logger = get_logger("cli.health")
async with engine.begin() as conn:
await conn.run_sync(lambda s: None)
click.echo("Database: OK")
await redis_cache.connect()
ok = await redis_cache.health_check()
click.echo(f"Redis: {'OK' if ok else 'FAIL'}")
await redis_cache.disconnect()
await rabbitmq_client.connect()
ok = await rabbitmq_client.health_check()
click.echo(f"RabbitMQ: {'OK' if ok else 'FAIL'}")
await rabbitmq_client.disconnect()
await engine.dispose()
if __name__ == "__main__":
cli()
@@ -0,0 +1,17 @@
from .base_connector import (
BaseInventoryConnector,
ConnectorResult,
StockInfo,
PharmacyInfo,
SyncResult,
ReservationResult,
)
__all__ = [
"BaseInventoryConnector",
"ConnectorResult",
"StockInfo",
"PharmacyInfo",
"SyncResult",
"ReservationResult",
]
@@ -0,0 +1,78 @@
from abc import ABC, abstractmethod
from typing import Any
from uuid import UUID
from pydantic import BaseModel
class ConnectorResult(BaseModel):
success: bool
data: Any | None = None
error: str | None = None
metadata: dict | None = None
class StockInfo(BaseModel):
medication_id: UUID
nregistro: str
name: str
stock: int
reserved_stock: int = 0
available_stock: int = 0
price: float | None = None
last_updated: str | None = None
class PharmacyInfo(BaseModel):
pharmacy_id: UUID
code: str
name: str
address: str | None = None
phone: str | None = None
email: str | None = None
opening_hours: dict | None = None
latitude: float | None = None
longitude: float | None = None
class SyncResult(BaseModel):
success: bool
items_total: int = 0
items_processed: int = 0
items_failed: int = 0
error_message: str | None = None
metadata: dict | None = None
class ReservationResult(BaseModel):
success: bool
reservation_id: str | None = None
error: str | None = None
class BaseInventoryConnector(ABC):
connector_type: str = "base"
@abstractmethod
async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult:
...
@abstractmethod
async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult:
...
@abstractmethod
async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult:
...
@abstractmethod
async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult:
...
@abstractmethod
async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult:
...
@abstractmethod
async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult:
...
@@ -0,0 +1,3 @@
from src.connectors.farmatic.farmatic_connector import FarmaticConnector
__all__ = ["FarmaticConnector"]
@@ -0,0 +1,122 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from src.connectors.sdk.base_sdk import SDKConnector
from src.connectors.sdk.context import ConnectorContext
from src.connectors.base import ConnectorResult
from src.core.exceptions import ConnectorException
from src.core.logging import get_logger
from src.core.metrics import CONNECTOR_OPERATIONS
logger = get_logger("farmatic_connector")
class FarmaticConnector(SDKConnector):
connector_type: str = "farmatic"
def configure(self, config: dict[str, Any]) -> None:
self._base_url = config.get("base_url", "https://erp.farmatic.local/api/v1")
self._timeout = config.get("timeout", 45.0)
super().configure(config)
async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult:
try:
params: dict[str, Any] = {"pharmacy": str(pharmacy_id)}
if nregistro:
params["nregistro"] = nregistro
if medication_id:
params["medication_id"] = str(medication_id)
response = await self._request("GET", "/stock", params=params)
data = response.json()
self._record_metric("get_stock", "success")
return ConnectorResult(success=True, data=data)
except Exception as exc:
self._record_metric("get_stock", "error")
logger.error("farmatic_get_stock_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)})
return ConnectorResult(success=False, error=str(exc))
async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult:
try:
payload: dict[str, Any] = {
"pharmacy_id": str(pharmacy_id),
"medication_id": str(medication_id),
"quantity": quantity,
}
if idempotency_key:
payload["idempotency_key"] = idempotency_key
response = await self._request("POST", "/reservations", json=payload)
data = response.json()
self._record_metric("reserve", "success")
return ConnectorResult(success=True, data=data)
except Exception as exc:
self._record_metric("reserve", "error")
logger.error("farmatic_reserve_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)})
return ConnectorResult(success=False, error=str(exc))
async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult:
try:
response = await self._request("DELETE", f"/reservations/{reservation_id}", json={"pharmacy_id": str(pharmacy_id)})
data = response.json()
self._record_metric("cancel_reservation", "success")
return ConnectorResult(success=True, data=data)
except Exception as exc:
self._record_metric("cancel_reservation", "error")
logger.error("farmatic_cancel_error", extra={"reservation_id": reservation_id, "error": str(exc)})
return ConnectorResult(success=False, error=str(exc))
async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult:
try:
response = await self._request("GET", f"/pharmacies/{pharmacy_id}")
data = response.json()
self._record_metric("pharmacy_information", "success")
return ConnectorResult(success=True, data=data)
except Exception as exc:
self._record_metric("pharmacy_information", "error")
logger.error("farmatic_pharmacy_info_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)})
return ConnectorResult(success=False, error=str(exc))
async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult:
try:
payload: dict[str, Any] = {
"pharmacy_id": str(pharmacy_id),
"strategy": strategy,
}
if since:
payload["since"] = since
response = await self._request("POST", "/sync", json=payload)
data = response.json()
self._record_metric("synchronize", "success")
return ConnectorResult(success=True, data=data)
except Exception as exc:
self._record_metric("synchronize", "error")
logger.error("farmatic_sync_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)})
return ConnectorResult(success=False, error=str(exc))
async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult:
try:
response = await self._request("GET", "/health")
data = response.json()
data["connector_type"] = self.connector_type
data["pharmacy_id"] = str(pharmacy_id)
data["circuit_breaker"] = self._circuit_breaker.state.value
self._record_metric("heartbeat", "success")
return ConnectorResult(success=True, data=data)
except Exception as exc:
self._record_metric("heartbeat", "error")
return ConnectorResult(
success=False,
error=str(exc),
metadata={"circuit_breaker": self._circuit_breaker.state.value},
)
@@ -0,0 +1,3 @@
from .mock_connector import MockConnector
__all__ = ["MockConnector"]
@@ -0,0 +1,215 @@
import asyncio
import random
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from src.connectors.base import BaseInventoryConnector, ConnectorResult
from src.core.logging import get_logger
from src.core.metrics import CONNECTOR_OPERATIONS
logger = get_logger("mock_connector")
class MockConnector(BaseInventoryConnector):
connector_type: str = "mock"
def __init__(self) -> None:
self._simulate_latency: bool = True
self._latency_range_ms: tuple[int, int] = (20, 150)
self._error_rate: float = 0.0
self._stock_data: dict[str, dict[str, Any]] = {}
self._reservations: dict[str, dict[str, Any]] = {}
self._pharmacies: dict[str, dict[str, Any]] = {}
self._init_mock_data()
def configure(self, config: dict[str, Any]) -> None:
self._simulate_latency = config.get("simulate_latency", True)
self._latency_range_ms = tuple(config.get("latency_range_ms", [20, 150]))
self._error_rate = config.get("error_rate", 0.0)
def _init_mock_data(self) -> None:
medicamentos = [
("00001", "Ibuprofeno 600mg", 100),
("00002", "Paracetamol 1g", 50),
("00003", "Amoxicilina 500mg", 200),
("00004", "Omeprazol 20mg", 75),
("00005", "Loratadina 10mg", 120),
("00006", "Diazepam 5mg", 30),
("00007", "Metformina 850mg", 250),
("00008", "Atorvastatina 20mg", 80),
("00009", "Salbutamol Inhalador", 15),
("00010", "Insulina Glargina", 40),
]
for nregistro, name, stock in medicamentos:
reserved = random.randint(0, max(stock // 10, 1))
self._stock_data[nregistro] = {
"nregistro": nregistro,
"name": name,
"stock": stock,
"reserved_stock": reserved,
"available_stock": stock - reserved,
"price": round(random.uniform(2.5, 45.0), 2),
"last_updated": datetime.now(timezone.utc).isoformat(),
}
self._pharmacies["default"] = {
"code": "FARM-001",
"name": "Farmacia Mock Central",
"address": "Calle Mayor 15, Madrid",
"phone": "+34912345678",
"email": "central@mock-farmacia.es",
"opening_hours": {
"mon_fri": ["09:00", "21:30"],
"sat": ["10:00", "14:00"],
"sun": "closed",
},
"latitude": 40.4168,
"longitude": -3.7038,
}
self._pharmacies["second"] = {
"code": "FARM-002",
"name": "Farmacia Mock Norte",
"address": "Av. de la Ilustración 1, Madrid",
"phone": "+34915432100",
"email": "norte@mock-farmacia.es",
"opening_hours": {
"mon_fri": ["08:30", "22:00"],
"sat": ["09:00", "22:00"],
"sun": ["10:00", "14:00"],
},
"latitude": 40.4722,
"longitude": -3.6934,
}
async def _maybe_delay(self) -> None:
if self._simulate_latency:
low, high = self._latency_range_ms
delay = random.randint(low, high) / 1000.0
await asyncio.sleep(delay)
def _maybe_error(self) -> None:
if self._error_rate > 0 and random.random() < self._error_rate:
error_types = [
("TIMEOUT", "Connection timed out"),
("CONNECTION_REFUSED", "ERP server refused connection"),
("AUTH_FAILED", "Authentication failed"),
("RATE_LIMITED", "ERP rate limit exceeded"),
("INTERNAL_ERROR", "ERP internal server error"),
]
code, msg = random.choice(error_types)
from src.core.exceptions import ConnectorException
raise ConnectorException(self.connector_type, msg, details={"error_code": code})
def _metric(self, operation: str, status: str) -> None:
CONNECTOR_OPERATIONS.labels(connector_type=self.connector_type, operation=operation, status=status).inc()
async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult:
await self._maybe_delay()
self._maybe_error()
logger.info("mock_get_stock", extra={"pharmacy_id": str(pharmacy_id), "nregistro": nregistro})
if nregistro and nregistro in self._stock_data:
data = dict(self._stock_data[nregistro])
data["pharmacy_id"] = str(pharmacy_id)
self._metric("get_stock", "success")
return ConnectorResult(success=True, data=data)
if nregistro is None and medication_id is None:
results = [dict(v) for v in self._stock_data.values()]
for r in results:
r["pharmacy_id"] = str(pharmacy_id)
self._metric("get_stock", "success")
return ConnectorResult(success=True, data={"items": results, "total": len(results)})
self._metric("get_stock", "not_found")
return ConnectorResult(success=False, error=f"Medication '{nregistro}' not found in mock data")
async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult:
await self._maybe_delay()
self._maybe_error()
logger.info("mock_reserve", extra={"pharmacy_id": str(pharmacy_id), "quantity": quantity})
if idempotency_key and idempotency_key in self._reservations:
self._metric("reserve", "idempotent")
return ConnectorResult(success=True, data=self._reservations[idempotency_key])
reservation_id = f"mock-res-{idempotency_key or random.randint(100000, 999999)}"
reservation_data = {
"reservation_id": reservation_id,
"status": "confirmed",
"quantity": quantity,
"pharmacy_id": str(pharmacy_id),
"medication_id": str(medication_id),
"confirmed_at": datetime.now(timezone.utc).isoformat(),
"expires_at": datetime.now(timezone.utc).isoformat(),
}
self._reservations[reservation_id] = reservation_data
if idempotency_key:
self._reservations[idempotency_key] = reservation_data
self._metric("reserve", "success")
return ConnectorResult(success=True, data=reservation_data)
async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult:
await self._maybe_delay()
self._maybe_error()
logger.info("mock_cancel_reservation", extra={"reservation_id": reservation_id})
if reservation_id in self._reservations:
self._reservations[reservation_id]["status"] = "cancelled"
self._metric("cancel_reservation", "success")
return ConnectorResult(success=True, data={"reservation_id": reservation_id, "status": "cancelled"})
self._metric("cancel_reservation", "not_found")
return ConnectorResult(success=False, error=f"Reservation '{reservation_id}' not found")
async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult:
await self._maybe_delay()
self._maybe_error()
logger.info("mock_pharmacy_information", extra={"pharmacy_id": str(pharmacy_id)})
pharmacy = list(self._pharmacies.values())[0]
result = dict(pharmacy)
result["pharmacy_id"] = str(pharmacy_id)
self._metric("pharmacy_information", "success")
return ConnectorResult(success=True, data=result)
async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult:
await self._maybe_delay()
self._maybe_error()
logger.info("mock_synchronize", extra={"pharmacy_id": str(pharmacy_id), "strategy": strategy, "since": since})
items_total = len(self._stock_data)
items_processed = items_total
items_failed = random.randint(0, min(2, items_total)) if self._error_rate > 0 else 0
if items_failed > 0:
items_processed = items_total - items_failed
self._metric("synchronize", "success")
return ConnectorResult(
success=True,
data={
"items_total": items_total,
"items_processed": items_processed,
"items_failed": items_failed,
"strategy": strategy,
"synced_at": datetime.now(timezone.utc).isoformat(),
"pharmacy_id": str(pharmacy_id),
},
)
async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult:
self._metric("heartbeat", "success")
return ConnectorResult(
success=True,
data={
"status": "healthy",
"timestamp": datetime.now(timezone.utc).isoformat(),
"connector_type": self.connector_type,
"pharmacy_id": str(pharmacy_id),
"circuit_breaker": "closed",
"stock_items": len(self._stock_data),
"active_reservations": len([r for r in self._reservations.values() if r.get("status") == "confirmed"]),
},
)
@@ -0,0 +1,105 @@
from __future__ import annotations
import importlib
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from src.connectors.base import BaseInventoryConnector, ConnectorResult
from src.core.exceptions import ConnectorException
from src.core.logging import get_logger
from src.core.metrics import CONNECTOR_OPERATIONS
from src.domain.entities import ConnectorStatus, ERPType
from src.infrastructure.cache.redis_cache import redis_cache
from src.infrastructure.database.repositories import ConnectorConfigRepository, ConnectorRepository
from src.infrastructure.messaging.rabbitmq import rabbitmq_client
logger = get_logger("connector_registry")
_BUILTIN_CONNECTORS: dict[str, str] = {
ERPType.MOCK.value: "src.connectors.mock.mock_connector.MockConnector",
ERPType.FARMATIC.value: "src.connectors.farmatic.farmatic_connector.FarmaticConnector",
ERPType.CUSTOM_REST.value: "src.connectors.rest.rest_connector.RESTConnector",
}
class ConnectorRegistry:
_instance: ConnectorRegistry | None = None
_connectors: dict[str, BaseInventoryConnector]
_instances: dict[UUID, BaseInventoryConnector]
def __init__(self) -> None:
self._connectors = {}
self._instances = {}
@classmethod
def get_registry(cls) -> ConnectorRegistry:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def register(self, connector_type: str, connector: BaseInventoryConnector) -> None:
self._connectors[connector_type] = connector
logger.info("connector_registered", extra={"connector_type": connector_type})
def unregister(self, connector_type: str) -> None:
self._connectors.pop(connector_type, None)
logger.info("connector_unregistered", extra={"connector_type": connector_type})
def get_connector_class(self, connector_type: str) -> type[BaseInventoryConnector]:
if connector_type in self._connectors:
return type(self._connectors[connector_type])
class_path = _BUILTIN_CONNECTORS.get(connector_type)
if not class_path:
raise ConnectorException(connector_type, f"No connector registered for type '{connector_type}'")
try:
module_path, class_name = class_path.rsplit(".", 1)
module = importlib.import_module(module_path)
cls = getattr(module, class_name)
self._connectors[connector_type] = cls()
return cls
except (ImportError, AttributeError) as e:
raise ConnectorException(connector_type, f"Failed to load connector '{connector_type}': {e}") from e
def create_instance(self, connector_type: str, config: dict[str, Any] | None = None) -> BaseInventoryConnector:
cls = self.get_connector_class(connector_type)
instance = cls()
if config and hasattr(instance, "configure"):
instance.configure(config)
return instance
def get_or_create_instance(self, connector_id: UUID, connector_type: str, config: dict[str, Any] | None = None) -> BaseInventoryConnector:
if connector_id in self._instances:
return self._instances[connector_id]
instance = self.create_instance(connector_type, config)
self._instances[connector_id] = instance
return instance
def remove_instance(self, connector_id: UUID) -> None:
self._instances.pop(connector_id, None)
def list_registered_types(self) -> list[str]:
all_types = set(self._connectors.keys()) | set(_BUILTIN_CONNECTORS.keys())
return sorted(all_types)
@staticmethod
async def record_heartbeat(repo: ConnectorRepository, connector_id: UUID) -> None:
entity = await repo.get_by_id(connector_id)
if entity:
entity.last_heartbeat_at = datetime.now(timezone.utc)
entity.status = ConnectorStatus.ACTIVE
entity.last_error = None
await repo.update(entity)
@staticmethod
async def record_error(repo: ConnectorRepository, connector_id: UUID, error: str) -> None:
entity = await repo.get_by_id(connector_id)
if entity:
entity.last_error = error
entity.status = ConnectorStatus.ERROR
await repo.update(entity)
connector_registry = ConnectorRegistry.get_registry()
@@ -0,0 +1,3 @@
from src.connectors.rest.rest_connector import RESTConnector
__all__ = ["RESTConnector"]
@@ -0,0 +1,103 @@
from __future__ import annotations
from typing import Any
from uuid import UUID
from src.connectors.sdk.base_sdk import SDKConnector
from src.connectors.base import ConnectorResult
from src.core.logging import get_logger
logger = get_logger("rest_connector")
class RESTConnector(SDKConnector):
connector_type: str = "custom_rest"
_STOCK_PATH: str = "/stock"
_RESERVE_PATH: str = "/reservations"
_PHARMACY_PATH: str = "/pharmacy"
_SYNC_PATH: str = "/sync"
_HEALTH_PATH: str = "/health"
def configure(self, config: dict[str, Any]) -> None:
self._base_url = config.get("base_url", "")
self._timeout = config.get("timeout", 30.0)
self._STOCK_PATH = config.get("stock_path", self._STOCK_PATH)
self._RESERVE_PATH = config.get("reserve_path", self._RESERVE_PATH)
self._PHARMACY_PATH = config.get("pharmacy_path", self._PHARMACY_PATH)
self._SYNC_PATH = config.get("sync_path", self._SYNC_PATH)
self._HEALTH_PATH = config.get("health_path", self._HEALTH_PATH)
super().configure(config)
async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult:
try:
params: dict[str, Any] = {"pharmacy_id": str(pharmacy_id)}
if nregistro:
params["nregistro"] = nregistro
if medication_id:
params["medication_id"] = str(medication_id)
response = await self._request("GET", self._STOCK_PATH, params=params)
self._record_metric("get_stock", "success")
return ConnectorResult(success=True, data=response.json())
except Exception as exc:
self._record_metric("get_stock", "error")
return ConnectorResult(success=False, error=str(exc))
async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult:
try:
payload: dict[str, Any] = {
"pharmacy_id": str(pharmacy_id),
"medication_id": str(medication_id),
"quantity": quantity,
}
if idempotency_key:
payload["idempotency_key"] = idempotency_key
response = await self._request("POST", self._RESERVE_PATH, json=payload)
self._record_metric("reserve", "success")
return ConnectorResult(success=True, data=response.json())
except Exception as exc:
self._record_metric("reserve", "error")
return ConnectorResult(success=False, error=str(exc))
async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult:
try:
response = await self._request("DELETE", f"{self._RESERVE_PATH}/{reservation_id}", json={"pharmacy_id": str(pharmacy_id)})
self._record_metric("cancel_reservation", "success")
return ConnectorResult(success=True, data=response.json())
except Exception as exc:
self._record_metric("cancel_reservation", "error")
return ConnectorResult(success=False, error=str(exc))
async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult:
try:
response = await self._request("GET", f"{self._PHARMACY_PATH}/{pharmacy_id}")
self._record_metric("pharmacy_information", "success")
return ConnectorResult(success=True, data=response.json())
except Exception as exc:
self._record_metric("pharmacy_information", "error")
return ConnectorResult(success=False, error=str(exc))
async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult:
try:
payload: dict[str, Any] = {"pharmacy_id": str(pharmacy_id), "strategy": strategy}
if since:
payload["since"] = since
response = await self._request("POST", self._SYNC_PATH, json=payload)
self._record_metric("synchronize", "success")
return ConnectorResult(success=True, data=response.json())
except Exception as exc:
self._record_metric("synchronize", "error")
return ConnectorResult(success=False, error=str(exc))
async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult:
try:
response = await self._request("GET", self._HEALTH_PATH)
data = response.json()
data["connector_type"] = self.connector_type
data["pharmacy_id"] = str(pharmacy_id)
data["circuit_breaker"] = self._circuit_breaker.state.value
self._record_metric("heartbeat", "success")
return ConnectorResult(success=True, data=data)
except Exception as exc:
self._record_metric("heartbeat", "error")
return ConnectorResult(success=False, error=str(exc))
@@ -0,0 +1,19 @@
from .context import ConnectorContext
from .auth import AuthHandler, BasicAuthHandler, BearerAuthHandler, APIKeyAuthHandler, OAuth2Handler
from .retry import RetryPolicy, with_retry
from .circuit_breaker import CircuitBreaker, CircuitState
from .base_sdk import SDKConnector
__all__ = [
"APIKeyAuthHandler",
"AuthHandler",
"BasicAuthHandler",
"BearerAuthHandler",
"CircuitBreaker",
"CircuitState",
"ConnectorContext",
"OAuth2Handler",
"RetryPolicy",
"SDKConnector",
"with_retry",
]
@@ -0,0 +1,123 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
import httpx
from src.connectors.sdk.context import ConnectorContext
from src.core.logging import get_logger
logger = get_logger("connector_auth")
class AuthHandler(ABC):
@abstractmethod
async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request:
...
@abstractmethod
async def refresh(self, context: ConnectorContext) -> bool:
return True
class BasicAuthHandler(AuthHandler):
def __init__(self, username_key: str = "username", password_key: str = "password") -> None:
self._username_key = username_key
self._password_key = password_key
async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request:
import base64
username = context.get_secret(self._username_key) or context.get_config(self._username_key, "")
password = context.get_secret(self._password_key) or context.get_config(self._password_key, "")
credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
request.headers["Authorization"] = f"Basic {credentials}"
return request
async def refresh(self, context: ConnectorContext) -> bool:
return True
class BearerAuthHandler(AuthHandler):
def __init__(self, token_key: str = "access_token") -> None:
self._token_key = token_key
self._token: str | None = None
async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request:
token = self._token or context.get_secret(self._token_key) or context.get_config(self._token_key, "")
if token:
request.headers["Authorization"] = f"Bearer {token}"
return request
async def refresh(self, context: ConnectorContext) -> bool:
self._token = None
return True
class APIKeyAuthHandler(AuthHandler):
def __init__(self, header_name: str = "X-API-Key", key_secret: str = "api_key") -> None:
self._header_name = header_name
self._key_secret = key_secret
async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request:
api_key = context.get_secret(self._key_secret) or context.get_config(self._key_secret, "")
if api_key:
request.headers[self._header_name] = api_key
return request
async def refresh(self, context: ConnectorContext) -> bool:
return True
class OAuth2Handler(AuthHandler):
def __init__(
self,
token_url_key: str = "token_url",
client_id_key: str = "client_id",
client_secret_key: str = "client_secret",
) -> None:
self._token_url_key = token_url_key
self._client_id_key = client_id_key
self._client_secret_key = client_secret_key
self._access_token: str | None = None
self._token_expires: float = 0
async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request:
import time
if not self._access_token or time.time() >= self._token_expires:
await self._fetch_token(context)
if self._access_token:
request.headers["Authorization"] = f"Bearer {self._access_token}"
return request
async def _fetch_token(self, context: ConnectorContext) -> None:
import time
token_url = context.require_config(self._token_url_key)
client_id = context.require_secret(self._client_id_key)
client_secret = context.require_secret(self._client_secret_key)
async with httpx.AsyncClient() as client:
resp = await client.post(
token_url,
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
resp.raise_for_status()
data = resp.json()
self._access_token = data["access_token"]
self._token_expires = time.time() + data.get("expires_in", 3600) - 30
logger.info("oauth2_token_acquired", extra={"token_url": token_url})
async def refresh(self, context: ConnectorContext) -> bool:
self._access_token = None
self._token_expires = 0
return True
@@ -0,0 +1,137 @@
from __future__ import annotations
from abc import abstractmethod
from typing import Any
from uuid import UUID
import httpx
from src.connectors.base import BaseInventoryConnector, ConnectorResult
from src.connectors.sdk.auth import AuthHandler, BearerAuthHandler
from src.connectors.sdk.circuit_breaker import CircuitBreaker
from src.connectors.sdk.context import ConnectorContext
from src.connectors.sdk.retry import RetryPolicy, with_retry
from src.core.exceptions import ConnectorException
from src.core.logging import get_logger
from src.core.metrics import CONNECTOR_OPERATIONS
logger = get_logger("sdk_connector")
class SDKConnector(BaseInventoryConnector):
connector_type: str = "sdk"
def __init__(self) -> None:
self._context: ConnectorContext | None = None
self._auth_handler: AuthHandler = BearerAuthHandler()
self._retry_policy: RetryPolicy = RetryPolicy()
self._circuit_breaker: CircuitBreaker = CircuitBreaker(name=self.connector_type)
self._http_client: httpx.AsyncClient | None = None
self._base_url: str = ""
self._timeout: float = 30.0
def configure(self, config: dict[str, Any]) -> None:
self._base_url = config.get("base_url", "")
self._timeout = config.get("timeout", 30.0)
auth_type = config.get("auth_type", "bearer")
if auth_type == "basic":
from src.connectors.sdk.auth import BasicAuthHandler
self._auth_handler = BasicAuthHandler()
elif auth_type == "api_key":
from src.connectors.sdk.auth import APIKeyAuthHandler
self._auth_handler = APIKeyAuthHandler(
header_name=config.get("api_key_header", "X-API-Key"),
key_secret=config.get("api_key_secret_key", "api_key"),
)
elif auth_type == "oauth2":
from src.connectors.sdk.auth import OAuth2Handler
self._auth_handler = OAuth2Handler()
cb_config = config.get("circuit_breaker", {})
if cb_config:
self._circuit_breaker = CircuitBreaker(
name=self.connector_type,
failure_threshold=cb_config.get("failure_threshold", 5),
recovery_timeout=cb_config.get("recovery_timeout", 30.0),
)
retry_config = config.get("retry", {})
if retry_config:
self._retry_policy = RetryPolicy(
max_attempts=retry_config.get("max_attempts", 3),
base_delay=retry_config.get("base_delay", 1.0),
max_delay=retry_config.get("max_delay", 30.0),
)
def set_context(self, context: ConnectorContext) -> None:
self._context = context
if context.config:
self.configure(context.config)
@property
def http_client(self) -> httpx.AsyncClient:
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(
base_url=self._base_url,
timeout=self._timeout,
)
return self._http_client
async def _request(
self,
method: str,
path: str,
*,
json: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
) -> httpx.Response:
if not self._context:
raise ConnectorException(self.connector_type, "Connector context not set. Call set_context() first.")
async def _do_request() -> httpx.Response:
request = self.http_client.build_request(method, path, json=json, params=params, headers=headers)
request = await self._auth_handler.apply(request, self._context)
response = await self.http_client.send(request)
response.raise_for_status()
return response
return await self._circuit_breaker.call(
with_retry,
_do_request,
policy=self._retry_policy,
connector_type=self.connector_type,
operation=f"{method}:{path}",
)
def _record_metric(self, operation: str, status: str) -> None:
CONNECTOR_OPERATIONS.labels(connector_type=self.connector_type, operation=operation, status=status).inc()
async def close(self) -> None:
if self._http_client and not self._http_client.is_closed:
await self._http_client.aclose()
self._http_client = None
@abstractmethod
async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult:
...
@abstractmethod
async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult:
...
@abstractmethod
async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult:
...
@abstractmethod
async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult:
...
@abstractmethod
async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult:
...
@abstractmethod
async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult:
...
@@ -0,0 +1,106 @@
from __future__ import annotations
import time
from enum import Enum
from typing import Any, Callable
from src.core.exceptions import ConnectorException, ServiceUnavailableException
from src.core.logging import get_logger
logger = get_logger("circuit_breaker")
class CircuitState(str, Enum):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
class CircuitBreaker:
def __init__(
self,
name: str,
failure_threshold: int = 5,
recovery_timeout: float = 30.0,
half_open_max_calls: int = 1,
) -> None:
self._name = name
self._failure_threshold = failure_threshold
self._recovery_timeout = recovery_timeout
self._half_open_max_calls = half_open_max_calls
self._state = CircuitState.CLOSED
self._failure_count = 0
self._success_count = 0
self._last_failure_time: float = 0
self._half_open_calls: int = 0
@property
def state(self) -> CircuitState:
if self._state == CircuitState.OPEN:
if time.monotonic() - self._last_failure_time >= self._recovery_timeout:
self._state = CircuitState.HALF_OPEN
self._half_open_calls = 0
logger.info("circuit_half_open", extra={"name": self._name})
return self._state
async def call(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
current_state = self.state
if current_state == CircuitState.OPEN:
raise ServiceUnavailableException(
f"circuit_breaker:{self._name}",
f"Circuit breaker '{self._name}' is open. Retry after {self._recovery_timeout}s.",
)
if current_state == CircuitState.HALF_OPEN:
if self._half_open_calls >= self._half_open_max_calls:
raise ServiceUnavailableException(
f"circuit_breaker:{self._name}",
f"Circuit breaker '{self._name}' is half-open with max concurrent calls reached.",
)
self._half_open_calls += 1
try:
result = await func(*args, **kwargs)
self._on_success()
return result
except Exception as exc:
self._on_failure(exc)
raise
def _on_success(self) -> None:
if self._state == CircuitState.HALF_OPEN:
self._state = CircuitState.CLOSED
logger.info("circuit_closed", extra={"name": self._name})
self._failure_count = 0
self._success_count += 1
def _on_failure(self, exc: Exception) -> None:
self._failure_count += 1
self._last_failure_time = time.monotonic()
self._success_count = 0
if self._state == CircuitState.HALF_OPEN:
self._state = CircuitState.OPEN
logger.warning("circuit_reopened", extra={"name": self._name, "error": str(exc)})
elif self._failure_count >= self._failure_threshold:
self._state = CircuitState.OPEN
logger.warning("circuit_opened", extra={"name": self._name, "failure_count": self._failure_count})
def reset(self) -> None:
self._state = CircuitState.CLOSED
self._failure_count = 0
self._success_count = 0
self._last_failure_time = 0
def get_status(self) -> dict[str, Any]:
return {
"name": self._name,
"state": self.state.value,
"failure_count": self._failure_count,
"success_count": self._success_count,
"failure_threshold": self._failure_threshold,
"recovery_timeout": self._recovery_timeout,
"last_failure_time": self._last_failure_time,
}
@@ -0,0 +1,42 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from uuid import UUID
@dataclass
class ConnectorContext:
connector_id: UUID
pharmacy_id: UUID
erp_system_id: UUID
connector_type: str
config: dict[str, Any] = field(default_factory=dict)
secrets: dict[str, str] = field(default_factory=dict)
_cache_prefix: str = field(default="connector", init=False, repr=False)
@property
def cache_key_prefix(self) -> str:
return f"{self._cache_prefix}:{self.connector_type}:{self.pharmacy_id}"
def cache_key(self, suffix: str) -> str:
return f"{self.cache_key_prefix}:{suffix}"
def get_config(self, key: str, default: Any = None) -> Any:
return self.config.get(key, default)
def get_secret(self, key: str) -> str | None:
return self.secrets.get(key)
def require_secret(self, key: str) -> str:
value = self.secrets.get(key)
if value is None:
raise ValueError(f"Required secret '{key}' not found in connector context")
return value
def require_config(self, key: str) -> Any:
value = self.config.get(key)
if value is None:
raise ValueError(f"Required config '{key}' not found in connector context")
return value
@@ -0,0 +1,87 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from functools import wraps
from typing import Any, Callable, TypeVar
from src.core.exceptions import ConnectorException
from src.core.logging import get_logger
logger = get_logger("connector_retry")
T = TypeVar("T")
DEFAULT_RETRYABLE_EXCEPTIONS = (ConnectionError, TimeoutError, OSError)
@dataclass
class RetryPolicy:
max_attempts: int = 3
base_delay: float = 1.0
max_delay: float = 30.0
exponential_base: float = 2.0
jitter: bool = True
retryable_exceptions: tuple[type[Exception], ...] = field(default_factory=lambda: DEFAULT_RETRYABLE_EXCEPTIONS)
def delay_for_attempt(self, attempt: int) -> float:
import random
delay = min(self.base_delay * (self.exponential_base ** (attempt - 1)), self.max_delay)
if self.jitter:
delay = delay * (0.5 + random.random() * 0.5)
return delay
def should_retry(self, exception: Exception) -> bool:
if isinstance(exception, ConnectorException):
return False
return isinstance(exception, self.retryable_exceptions)
async def with_retry(
func: Callable[..., Any],
*args: Any,
policy: RetryPolicy | None = None,
connector_type: str = "unknown",
operation: str = "unknown",
**kwargs: Any,
) -> Any:
p = policy or RetryPolicy()
last_exception: Exception | None = None
for attempt in range(1, p.max_attempts + 1):
try:
result = await func(*args, **kwargs)
if attempt > 1:
logger.info("retry_succeeded", extra={"connector_type": connector_type, "operation": operation, "attempt": attempt})
return result
except Exception as exc:
last_exception = exc
if not p.should_retry(exc) or attempt >= p.max_attempts:
break
delay = p.delay_for_attempt(attempt)
logger.warning(
"retry_attempt",
extra={
"connector_type": connector_type,
"operation": operation,
"attempt": attempt,
"delay_s": round(delay, 2),
"error": str(exc),
},
)
await asyncio.sleep(delay)
raise last_exception # type: ignore[misc]
def retry(policy: RetryPolicy | None = None):
p = policy or RetryPolicy()
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
connector_type = getattr(args[0], "connector_type", "unknown") if args else "unknown"
return await with_retry(func, *args, policy=p, connector_type=connector_type, operation=func.__name__, **kwargs)
return wrapper
return decorator
+77
View File
@@ -0,0 +1,77 @@
from datetime import datetime, timezone
class PIPException(Exception):
def __init__(self, message: str, code: str = "PIP_ERROR", status_code: int = 500, details: dict | None = None):
super().__init__(message)
self.message = message
self.code = code
self.status_code = status_code
self.details = details or {}
self.timestamp = datetime.now(timezone.utc).isoformat()
def to_dict(self) -> dict:
return {
"error": {
"code": self.code,
"message": self.message,
"details": self.details,
"timestamp": self.timestamp,
}
}
class NotFoundException(PIPException):
def __init__(self, resource: str, resource_id: str | None = None):
msg = f"{resource} not found"
if resource_id:
msg = f"{resource} '{resource_id}' not found"
super().__init__(message=msg, code="NOT_FOUND", status_code=404, details={"resource": resource, "resource_id": resource_id})
class ConflictException(PIPException):
def __init__(self, message: str, details: dict | None = None):
super().__init__(message=message, code="CONFLICT", status_code=409, details=details)
class ValidationException(PIPException):
def __init__(self, message: str, details: dict | None = None):
super().__init__(message=message, code="VALIDATION_ERROR", status_code=422, details=details)
class UnauthorizedException(PIPException):
def __init__(self, message: str = "Authentication required"):
super().__init__(message=message, code="UNAUTHORIZED", status_code=401)
class ForbiddenException(PIPException):
def __init__(self, message: str = "Insufficient permissions", required_role: str | None = None):
details = {}
if required_role:
details["required_role"] = required_role
super().__init__(message=message, code="FORBIDDEN", status_code=403, details=details)
class RateLimitException(PIPException):
def __init__(self, message: str = "Rate limit exceeded"):
super().__init__(message=message, code="RATE_LIMIT_EXCEEDED", status_code=429)
class ServiceUnavailableException(PIPException):
def __init__(self, service: str, message: str | None = None):
msg = message or f"Service '{service}' is unavailable"
super().__init__(message=msg, code="SERVICE_UNAVAILABLE", status_code=503, details={"service": service})
class ConnectorException(PIPException):
def __init__(self, connector_type: str, message: str, details: dict | None = None):
d = details or {}
d["connector_type"] = connector_type
super().__init__(message=message, code="CONNECTOR_ERROR", status_code=502, details=d)
class SyncException(PIPException):
def __init__(self, pharmacy_id: str, message: str, details: dict | None = None):
d = details or {}
d["pharmacy_id"] = pharmacy_id
super().__init__(message=message, code="SYNC_ERROR", status_code=502, details=d)
+56
View File
@@ -0,0 +1,56 @@
import logging
import sys
from typing import Any
import structlog
from src.infrastructure.config.settings import settings
def configure_logging() -> None:
shared_processors: list[Any] = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.UnicodeDecoder(),
]
if settings.LOG_JSON_FORMAT:
renderer = structlog.processors.JSONRenderer()
else:
renderer = structlog.dev.ConsoleRenderer()
structlog.configure(
processors=[
*shared_processors,
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
],
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
formatter = structlog.stdlib.ProcessorFormatter(
processors=[
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
renderer,
],
foreign_pre_chain=shared_processors,
)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(formatter)
root_logger = logging.getLogger()
root_logger.handlers.clear()
root_logger.addHandler(handler)
root_logger.setLevel(settings.LOG_LEVEL.upper())
for noisy in ("uvicorn.access", "sqlalchemy.engine", "httpx", "aioredis"):
logging.getLogger(noisy).setLevel(logging.WARNING)
def get_logger(name: str | None = None) -> Any:
return structlog.get_logger(name)
+94
View File
@@ -0,0 +1,94 @@
import time
from collections.abc import Callable
from typing import Any
from fastapi import Request, Response
from prometheus_client import Counter, Gauge, Histogram
REQUEST_COUNT = Counter(
"pip_http_requests_total",
"Total HTTP requests",
["method", "endpoint", "status_code"],
)
REQUEST_DURATION = Histogram(
"pip_http_request_duration_seconds",
"HTTP request duration in seconds",
["method", "endpoint"],
buckets=[0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0],
)
REQUESTS_IN_PROGRESS = Gauge(
"pip_http_requests_in_progress",
"HTTP requests currently in progress",
["method", "endpoint"],
)
DB_QUERY_DURATION = Histogram(
"pip_db_query_duration_seconds",
"Database query duration in seconds",
["operation"],
buckets=[0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0],
)
CONNECTOR_OPERATIONS = Counter(
"pip_connector_operations_total",
"Total connector operations",
["connector_type", "operation", "status"],
)
SYNC_JOBS = Counter(
"pip_sync_jobs_total",
"Total sync jobs",
["strategy", "status"],
)
SYNC_DURATION = Histogram(
"pip_sync_duration_seconds",
"Sync job duration in seconds",
["strategy"],
buckets=[1.0, 5.0, 10.0, 30.0, 60.0, 300.0, 600.0, 1800.0],
)
ACTIVE_CONNECTIONS = Gauge(
"pip_active_connections",
"Active infrastructure connections",
["type"],
)
CACHE_OPERATIONS = Counter(
"pip_cache_operations_total",
"Cache operations",
["operation", "result"],
)
RESERVATION_OPERATIONS = Counter(
"pip_reservation_operations_total",
"Reservation operations",
["operation", "status"],
)
async def metrics_middleware(request: Request, call_next: Callable) -> Response:
if request.url.path == "/metrics" or request.url.path.startswith("/docs") or request.url.path.startswith("/openapi"):
return await call_next(request)
method = request.method
endpoint = request.url.path
REQUESTS_IN_PROGRESS.labels(method=method, endpoint=endpoint).inc()
start_time = time.perf_counter()
try:
response = await call_next(request)
status_code = response.status_code
except Exception:
status_code = 500
raise
finally:
duration = time.perf_counter() - start_time
REQUEST_COUNT.labels(method=method, endpoint=endpoint, status_code=status_code).inc()
REQUEST_DURATION.labels(method=method, endpoint=endpoint).observe(duration)
REQUESTS_IN_PROGRESS.labels(method=method, endpoint=endpoint).dec()
return response
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from src.infrastructure.config.settings import settings
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncEngine
def setup_tracing(app: Any = None, engine: AsyncEngine | None = None) -> None:
if not settings.OTEL_TRACES_ENABLED:
return
resource = Resource.create({"service.name": settings.OTEL_SERVICE_NAME})
provider = TracerProvider(resource=resource)
otlp_exporter = OTLPSpanExporter(endpoint=settings.OTEL_EXPORTER_OTLP_ENDPOINT)
provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
trace.set_tracer_provider(provider)
if engine is not None:
sync_engine = engine.sync_engine if hasattr(engine, "sync_engine") else engine
SQLAlchemyInstrumentor().instrument(engine=sync_engine)
RedisInstrumentor().instrument()
if app is not None:
FastAPIInstrumentor.instrument_app(app)
def get_tracer(name: str = "pip-platform") -> trace.Tracer:
return trace.get_tracer(name)
@@ -0,0 +1,51 @@
from .entities import (
APIClientEntity,
APIKeyEntity,
AuditLogEntity,
ConnectorConfigEntity,
ConnectorEntity,
ConnectorStatus,
ConnectorVersionEntity,
ERPType,
ERPSystemEntity,
InventoryHistoryEntity,
InventoryRecordEntity,
MedicationCatalogEntryEntity,
MedicationEntity,
PharmacyEntity,
PharmacyStatus,
ReservationEntity,
ReservationStatus,
SyncJobEntity,
SyncJobStatus,
SyncLogEntity,
SyncStrategy,
TechnicalUserEntity,
UserRole,
)
__all__ = [
"APIClientEntity",
"APIKeyEntity",
"AuditLogEntity",
"ConnectorConfigEntity",
"ConnectorEntity",
"ConnectorStatus",
"ConnectorVersionEntity",
"ERPType",
"ERPSystemEntity",
"InventoryHistoryEntity",
"InventoryRecordEntity",
"MedicationCatalogEntryEntity",
"MedicationEntity",
"PharmacyEntity",
"PharmacyStatus",
"ReservationEntity",
"ReservationStatus",
"SyncJobEntity",
"SyncJobStatus",
"SyncLogEntity",
"SyncStrategy",
"TechnicalUserEntity",
"UserRole",
]
@@ -0,0 +1,317 @@
import uuid
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, Field
class PharmacyStatus(str, Enum):
ACTIVE = "active"
INACTIVE = "inactive"
SUSPENDED = "suspended"
PENDING = "pending"
class ERPType(str, Enum):
FARMATIC = "farmatic"
NIXFARMA = "nixfarma"
UNYCOP = "unycop"
CUSTOM_REST = "custom_rest"
CUSTOM_SOAP = "custom_soap"
CSV = "csv"
FTP = "ftp"
SQL = "sql"
MOCK = "mock"
class ConnectorStatus(str, Enum):
ACTIVE = "active"
INACTIVE = "inactive"
ERROR = "error"
PENDING = "pending"
UPDATING = "updating"
class SyncStrategy(str, Enum):
FULL = "full"
INCREMENTAL = "incremental"
EVENT_DRIVEN = "event_driven"
SCHEDULED = "scheduled"
HYBRID = "hybrid"
class SyncJobStatus(str, Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class ReservationStatus(str, Enum):
PENDING = "pending"
CONFIRMED = "confirmed"
CANCELLED = "cancelled"
EXPIRED = "expired"
class UserRole(str, Enum):
SUPERADMIN = "superadmin"
ADMIN = "admin"
OPERATOR = "operator"
VIEWER = "viewer"
API_CLIENT = "api_client"
class PharmacyEntity(BaseModel):
id: uuid.UUID | None = None
code: str
name: str
address: str | None = None
city: str | None = None
province: str | None = None
postal_code: str | None = None
country: str = "ES"
latitude: float | None = None
longitude: float | None = None
phone: str | None = None
email: str | None = None
opening_hours: dict | None = None
status: PharmacyStatus = PharmacyStatus.PENDING
erp_type: ERPType | None = None
erp_version: str | None = None
connector_id: uuid.UUID | None = None
sync_strategy: SyncStrategy = SyncStrategy.INCREMENTAL
sync_interval_minutes: int = 30
last_sync_at: datetime | None = None
last_sync_status: SyncJobStatus | None = None
metadata_: dict | None = Field(default=None, alias="metadata")
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = {"populate_by_name": True}
class ERPSystemEntity(BaseModel):
id: uuid.UUID | None = None
name: str
erp_type: ERPType
version: str | None = None
base_url: str | None = None
auth_config: dict | None = None
status: ConnectorStatus = ConnectorStatus.PENDING
metadata_: dict | None = Field(default=None, alias="metadata")
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = {"populate_by_name": True}
class ConnectorEntity(BaseModel):
id: uuid.UUID | None = None
erp_system_id: uuid.UUID
pharmacy_id: uuid.UUID
name: str
connector_type: ERPType
version: str = "1.0.0"
config: dict | None = None
status: ConnectorStatus = ConnectorStatus.PENDING
last_heartbeat_at: datetime | None = None
last_error: str | None = None
metadata_: dict | None = Field(default=None, alias="metadata")
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = {"populate_by_name": True}
class ConnectorVersionEntity(BaseModel):
id: uuid.UUID | None = None
connector_id: uuid.UUID | None = None
connector_type: ERPType
version: str
changelog: str | None = None
download_url: str | None = None
checksum: str | None = None
is_latest: bool = False
is_required: bool = False
released_at: datetime | None = None
created_at: datetime | None = None
class ConnectorConfigEntity(BaseModel):
id: uuid.UUID | None = None
connector_id: uuid.UUID
key: str
value: str
encrypted: bool = False
description: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class MedicationEntity(BaseModel):
id: uuid.UUID | None = None
nregistro: str
name: str
active_ingredient: str | None = None
dosage: str | None = None
form: str | None = None
atc_code: str | None = None
prescription_required: bool = False
manufacturer: str | None = None
metadata_: dict | None = Field(default=None, alias="metadata")
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = {"populate_by_name": True}
class MedicationCatalogEntryEntity(BaseModel):
id: uuid.UUID | None = None
pharmacy_id: uuid.UUID
medication_id: uuid.UUID
price: float | None = None
stock: int = 0
is_available: bool = False
last_verified_at: datetime | None = None
metadata_: dict | None = Field(default=None, alias="metadata")
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = {"populate_by_name": True}
class InventoryRecordEntity(BaseModel):
id: uuid.UUID | None = None
pharmacy_id: uuid.UUID
medication_id: uuid.UUID
stock: int = 0
reserved_stock: int = 0
available_stock: int = 0
min_stock: int | None = None
max_stock: int | None = None
last_updated_at: datetime | None = None
source: str | None = None
batch_id: str | None = None
metadata_: dict | None = Field(default=None, alias="metadata")
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = {"populate_by_name": True}
class InventoryHistoryEntity(BaseModel):
id: uuid.UUID | None = None
inventory_record_id: uuid.UUID | None = None
pharmacy_id: uuid.UUID
medication_id: uuid.UUID
previous_stock: int
new_stock: int
delta: int
reason: str | None = None
source: str | None = None
created_at: datetime | None = None
class ReservationEntity(BaseModel):
id: uuid.UUID | None = None
pharmacy_id: uuid.UUID
medication_id: uuid.UUID
user_id: uuid.UUID | None = None
external_reference: str | None = None
quantity: int
status: ReservationStatus = ReservationStatus.PENDING
expires_at: datetime | None = None
confirmed_at: datetime | None = None
cancelled_at: datetime | None = None
idempotency_key: str | None = None
metadata_: dict | None = Field(default=None, alias="metadata")
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = {"populate_by_name": True}
class SyncJobEntity(BaseModel):
id: uuid.UUID | None = None
pharmacy_id: uuid.UUID
connector_id: uuid.UUID
strategy: SyncStrategy
status: SyncJobStatus = SyncJobStatus.PENDING
started_at: datetime | None = None
completed_at: datetime | None = None
items_total: int | None = None
items_processed: int | None = None
items_failed: int | None = None
error_message: str | None = None
metadata_: dict | None = Field(default=None, alias="metadata")
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = {"populate_by_name": True}
class SyncLogEntity(BaseModel):
id: uuid.UUID | None = None
sync_job_id: uuid.UUID
level: str = "INFO"
message: str
details: dict | None = None
created_at: datetime | None = None
class AuditLogEntity(BaseModel):
id: uuid.UUID | None = None
actor_id: uuid.UUID | None = None
actor_type: str | None = None
action: str
resource_type: str
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
created_at: datetime | None = None
class TechnicalUserEntity(BaseModel):
id: uuid.UUID | None = None
username: str
email: str | None = None
full_name: str | None = None
role: UserRole = UserRole.VIEWER
is_active: bool = True
scopes: list[str] = Field(default_factory=list)
last_login_at: datetime | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class APIClientEntity(BaseModel):
id: uuid.UUID | None = None
name: str
description: str | None = None
owner_id: uuid.UUID | None = None
is_active: bool = True
scopes: list[str] = Field(default_factory=list)
rate_limit: int | None = None
allowed_ips: list[str] = Field(default_factory=list)
metadata_: dict | None = Field(default=None, alias="metadata")
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = {"populate_by_name": True}
class APIKeyEntity(BaseModel):
id: uuid.UUID | None = None
api_client_id: uuid.UUID
key_prefix: str
key_hash: bytes
name: str
is_active: bool = True
expires_at: datetime | None = None
last_used_at: datetime | None = None
created_at: datetime | None = None
@@ -0,0 +1,35 @@
from .repositories import (
IAPIClientRepository,
IAPIKeyRepository,
IAuditLogRepository,
IConnectorConfigRepository,
IConnectorRepository,
IConnectorVersionRepository,
IERPSystemRepository,
IInventoryRepository,
IMedicationCatalogRepository,
IMedicationRepository,
IPharmacyRepository,
IReservationRepository,
ISyncJobRepository,
ISyncLogRepository,
ITechnicalUserRepository,
)
__all__ = [
"IAPIClientRepository",
"IAPIKeyRepository",
"IAuditLogRepository",
"IConnectorConfigRepository",
"IConnectorRepository",
"IConnectorVersionRepository",
"IERPSystemRepository",
"IInventoryRepository",
"IMedicationCatalogRepository",
"IMedicationRepository",
"IPharmacyRepository",
"IReservationRepository",
"ISyncJobRepository",
"ISyncLogRepository",
"ITechnicalUserRepository",
]
@@ -0,0 +1,249 @@
import uuid
from abc import ABC, abstractmethod
from collections.abc import Sequence
from typing import Any
from src.domain.entities import (
APIClientEntity,
APIKeyEntity,
AuditLogEntity,
ConnectorConfigEntity,
ConnectorEntity,
ConnectorVersionEntity,
ERPSystemEntity,
InventoryHistoryEntity,
InventoryRecordEntity,
MedicationCatalogEntryEntity,
MedicationEntity,
PharmacyEntity,
ReservationEntity,
SyncJobEntity,
SyncLogEntity,
TechnicalUserEntity,
)
class IPharmacyRepository(ABC):
@abstractmethod
async def get_by_id(self, pharmacy_id: uuid.UUID) -> PharmacyEntity | None: ...
@abstractmethod
async def get_by_code(self, code: str) -> PharmacyEntity | None: ...
@abstractmethod
async def list_pharmacies(
self,
*,
status: str | None = None,
erp_type: str | None = None,
offset: int = 0,
limit: int = 50,
) -> tuple[Sequence[PharmacyEntity], int]: ...
@abstractmethod
async def create(self, entity: PharmacyEntity) -> PharmacyEntity: ...
@abstractmethod
async def update(self, entity: PharmacyEntity) -> PharmacyEntity: ...
@abstractmethod
async def delete(self, pharmacy_id: uuid.UUID) -> bool: ...
class IERPSystemRepository(ABC):
@abstractmethod
async def get_by_id(self, erp_id: uuid.UUID) -> ERPSystemEntity | None: ...
@abstractmethod
async def list_erp_systems(self, *, erp_type: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[ERPSystemEntity], int]: ...
@abstractmethod
async def create(self, entity: ERPSystemEntity) -> ERPSystemEntity: ...
@abstractmethod
async def update(self, entity: ERPSystemEntity) -> ERPSystemEntity: ...
@abstractmethod
async def delete(self, erp_id: uuid.UUID) -> bool: ...
class IConnectorRepository(ABC):
@abstractmethod
async def get_by_id(self, connector_id: uuid.UUID) -> ConnectorEntity | None: ...
@abstractmethod
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]: ...
@abstractmethod
async def create(self, entity: ConnectorEntity) -> ConnectorEntity: ...
@abstractmethod
async def update(self, entity: ConnectorEntity) -> ConnectorEntity: ...
@abstractmethod
async def delete(self, connector_id: uuid.UUID) -> bool: ...
class IConnectorVersionRepository(ABC):
@abstractmethod
async def get_latest(self, connector_type: str) -> ConnectorVersionEntity | None: ...
@abstractmethod
async def list_versions(self, *, connector_type: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[ConnectorVersionEntity], int]: ...
@abstractmethod
async def create(self, entity: ConnectorVersionEntity) -> ConnectorVersionEntity: ...
class IConnectorConfigRepository(ABC):
@abstractmethod
async def list_by_connector(self, connector_id: uuid.UUID) -> Sequence[ConnectorConfigEntity]: ...
@abstractmethod
async def get_by_key(self, connector_id: uuid.UUID, key: str) -> ConnectorConfigEntity | None: ...
@abstractmethod
async def upsert(self, entity: ConnectorConfigEntity) -> ConnectorConfigEntity: ...
@abstractmethod
async def delete(self, config_id: uuid.UUID) -> bool: ...
class IMedicationRepository(ABC):
@abstractmethod
async def get_by_id(self, medication_id: uuid.UUID) -> MedicationEntity | None: ...
@abstractmethod
async def get_by_nregistro(self, nregistro: str) -> MedicationEntity | None: ...
@abstractmethod
async def search(self, query: str, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[MedicationEntity], int]: ...
@abstractmethod
async def create(self, entity: MedicationEntity) -> MedicationEntity: ...
@abstractmethod
async def update(self, entity: MedicationEntity) -> MedicationEntity: ...
@abstractmethod
async def delete(self, medication_id: uuid.UUID) -> bool: ...
class IMedicationCatalogRepository(ABC):
@abstractmethod
async def get_entry(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID) -> MedicationCatalogEntryEntity | None: ...
@abstractmethod
async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[MedicationCatalogEntryEntity], int]: ...
@abstractmethod
async def upsert(self, entity: MedicationCatalogEntryEntity) -> MedicationCatalogEntryEntity: ...
class IInventoryRepository(ABC):
@abstractmethod
async def get_record(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID) -> InventoryRecordEntity | None: ...
@abstractmethod
async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[InventoryRecordEntity], int]: ...
@abstractmethod
async def upsert(self, entity: InventoryRecordEntity) -> InventoryRecordEntity: ...
@abstractmethod
async def add_history(self, entity: InventoryHistoryEntity) -> InventoryHistoryEntity: ...
@abstractmethod
async def get_history(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> Sequence[InventoryHistoryEntity]: ...
class IReservationRepository(ABC):
@abstractmethod
async def get_by_id(self, reservation_id: uuid.UUID) -> ReservationEntity | None: ...
@abstractmethod
async def get_by_idempotency_key(self, key: str) -> ReservationEntity | None: ...
@abstractmethod
async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[ReservationEntity], int]: ...
@abstractmethod
async def create(self, entity: ReservationEntity) -> ReservationEntity: ...
@abstractmethod
async def update(self, entity: ReservationEntity) -> ReservationEntity: ...
class ISyncJobRepository(ABC):
@abstractmethod
async def get_by_id(self, job_id: uuid.UUID) -> SyncJobEntity | None: ...
@abstractmethod
async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[SyncJobEntity], int]: ...
@abstractmethod
async def create(self, entity: SyncJobEntity) -> SyncJobEntity: ...
@abstractmethod
async def update(self, entity: SyncJobEntity) -> SyncJobEntity: ...
class ISyncLogRepository(ABC):
@abstractmethod
async def add(self, entity: SyncLogEntity) -> SyncLogEntity: ...
@abstractmethod
async def list_by_job(self, job_id: uuid.UUID, *, level: str | None = None, offset: int = 0, limit: int = 100) -> Sequence[SyncLogEntity]: ...
class IAuditLogRepository(ABC):
@abstractmethod
async def add(self, entity: AuditLogEntity) -> AuditLogEntity: ...
@abstractmethod
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]: ...
class ITechnicalUserRepository(ABC):
@abstractmethod
async def get_by_id(self, user_id: uuid.UUID) -> TechnicalUserEntity | None: ...
@abstractmethod
async def get_by_username(self, username: str) -> TechnicalUserEntity | None: ...
@abstractmethod
async def list_users(self, *, role: str | None = None, is_active: bool | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[TechnicalUserEntity], int]: ...
@abstractmethod
async def create(self, entity: TechnicalUserEntity, password_hash: str) -> TechnicalUserEntity: ...
@abstractmethod
async def update(self, entity: TechnicalUserEntity) -> TechnicalUserEntity: ...
class IAPIClientRepository(ABC):
@abstractmethod
async def get_by_id(self, client_id: uuid.UUID) -> APIClientEntity | None: ...
@abstractmethod
async def list_clients(self, *, is_active: bool | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[APIClientEntity], int]: ...
@abstractmethod
async def create(self, entity: APIClientEntity) -> APIClientEntity: ...
@abstractmethod
async def update(self, entity: APIClientEntity) -> APIClientEntity: ...
class IAPIKeyRepository(ABC):
@abstractmethod
async def get_by_prefix(self, key_prefix: str) -> APIKeyEntity | None: ...
@abstractmethod
async def list_by_client(self, api_client_id: uuid.UUID) -> Sequence[APIKeyEntity]: ...
@abstractmethod
async def create(self, entity: APIKeyEntity) -> APIKeyEntity: ...
@abstractmethod
async def update_last_used(self, key_id: uuid.UUID) -> None: ...
@@ -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()
+78
View File
@@ -0,0 +1,78 @@
from contextlib import asynccontextmanager
from collections.abc import AsyncGenerator
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from prometheus_client import make_asgi_app
from src.core.exceptions import PIPException
from src.core.logging import configure_logging
from src.core.metrics import metrics_middleware
from src.core.tracing import setup_tracing
from src.infrastructure.cache.redis_cache import redis_cache
from src.infrastructure.config.settings import settings
from src.infrastructure.messaging.rabbitmq import rabbitmq_client
from src.api.middleware.audit import AuditMiddleware
from src.api.middleware.logging import RequestLoggingMiddleware
from src.api.middleware.rate_limit import RateLimitMiddleware
from src.api.v1.router import v1_router
from src.infrastructure.database.session import engine
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
configure_logging()
setup_tracing(app, engine=engine)
await redis_cache.connect()
await rabbitmq_client.connect()
yield
await rabbitmq_client.disconnect()
await redis_cache.disconnect()
await engine.dispose()
def create_app() -> FastAPI:
application = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
description="Pharmacy Integration Platform — unified API between pharmacy ERPs and external applications",
docs_url="/docs",
redoc_url="/redoc",
openapi_url="/openapi.json",
lifespan=lifespan,
)
application.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=settings.CORS_ALLOW_CREDENTIALS,
allow_methods=["*"],
allow_headers=["*"],
)
application.add_middleware(RateLimitMiddleware)
application.add_middleware(AuditMiddleware)
application.add_middleware(RequestLoggingMiddleware)
application.middleware("http")(metrics_middleware)
application.include_router(v1_router)
metrics_app = make_asgi_app()
application.mount("/metrics", metrics_app)
@application.exception_handler(PIPException)
async def pip_exception_handler(request: Request, exc: PIPException) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content=exc.to_dict())
@application.exception_handler(Exception)
async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
return JSONResponse(status_code=500, content={"error": {"code": "INTERNAL_ERROR", "message": "Internal server error"}})
return application
app = create_app()
@@ -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)})