Restructure with Turborepo
This commit is contained in:
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
+71
@@ -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
|
||||
+82
@@ -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]
|
||||
+90
@@ -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]
|
||||
+88
@@ -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()
|
||||
@@ -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)})
|
||||
Reference in New Issue
Block a user