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

This commit is contained in:
Antoni Nuñez Romeu
2026-07-06 15:51:53 +02:00
parent f66cafbbc3
commit 190b3d163d
277 changed files with 53253 additions and 0 deletions
@@ -0,0 +1,91 @@
from __future__ import annotations
import time
import uuid
from typing import Any
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response
from src.core.logging import get_logger
from src.domain.entities import AuditLogEntity
from src.domain.interfaces import IAuditLogRepository
from src.infrastructure.database.session import async_session_factory
logger = get_logger("audit_middleware")
WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
IGNORED_PATHS = {"/docs", "/redoc", "/openapi.json", "/metrics", "/api/v1/system/health"}
class AuditMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
response = await call_next(request)
if request.method in WRITE_METHODS and not self._should_ignore(request.url.path):
try:
await self._record_audit(request, response)
except Exception as exc:
logger.warning("audit_log_failed", extra={"error": str(exc)})
return response
def _should_ignore(self, path: str) -> bool:
return any(path.startswith(p) for p in IGNORED_PATHS)
async def _record_audit(self, request: Request, response: Response) -> None:
actor_id = None
actor_type = "anonymous"
if hasattr(request.state, "user") and request.state.user:
user = request.state.user
try:
actor_id = uuid.UUID(user.subject)
except (ValueError, TypeError):
pass
actor_type = user.role.value if hasattr(user.role, "value") else str(user.role)
resource_type = self._extract_resource_type(request.url.path)
action = f"{request.method.lower()}_{resource_type}"
resource_id = self._extract_resource_id(request.url.path)
ip_address = request.client.host if request.client else None
user_agent = request.headers.get("user-agent", "")
request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
async with async_session_factory() as session:
try:
from src.infrastructure.database.repositories import AuditLogRepository
repo = AuditLogRepository(session)
entity = AuditLogEntity(
actor_id=actor_id,
actor_type=actor_type,
action=action,
resource_type=resource_type,
resource_id=resource_id,
ip_address=ip_address,
user_agent=user_agent,
request_id=request_id,
)
await repo.add(entity)
await session.commit()
except Exception as exc:
logger.warning("audit_db_write_failed", extra={"error": str(exc)})
def _extract_resource_type(self, path: str) -> str:
parts = path.strip("/").split("/")
if len(parts) >= 3:
return parts[2]
if len(parts) >= 1:
return parts[-1]
return "unknown"
def _extract_resource_id(self, path: str) -> str | None:
parts = path.strip("/").split("/")
for part in reversed(parts[3:] if len(parts) > 3 else parts):
try:
uuid.UUID(part)
return part
except ValueError:
continue
return None
@@ -0,0 +1,36 @@
import time
import uuid
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response
from src.core.logging import get_logger
logger = get_logger("request_logging")
class RequestLoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
request.state.request_id = request_id
start = time.perf_counter()
response = await call_next(request)
duration_ms = (time.perf_counter() - start) * 1000
logger.info(
"request_completed",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"status_code": response.status_code,
"duration_ms": round(duration_ms, 2),
"client_ip": request.client.host if request.client else None,
"user_agent": request.headers.get("user-agent", ""),
},
)
response.headers["X-Request-ID"] = request_id
return response
@@ -0,0 +1,53 @@
import time
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from src.infrastructure.config.settings import settings
class RateLimitMiddleware(BaseHTTPMiddleware):
def __init__(self, app, requests_per_minute: int | None = None):
super().__init__(app)
self._rpm = requests_per_minute or settings.RATE_LIMIT_PER_MINUTE
self._clients: dict[str, list[float]] = {}
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
if request.method == "OPTIONS":
return await call_next(request)
path = request.url.path
if path.startswith("/docs") or path.startswith("/redoc") or path.startswith("/openapi") or path.startswith("/metrics"):
return await call_next(request)
client_key = self._get_client_key(request)
now = time.monotonic()
window = self._clients.setdefault(client_key, [])
window[:] = [t for t in window if now - t < 60]
if len(window) >= self._rpm:
return Response(
content='{"error":{"code":"RATE_LIMIT_EXCEEDED","message":"Rate limit exceeded. Try again later."}}',
status_code=429,
media_type="application/json",
headers={"Retry-After": "60"},
)
window.append(now)
response = await call_next(request)
remaining = self._rpm - len(window)
response.headers["X-RateLimit-Limit"] = str(self._rpm)
response.headers["X-RateLimit-Remaining"] = str(max(0, remaining))
response.headers["X-RateLimit-Reset"] = str(int(time.time()) + 60)
return response
@staticmethod
def _get_client_key(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client:
return request.client.host
return "unknown"
@@ -0,0 +1,107 @@
import uuid
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer
from src.core.exceptions import ForbiddenException, UnauthorizedException
from src.domain.entities import UserRole
from src.infrastructure.database.session import AsyncSession, get_db_session
from src.services.auth.auth_service import AuthService
from src.services.auth.security import RBACGuard, TokenData, decode_token, validate_token_type
from src.infrastructure.database.repositories import APIClientRepository, APIKeyRepository
from src.services.auth.security import verify_api_key
_bearer_scheme = HTTPBearer(auto_error=False)
_api_key_scheme = APIKeyHeader(name="X-API-Key", auto_error=False)
async def get_auth_service(session: AsyncSession = Depends(get_db_session)) -> AuthService:
return AuthService(session)
async def get_api_key_user(
api_key: str | None = Depends(_api_key_scheme),
session: AsyncSession = Depends(get_db_session),
) -> TokenData | None:
if not api_key:
return None
prefix = api_key[:8]
key_repo = APIKeyRepository(session)
key_entity = await key_repo.get_by_prefix(prefix)
if not key_entity or not key_entity.is_active:
return None
from datetime import datetime, timezone
if key_entity.expires_at and key_entity.expires_at < datetime.now(timezone.utc):
return None
if not verify_api_key(api_key, key_entity.key_hash):
return None
client_repo = APIClientRepository(session)
client = await client_repo.get_by_id(key_entity.api_client_id)
if not client or not client.is_active:
return None
await key_repo.update_last_used(key_entity.id)
return TokenData({
"sub": str(client.id),
"role": "api_client",
"scopes": client.scopes or [],
"iss": "pip-platform",
"type": "access",
"jti": str(key_entity.id),
})
async def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
api_key_user: TokenData | None = Depends(get_api_key_user),
) -> TokenData:
if credentials:
try:
payload = decode_token(credentials.credentials)
validate_token_type(payload, expected_type="access")
user = TokenData(payload)
request.state.user = user
return user
except UnauthorizedException:
raise
except Exception as e:
raise UnauthorizedException(f"Invalid token: {e}") from e
if api_key_user:
request.state.user = api_key_user
return api_key_user
raise UnauthorizedException("Authentication required. Provide Bearer token or X-API-Key header.")
def get_current_user_optional(
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
) -> TokenData | None:
if not credentials:
return None
try:
payload = decode_token(credentials.credentials)
validate_token_type(payload, expected_type="access")
return TokenData(payload)
except Exception:
return None
def require_role(role: UserRole):
def _checker(current_user: TokenData = Depends(get_current_user)) -> TokenData:
RBACGuard.require_role(current_user, role)
return current_user
return _checker
def require_scope(scope: str):
def _checker(current_user: TokenData = Depends(get_current_user)) -> TokenData:
RBACGuard.require_scope(current_user, scope)
return current_user
return _checker
@@ -0,0 +1,157 @@
from datetime import datetime
from uuid import UUID
import hashlib
import secrets
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.exceptions import ConflictException, NotFoundException
from src.domain.entities import APIClientEntity, APIKeyEntity
from src.infrastructure.database.repositories import APIClientRepository, APIKeyRepository
from src.infrastructure.database.session import get_db_session
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.domain.entities import UserRole
from src.services.auth.security import TokenData, generate_api_key
router = APIRouter(prefix="/api-clients", tags=["API Clients"])
class APIClientCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
description: str | None = None
scopes: list[str] = Field(default_factory=lambda: ["pharmacies:read", "medications:read", "inventory:read"])
rate_limit: int | None = None
allowed_ips: list[str] = Field(default_factory=list)
class APIClientResponse(BaseModel):
id: UUID
name: str
description: str | None
owner_id: UUID | None
is_active: bool
scopes: list[str]
rate_limit: int | None
allowed_ips: list[str]
created_at: datetime | None
updated_at: datetime | None
class APIKeyCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
expires_at: datetime | None = None
class APIKeyResponse(BaseModel):
id: UUID
name: str
key_prefix: str
is_active: bool
expires_at: datetime | None
last_used_at: datetime | None
created_at: datetime | None
class APIKeyCreateResponse(BaseModel):
id: UUID
name: str
key: str
key_prefix: str
expires_at: datetime | None
class APIClientListResponse(BaseModel):
items: list[APIClientResponse]
total: int
offset: int
limit: int
@router.get("", response_model=APIClientListResponse)
async def list_api_clients(
is_active: bool | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = APIClientRepository(session)
entities, total = await repo.list_clients(is_active=is_active, offset=offset, limit=limit)
items = [
APIClientResponse(
id=e.id, name=e.name, description=e.description, owner_id=e.owner_id,
is_active=e.is_active, scopes=e.scopes, rate_limit=e.rate_limit,
allowed_ips=e.allowed_ips, created_at=e.created_at, updated_at=e.updated_at,
)
for e in entities
]
return APIClientListResponse(items=items, total=total, offset=offset, limit=limit)
@router.post("", response_model=APIClientResponse, status_code=201)
async def create_api_client(
body: APIClientCreateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = APIClientRepository(session)
entity = APIClientEntity(
name=body.name, description=body.description,
owner_id=UUID(current_user.subject), is_active=True,
scopes=body.scopes, rate_limit=body.rate_limit,
allowed_ips=body.allowed_ips,
)
created = await repo.create(entity)
return APIClientResponse(
id=created.id, name=created.name, description=created.description,
owner_id=created.owner_id, is_active=created.is_active,
scopes=created.scopes, rate_limit=created.rate_limit,
allowed_ips=created.allowed_ips, created_at=created.created_at,
updated_at=created.updated_at,
)
@router.post("/{client_id}/keys", response_model=APIKeyCreateResponse, status_code=201)
async def create_api_key(
client_id: UUID,
body: APIKeyCreateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
client_repo = APIClientRepository(session)
client = await client_repo.get_by_id(client_id)
if not client:
raise NotFoundException("API Client", str(client_id))
raw_key, prefix, key_hash = generate_api_key()
key_repo = APIKeyRepository(session)
key_entity = APIKeyEntity(
api_client_id=client_id, key_prefix=prefix,
key_hash=key_hash, name=body.name,
is_active=True, expires_at=body.expires_at,
)
created = await key_repo.create(key_entity)
return APIKeyCreateResponse(
id=created.id, name=created.name, key=raw_key,
key_prefix=created.key_prefix, expires_at=created.expires_at,
)
@router.get("/{client_id}/keys", response_model=list[APIKeyResponse])
async def list_api_keys(
client_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
key_repo = APIKeyRepository(session)
keys = await key_repo.list_by_client(client_id)
return [
APIKeyResponse(
id=k.id, name=k.name, key_prefix=k.key_prefix,
is_active=k.is_active, expires_at=k.expires_at,
last_used_at=k.last_used_at, created_at=k.created_at,
)
for k in keys
]
@@ -0,0 +1,70 @@
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.domain.entities import UserRole
from src.infrastructure.database.repositories import AuditLogRepository
from src.infrastructure.database.session import get_db_session
from src.services.auth.security import TokenData
from src.services.audit.audit_service import AuditService
router = APIRouter(prefix="/audit", tags=["Audit"])
class AuditLogResponse(BaseModel):
id: UUID
actor_id: UUID | None
actor_type: str | None
action: str
resource_type: str
resource_id: str | None
old_value: dict | None
new_value: dict | None
ip_address: str | None
user_agent: str | None
request_id: str | None
created_at: datetime | None
class AuditLogListResponse(BaseModel):
items: list[AuditLogResponse]
total: int
offset: int
limit: int
def _get_service(session: AsyncSession) -> AuditService:
return AuditService(AuditLogRepository(session))
@router.get("/logs", response_model=AuditLogListResponse)
async def list_audit_logs(
actor_id: UUID | None = None,
action: str | None = None,
resource_type: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
entities, total = await service.list_logs(
actor_id=actor_id, action=action, resource_type=resource_type,
offset=offset, limit=limit,
)
items = [
AuditLogResponse(
id=e.id, actor_id=e.actor_id, actor_type=e.actor_type,
action=e.action, resource_type=e.resource_type,
resource_id=e.resource_id, old_value=e.old_value,
new_value=e.new_value, ip_address=e.ip_address,
user_agent=e.user_agent, request_id=e.request_id,
created_at=e.created_at,
)
for e in entities
]
return AuditLogListResponse(items=items, total=total, offset=offset, limit=limit)
@@ -0,0 +1,116 @@
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.entities import UserRole
from src.infrastructure.database.session import get_db_session
from src.services.auth.auth_service import AuthService
from src.api.v1.dependencies.auth import get_auth_service, get_current_user, require_role
from src.services.auth.security import TokenData
router = APIRouter(prefix="/auth", tags=["Authentication"])
class LoginRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=100)
password: str = Field(..., min_length=8)
class RegisterRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=100, pattern=r"^[A-Za-z0-9_]+$")
password: str = Field(..., min_length=8)
email: EmailStr | None = None
full_name: str | None = None
role: UserRole = UserRole.VIEWER
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
class RefreshRequest(BaseModel):
refresh_token: str
class UserResponse(BaseModel):
id: UUID
username: str
email: str | None
full_name: str | None
role: str
is_active: bool
scopes: list[str]
last_login_at: datetime | None
created_at: datetime | None
class MeResponse(BaseModel):
id: UUID
username: str
email: str | None
full_name: str | None
role: str
is_active: bool
scopes: list[str]
@router.post("/register", response_model=UserResponse, status_code=201)
async def register(body: RegisterRequest, service: AuthService = Depends(get_auth_service)):
user = await service.register(
username=body.username,
password=body.password,
email=body.email,
full_name=body.full_name,
role=body.role,
)
return UserResponse(
id=user.id,
username=user.username,
email=user.email,
full_name=user.full_name,
role=user.role.value,
is_active=user.is_active,
scopes=user.scopes,
last_login_at=user.last_login_at,
created_at=user.created_at,
)
@router.post("/token", response_model=TokenResponse)
async def login(body: LoginRequest, service: AuthService = Depends(get_auth_service)):
user, access_token, refresh_token = await service.authenticate(body.username, body.password)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=30 * 60,
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(body: RefreshRequest, service: AuthService = Depends(get_auth_service)):
access_token, refresh_token = await service.refresh_access_token(body.refresh_token)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=30 * 60,
)
@router.get("/me", response_model=MeResponse)
async def get_me(current_user: TokenData = Depends(get_current_user)):
return MeResponse(
id=UUID(current_user.subject),
username="",
email=None,
full_name=None,
role=current_user.role.value,
is_active=True,
scopes=current_user.scopes,
)
@@ -0,0 +1,106 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.core.exceptions import NotFoundException
from src.domain.entities import MedicationCatalogEntryEntity, UserRole
from src.infrastructure.database.repositories import MedicationCatalogRepository, MedicationRepository
from src.infrastructure.database.session import get_db_session
from src.services.auth.security import TokenData
from src.services.medication.medication_service import MedicationService
router = APIRouter(prefix="/pharmacies/{pharmacy_id}/catalog", tags=["Medication Catalog"])
class CatalogEntryUpsertRequest(BaseModel):
medication_id: UUID
price: float | None = None
stock: int = 0
is_available: bool = False
last_verified_at: datetime | None = None
metadata: dict[str, Any] | None = None
class CatalogEntryResponse(BaseModel):
id: UUID
pharmacy_id: UUID
medication_id: UUID
price: float | None
stock: int
is_available: bool
last_verified_at: datetime | None
metadata: dict[str, Any] | None
created_at: datetime | None
updated_at: datetime | None
class CatalogListResponse(BaseModel):
items: list[CatalogEntryResponse]
total: int
offset: int
limit: int
def _entity_to_response(e: MedicationCatalogEntryEntity) -> CatalogEntryResponse:
return CatalogEntryResponse(
id=e.id, pharmacy_id=e.pharmacy_id, medication_id=e.medication_id,
price=e.price, stock=e.stock, is_available=e.is_available,
last_verified_at=e.last_verified_at, metadata=e.metadata_,
created_at=e.created_at, updated_at=e.updated_at,
)
def _get_service(session: AsyncSession) -> MedicationService:
return MedicationService(
medication_repo=MedicationRepository(session),
catalog_repo=MedicationCatalogRepository(session),
)
@router.get("", response_model=CatalogListResponse)
async def list_catalog(
pharmacy_id: UUID,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entities, total = await service.list_catalog(pharmacy_id, offset=offset, limit=limit)
return CatalogListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/{medication_id}", response_model=CatalogEntryResponse)
async def get_catalog_entry(
pharmacy_id: UUID,
medication_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entity = await service.get_catalog_entry(pharmacy_id, medication_id)
if not entity:
raise NotFoundException("CatalogEntry", f"{pharmacy_id}/{medication_id}")
return _entity_to_response(entity)
@router.put("", response_model=CatalogEntryResponse)
async def upsert_catalog_entry(
pharmacy_id: UUID,
body: CatalogEntryUpsertRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
entity = MedicationCatalogEntryEntity(
pharmacy_id=pharmacy_id, medication_id=body.medication_id,
price=body.price, stock=body.stock, is_available=body.is_available,
last_verified_at=body.last_verified_at, metadata_=body.metadata,
)
upserted = await service.upsert_catalog_entry(entity)
return _entity_to_response(upserted)
@@ -0,0 +1,280 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.connectors.base import ConnectorResult
from src.core.exceptions import NotFoundException, ValidationException
from src.domain.entities import ConnectorConfigEntity, ConnectorEntity, ConnectorStatus, ERPType, UserRole
from src.infrastructure.database.repositories import ConnectorConfigRepository, ConnectorRepository
from src.infrastructure.database.session import get_db_session
from src.services.auth.security import TokenData
from src.services.connector_service import ConnectorService
router = APIRouter(prefix="/connectors", tags=["Connectors"])
class ConnectorCreateRequest(BaseModel):
erp_system_id: UUID
pharmacy_id: UUID
name: str = Field(..., min_length=1, max_length=255)
connector_type: str
version: str = "1.0.0"
config: dict[str, Any] | None = None
metadata: dict[str, Any] | None = None
class ConnectorUpdateRequest(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
version: str | None = None
config: dict[str, Any] | None = None
status: str | None = None
metadata: dict[str, Any] | None = None
class ConnectorConfigRequest(BaseModel):
key: str = Field(..., min_length=1, max_length=100)
value: str
encrypted: bool = False
description: str | None = None
class ConnectorResponse(BaseModel):
id: UUID
erp_system_id: UUID
pharmacy_id: UUID
name: str
connector_type: str
version: str
config: dict[str, Any] | None
status: str
last_heartbeat_at: datetime | None
last_error: str | None
metadata: dict[str, Any] | None
created_at: datetime | None
updated_at: datetime | None
class ConnectorListResponse(BaseModel):
items: list[ConnectorResponse]
total: int
offset: int
limit: int
class ConnectorConfigResponse(BaseModel):
id: UUID
connector_id: UUID
key: str
value: str
encrypted: bool
description: str | None
created_at: datetime | None
updated_at: datetime | None
class ConnectorTestResponse(BaseModel):
success: bool
data: dict[str, Any] | None = None
error: str | None = None
class ConnectorDeployResponse(BaseModel):
id: UUID
status: str
message: str
def _entity_to_response(e: ConnectorEntity) -> ConnectorResponse:
return ConnectorResponse(
id=e.id, erp_system_id=e.erp_system_id, pharmacy_id=e.pharmacy_id,
name=e.name, connector_type=e.connector_type, version=e.version,
config=e.config, status=e.status, last_heartbeat_at=e.last_heartbeat_at,
last_error=e.last_error, metadata=e.metadata_, created_at=e.created_at,
updated_at=e.updated_at,
)
def _config_entity_to_response(e: ConnectorConfigEntity) -> ConnectorConfigResponse:
return ConnectorConfigResponse(
id=e.id, connector_id=e.connector_id, key=e.key, value=e.value,
encrypted=e.encrypted, description=e.description,
created_at=e.created_at, updated_at=e.updated_at,
)
def _get_service(session: AsyncSession) -> ConnectorService:
return ConnectorService(
connector_repo=ConnectorRepository(session),
config_repo=ConnectorConfigRepository(session),
)
@router.get("", response_model=ConnectorListResponse)
async def list_connectors(
erp_system_id: UUID | None = None,
pharmacy_id: UUID | None = None,
status: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entities, total = await service.list_connectors(erp_system_id=erp_system_id, pharmacy_id=pharmacy_id, status=status, offset=offset, limit=limit)
return ConnectorListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/types", response_model=list[str])
async def list_connector_types(
current_user: TokenData = Depends(get_current_user),
):
from src.connectors.registry import connector_registry
return connector_registry.list_registered_types()
@router.get("/{connector_id}", response_model=ConnectorResponse)
async def get_connector(
connector_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entity = await service.get_connector(connector_id)
if not entity:
raise NotFoundException("Connector", str(connector_id))
return _entity_to_response(entity)
@router.post("", response_model=ConnectorResponse, status_code=201)
async def create_connector(
body: ConnectorCreateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
entity = ConnectorEntity(
erp_system_id=body.erp_system_id, pharmacy_id=body.pharmacy_id,
name=body.name, connector_type=body.connector_type,
version=body.version, config=body.config,
status=ConnectorStatus.PENDING, metadata_=body.metadata,
)
created = await service.create_connector(entity)
return _entity_to_response(created)
@router.put("/{connector_id}", response_model=ConnectorResponse)
async def update_connector(
connector_id: UUID,
body: ConnectorUpdateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
entity = await service.get_connector(connector_id)
if not entity:
raise NotFoundException("Connector", str(connector_id))
update_data = body.model_dump(exclude_unset=True)
if "metadata" in update_data:
entity.metadata_ = update_data.pop("metadata")
for key, value in update_data.items():
setattr(entity, key, value)
updated = await service.update_connector(entity)
return _entity_to_response(updated)
@router.delete("/{connector_id}", status_code=204)
async def delete_connector(
connector_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
deleted = await service.delete_connector(connector_id)
if not deleted:
raise NotFoundException("Connector", str(connector_id))
@router.post("/{connector_id}/test", response_model=ConnectorTestResponse)
async def test_connector(
connector_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
result = await service.test_connection(connector_id)
return ConnectorTestResponse(success=result.success, data=result.data, error=result.error)
@router.post("/{connector_id}/deploy", response_model=ConnectorDeployResponse)
async def deploy_connector(
connector_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
entity = await service.deploy_connector(connector_id)
return ConnectorDeployResponse(id=entity.id, status=entity.status, message="Connector deployed successfully")
@router.post("/{connector_id}/deactivate", response_model=ConnectorDeployResponse)
async def deactivate_connector(
connector_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
entity = await service.deactivate_connector(connector_id)
return ConnectorDeployResponse(id=entity.id, status=entity.status, message="Connector deactivated successfully")
@router.get("/{connector_id}/configs", response_model=list[ConnectorConfigResponse])
async def list_connector_configs(
connector_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
connector = await service.get_connector(connector_id)
if not connector:
raise NotFoundException("Connector", str(connector_id))
configs = await service.get_configs(connector_id)
return [_config_entity_to_response(c) for c in configs]
@router.put("/{connector_id}/configs", response_model=ConnectorConfigResponse)
async def upsert_connector_config(
connector_id: UUID,
body: ConnectorConfigRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
connector = await service.get_connector(connector_id)
if not connector:
raise NotFoundException("Connector", str(connector_id))
cfg_entity = ConnectorConfigEntity(
connector_id=connector_id, key=body.key, value=body.value,
encrypted=body.encrypted, description=body.description,
)
created = await service.upsert_config(cfg_entity)
return _config_entity_to_response(created)
@router.delete("/{connector_id}/configs/{config_id}", status_code=204)
async def delete_connector_config(
connector_id: UUID,
config_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
await service.delete_config(config_id)
@@ -0,0 +1,143 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.core.exceptions import NotFoundException
from src.domain.entities import ERPSystemEntity, UserRole
from src.domain.interfaces import IERPSystemRepository
from src.infrastructure.database.repositories import ERPSystemRepository
from src.infrastructure.database.session import get_db_session
from src.services.auth.security import TokenData
router = APIRouter(prefix="/erp-systems", tags=["ERP Systems"])
class ERPSystemCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
erp_type: str
version: str | None = None
base_url: str | None = None
auth_config: dict[str, Any] | None = None
metadata: dict[str, Any] | None = None
class ERPSystemUpdateRequest(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
version: str | None = None
base_url: str | None = None
auth_config: dict[str, Any] | None = None
status: str | None = None
metadata: dict[str, Any] | None = None
class ERPSystemResponse(BaseModel):
id: UUID
name: str
erp_type: str
version: str | None
base_url: str | None
auth_config: dict[str, Any] | None
status: str
metadata: dict[str, Any] | None
created_at: datetime | None
updated_at: datetime | None
class ERPSystemListResponse(BaseModel):
items: list[ERPSystemResponse]
total: int
offset: int
limit: int
def _entity_to_response(e: ERPSystemEntity) -> ERPSystemResponse:
return ERPSystemResponse(
id=e.id, name=e.name, erp_type=e.erp_type, version=e.version,
base_url=e.base_url, auth_config=e.auth_config, status=e.status,
metadata=e.metadata_, created_at=e.created_at, updated_at=e.updated_at,
)
def _get_repo(session: AsyncSession) -> IERPSystemRepository:
return ERPSystemRepository(session)
@router.get("", response_model=ERPSystemListResponse)
async def list_erp_systems(
erp_type: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
repo = _get_repo(session)
entities, total = await repo.list_erp_systems(erp_type=erp_type, offset=offset, limit=limit)
return ERPSystemListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/{erp_id}", response_model=ERPSystemResponse)
async def get_erp_system(
erp_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
repo = _get_repo(session)
entity = await repo.get_by_id(erp_id)
if not entity:
raise NotFoundException("ERP System", str(erp_id))
return _entity_to_response(entity)
@router.post("", response_model=ERPSystemResponse, status_code=201)
async def create_erp_system(
body: ERPSystemCreateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = _get_repo(session)
entity = ERPSystemEntity(
name=body.name, erp_type=body.erp_type, version=body.version,
base_url=body.base_url, auth_config=body.auth_config,
metadata_=body.metadata,
)
created = await repo.create(entity)
return _entity_to_response(created)
@router.put("/{erp_id}", response_model=ERPSystemResponse)
async def update_erp_system(
erp_id: UUID,
body: ERPSystemUpdateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = _get_repo(session)
entity = await repo.get_by_id(erp_id)
if not entity:
raise NotFoundException("ERP System", str(erp_id))
update_data = body.model_dump(exclude_unset=True)
if "metadata" in update_data:
entity.metadata_ = update_data.pop("metadata")
for key, value in update_data.items():
setattr(entity, key, value)
updated = await repo.update(entity)
return _entity_to_response(updated)
@router.delete("/{erp_id}", status_code=204)
async def delete_erp_system(
erp_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = _get_repo(session)
deleted = await repo.delete(erp_id)
if not deleted:
raise NotFoundException("ERP System", str(erp_id))
@@ -0,0 +1,150 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.core.exceptions import NotFoundException
from src.domain.entities import InventoryRecordEntity, UserRole
from src.infrastructure.database.repositories import InventoryRepository, ReservationRepository
from src.infrastructure.database.session import get_db_session
from src.services.auth.security import TokenData
from src.services.inventory.inventory_service import InventoryService
router = APIRouter(prefix="/pharmacies/{pharmacy_id}/inventory", tags=["Inventory"])
class InventoryUpsertRequest(BaseModel):
medication_id: UUID
stock: int = Field(..., ge=0)
reserved_stock: int = Field(0, ge=0)
available_stock: int = Field(0, ge=0)
min_stock: int | None = Field(None, ge=0)
max_stock: int | None = Field(None, ge=0)
last_updated_at: datetime | None = None
source: str | None = Field(None, max_length=100)
batch_id: str | None = Field(None, max_length=100)
metadata: dict[str, Any] | None = None
class InventoryRecordResponse(BaseModel):
id: UUID
pharmacy_id: UUID
medication_id: UUID
stock: int
reserved_stock: int
available_stock: int
min_stock: int | None
max_stock: int | None
last_updated_at: datetime | None
source: str | None
batch_id: str | None
metadata: dict[str, Any] | None
created_at: datetime | None
updated_at: datetime | None
class InventoryListResponse(BaseModel):
items: list[InventoryRecordResponse]
total: int
offset: int
limit: int
class InventoryHistoryResponse(BaseModel):
id: UUID
pharmacy_id: UUID
medication_id: UUID
previous_stock: int
new_stock: int
delta: int
reason: str | None
source: str | None
created_at: datetime | None
def _record_entity_to_response(e: InventoryRecordEntity) -> InventoryRecordResponse:
return InventoryRecordResponse(
id=e.id, pharmacy_id=e.pharmacy_id, medication_id=e.medication_id,
stock=e.stock, reserved_stock=e.reserved_stock, available_stock=e.available_stock,
min_stock=e.min_stock, max_stock=e.max_stock,
last_updated_at=e.last_updated_at, source=e.source, batch_id=e.batch_id,
metadata=e.metadata_, created_at=e.created_at, updated_at=e.updated_at,
)
def _get_service(session: AsyncSession) -> InventoryService:
return InventoryService(
inventory_repo=InventoryRepository(session),
reservation_repo=ReservationRepository(session),
)
@router.get("", response_model=InventoryListResponse)
async def list_inventory(
pharmacy_id: UUID,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entities, total = await service.list_inventory(pharmacy_id, offset=offset, limit=limit)
return InventoryListResponse(items=[_record_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/{medication_id}", response_model=InventoryRecordResponse)
async def get_inventory_record(
pharmacy_id: UUID,
medication_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entity = await service.get_inventory_record(pharmacy_id, medication_id)
if not entity:
raise NotFoundException("InventoryRecord", f"{pharmacy_id}/{medication_id}")
return _record_entity_to_response(entity)
@router.put("", response_model=InventoryRecordResponse)
async def upsert_inventory_record(
pharmacy_id: UUID,
body: InventoryUpsertRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
entity = InventoryRecordEntity(
pharmacy_id=pharmacy_id, medication_id=body.medication_id,
stock=body.stock, reserved_stock=body.reserved_stock,
available_stock=body.available_stock, min_stock=body.min_stock,
max_stock=body.max_stock, last_updated_at=body.last_updated_at,
source=body.source, batch_id=body.batch_id, metadata_=body.metadata,
)
upserted = await service.upsert_inventory_record(entity)
return _record_entity_to_response(upserted)
@router.get("/{medication_id}/history", response_model=list[InventoryHistoryResponse])
async def get_inventory_history(
pharmacy_id: UUID,
medication_id: UUID,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
history = await service.get_inventory_history(pharmacy_id, medication_id, offset=offset, limit=limit)
return [
InventoryHistoryResponse(
id=h.id, pharmacy_id=h.pharmacy_id, medication_id=h.medication_id,
previous_stock=h.previous_stock, new_stock=h.new_stock, delta=h.delta,
reason=h.reason, source=h.source, created_at=h.created_at,
)
for h in history
]
@@ -0,0 +1,189 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.core.exceptions import ConflictException, NotFoundException
from src.domain.entities import MedicationEntity, UserRole
from src.infrastructure.database.repositories import MedicationCatalogRepository, MedicationRepository
from src.infrastructure.database.session import get_db_session
from src.services.auth.security import TokenData
from src.services.medication.medication_service import MedicationService
router = APIRouter(prefix="/medications", tags=["Medications"])
class MedicationCreateRequest(BaseModel):
nregistro: str = Field(..., min_length=1, max_length=50)
name: str = Field(..., min_length=1, max_length=500)
active_ingredient: str | None = Field(None, max_length=500)
dosage: str | None = Field(None, max_length=100)
form: str | None = Field(None, max_length=100)
atc_code: str | None = Field(None, max_length=20)
prescription_required: bool = False
manufacturer: str | None = Field(None, max_length=255)
metadata: dict[str, Any] | None = None
class MedicationUpdateRequest(BaseModel):
name: str | None = Field(None, min_length=1, max_length=500)
active_ingredient: str | None = Field(None, max_length=500)
dosage: str | None = Field(None, max_length=100)
form: str | None = Field(None, max_length=100)
atc_code: str | None = Field(None, max_length=20)
prescription_required: bool | None = None
manufacturer: str | None = Field(None, max_length=255)
metadata: dict[str, Any] | None = None
class MedicationResponse(BaseModel):
id: UUID
nregistro: str
name: str
active_ingredient: str | None
dosage: str | None
form: str | None
atc_code: str | None
prescription_required: bool
manufacturer: str | None
metadata: dict[str, Any] | None
created_at: datetime | None
updated_at: datetime | None
class MedicationListResponse(BaseModel):
items: list[MedicationResponse]
total: int
offset: int
limit: int
def _entity_to_response(e: MedicationEntity) -> MedicationResponse:
return MedicationResponse(
id=e.id, nregistro=e.nregistro, name=e.name,
active_ingredient=e.active_ingredient, dosage=e.dosage,
form=e.form, atc_code=e.atc_code,
prescription_required=e.prescription_required,
manufacturer=e.manufacturer, metadata=e.metadata_,
created_at=e.created_at, updated_at=e.updated_at,
)
def _get_service(session: AsyncSession) -> MedicationService:
return MedicationService(
medication_repo=MedicationRepository(session),
catalog_repo=MedicationCatalogRepository(session),
)
@router.get("", response_model=MedicationListResponse)
async def list_medications(
q: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
if q:
entities, total = await service.search_medications(q, offset=offset, limit=limit)
else:
from src.domain.entities import MedicationEntity
all_entities, total = await service.search_medications("", offset=offset, limit=limit)
entities = all_entities
return MedicationListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/search", response_model=MedicationListResponse)
async def search_medications(
q: str = Query(..., min_length=1),
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entities, total = await service.search_medications(q, offset=offset, limit=limit)
return MedicationListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/nregistro/{nregistro}", response_model=MedicationResponse)
async def get_medication_by_nregistro(
nregistro: str,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entity = await service.get_by_nregistro(nregistro)
if not entity:
raise NotFoundException("Medication", nregistro)
return _entity_to_response(entity)
@router.get("/{medication_id}", response_model=MedicationResponse)
async def get_medication(
medication_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entity = await service.get_medication(medication_id)
if not entity:
raise NotFoundException("Medication", str(medication_id))
return _entity_to_response(entity)
@router.post("", response_model=MedicationResponse, status_code=201)
async def create_medication(
body: MedicationCreateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
entity = MedicationEntity(
nregistro=body.nregistro, name=body.name,
active_ingredient=body.active_ingredient, dosage=body.dosage,
form=body.form, atc_code=body.atc_code,
prescription_required=body.prescription_required,
manufacturer=body.manufacturer, metadata_=body.metadata,
)
created = await service.create_medication(entity)
return _entity_to_response(created)
@router.put("/{medication_id}", response_model=MedicationResponse)
async def update_medication(
medication_id: UUID,
body: MedicationUpdateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
entity = await service.get_medication(medication_id)
if not entity:
raise NotFoundException("Medication", str(medication_id))
update_data = body.model_dump(exclude_unset=True)
if "metadata" in update_data:
entity.metadata_ = update_data.pop("metadata")
for key, value in update_data.items():
setattr(entity, key, value)
updated = await service.update_medication(entity)
return _entity_to_response(updated)
@router.delete("/{medication_id}", status_code=204)
async def delete_medication(
medication_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
deleted = await service.delete_medication(medication_id)
if not deleted:
raise NotFoundException("Medication", str(medication_id))
@@ -0,0 +1,185 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.exceptions import NotFoundException
from src.domain.entities import PharmacyEntity, PharmacyStatus
from src.domain.interfaces import IPharmacyRepository
from src.infrastructure.database.session import get_db_session
from src.infrastructure.database.repositories import PharmacyRepository
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.domain.entities import UserRole
from src.services.auth.security import TokenData
router = APIRouter(prefix="/pharmacies", tags=["Pharmacies"])
class PharmacyCreateRequest(BaseModel):
code: str = Field(..., min_length=1, max_length=50)
name: str = Field(..., min_length=1, max_length=255)
address: str | None = None
city: str | None = None
province: str | None = None
postal_code: str | None = None
country: str = "ES"
latitude: float | None = None
longitude: float | None = None
phone: str | None = None
email: str | None = None
opening_hours: dict | None = None
erp_type: str | None = None
erp_version: str | None = None
sync_strategy: str = "incremental"
sync_interval_minutes: int = 30
class PharmacyUpdateRequest(BaseModel):
name: str | None = None
address: str | None = None
city: str | None = None
province: str | None = None
postal_code: str | None = None
country: str | None = None
latitude: float | None = None
longitude: float | None = None
phone: str | None = None
email: str | None = None
opening_hours: dict | None = None
status: str | None = None
erp_type: str | None = None
erp_version: str | None = None
sync_strategy: str | None = None
sync_interval_minutes: int | None = None
class PharmacyResponse(BaseModel):
id: UUID
code: str
name: str
address: str | None
city: str | None
province: str | None
postal_code: str | None
country: str
latitude: float | None
longitude: float | None
phone: str | None
email: str | None
opening_hours: dict | None
status: str
erp_type: str | None
erp_version: str | None
connector_id: UUID | None
sync_strategy: str
sync_interval_minutes: int
last_sync_at: datetime | None
last_sync_status: str | None
created_at: datetime | None
updated_at: datetime | None
class PharmacyListResponse(BaseModel):
items: list[PharmacyResponse]
total: int
offset: int
limit: int
def _entity_to_response(e: PharmacyEntity) -> PharmacyResponse:
return PharmacyResponse(
id=e.id, code=e.code, name=e.name, address=e.address, city=e.city,
province=e.province, postal_code=e.postal_code, country=e.country,
latitude=e.latitude, longitude=e.longitude, phone=e.phone, email=e.email,
opening_hours=e.opening_hours, status=e.status, erp_type=e.erp_type,
erp_version=e.erp_version, connector_id=e.connector_id,
sync_strategy=e.sync_strategy, sync_interval_minutes=e.sync_interval_minutes,
last_sync_at=e.last_sync_at, last_sync_status=e.last_sync_status,
created_at=e.created_at, updated_at=e.updated_at,
)
def _get_repo(session: AsyncSession) -> IPharmacyRepository:
return PharmacyRepository(session)
@router.get("", response_model=PharmacyListResponse)
async def list_pharmacies(
status: str | None = None,
erp_type: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
repo = _get_repo(session)
entities, total = await repo.list_pharmacies(status=status, erp_type=erp_type, offset=offset, limit=limit)
return PharmacyListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/{pharmacy_id}", response_model=PharmacyResponse)
async def get_pharmacy(
pharmacy_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
repo = _get_repo(session)
entity = await repo.get_by_id(pharmacy_id)
if not entity:
raise NotFoundException("Pharmacy", str(pharmacy_id))
return _entity_to_response(entity)
@router.post("", response_model=PharmacyResponse, status_code=201)
async def create_pharmacy(
body: PharmacyCreateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = _get_repo(session)
entity = PharmacyEntity(
code=body.code, name=body.name, address=body.address, city=body.city,
province=body.province, postal_code=body.postal_code, country=body.country,
latitude=body.latitude, longitude=body.longitude, phone=body.phone,
email=body.email, opening_hours=body.opening_hours,
status=PharmacyStatus.ACTIVE.value, erp_type=body.erp_type,
erp_version=body.erp_version, sync_strategy=body.sync_strategy,
sync_interval_minutes=body.sync_interval_minutes,
)
created = await repo.create(entity)
return _entity_to_response(created)
@router.put("/{pharmacy_id}", response_model=PharmacyResponse)
async def update_pharmacy(
pharmacy_id: UUID,
body: PharmacyUpdateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = _get_repo(session)
entity = await repo.get_by_id(pharmacy_id)
if not entity:
raise NotFoundException("Pharmacy", str(pharmacy_id))
update_data = body.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(entity, key, value)
updated = await repo.update(entity)
return _entity_to_response(updated)
@router.delete("/{pharmacy_id}", status_code=204)
async def delete_pharmacy(
pharmacy_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = _get_repo(session)
deleted = await repo.delete(pharmacy_id)
if not deleted:
raise NotFoundException("Pharmacy", str(pharmacy_id))
@@ -0,0 +1,149 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.core.exceptions import NotFoundException, ValidationException
from src.domain.entities import ReservationEntity, ReservationStatus, UserRole
from src.infrastructure.database.repositories import InventoryRepository, ReservationRepository
from src.infrastructure.database.session import get_db_session
from src.services.auth.security import TokenData
from src.services.inventory.inventory_service import InventoryService
router = APIRouter(prefix="/pharmacies/{pharmacy_id}/reservations", tags=["Reservations"])
class ReservationCreateRequest(BaseModel):
medication_id: UUID
quantity: int = Field(..., gt=0)
external_reference: str | None = Field(None, max_length=255)
idempotency_key: str | None = Field(None, max_length=255)
ttl_minutes: int = Field(30, ge=1, le=1440)
metadata: dict[str, Any] | None = None
class ReservationResponse(BaseModel):
id: UUID
pharmacy_id: UUID
medication_id: UUID
user_id: UUID | None
external_reference: str | None
quantity: int
status: str
expires_at: datetime | None
confirmed_at: datetime | None
cancelled_at: datetime | None
idempotency_key: str | None
metadata: dict[str, Any] | None
created_at: datetime | None
updated_at: datetime | None
class ReservationListResponse(BaseModel):
items: list[ReservationResponse]
total: int
offset: int
limit: int
def _entity_to_response(e: ReservationEntity) -> ReservationResponse:
return ReservationResponse(
id=e.id, pharmacy_id=e.pharmacy_id, medication_id=e.medication_id,
user_id=e.user_id, external_reference=e.external_reference,
quantity=e.quantity, status=e.status, expires_at=e.expires_at,
confirmed_at=e.confirmed_at, cancelled_at=e.cancelled_at,
idempotency_key=e.idempotency_key, metadata=e.metadata_,
created_at=e.created_at, updated_at=e.updated_at,
)
def _get_service(session: AsyncSession) -> InventoryService:
return InventoryService(
inventory_repo=InventoryRepository(session),
reservation_repo=ReservationRepository(session),
)
@router.get("", response_model=ReservationListResponse)
async def list_reservations(
pharmacy_id: UUID,
status: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entities, total = await service.list_reservations(pharmacy_id, status=status, offset=offset, limit=limit)
return ReservationListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/{reservation_id}", response_model=ReservationResponse)
async def get_reservation(
pharmacy_id: UUID,
reservation_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entity = await service.get_reservation(reservation_id)
if not entity or entity.pharmacy_id != pharmacy_id:
raise NotFoundException("Reservation", str(reservation_id))
return _entity_to_response(entity)
@router.post("", response_model=ReservationResponse, status_code=201)
async def create_reservation(
pharmacy_id: UUID,
body: ReservationCreateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
user_id = None
if hasattr(current_user, "user_id"):
user_id = current_user.user_id
elif hasattr(current_user, "sub"):
try:
user_id = UUID(current_user.sub)
except (ValueError, TypeError):
pass
created = await service.create_reservation(
pharmacy_id=pharmacy_id,
medication_id=body.medication_id,
quantity=body.quantity,
user_id=user_id,
external_reference=body.external_reference,
idempotency_key=body.idempotency_key,
ttl_minutes=body.ttl_minutes,
)
return _entity_to_response(created)
@router.post("/{reservation_id}/confirm", response_model=ReservationResponse)
async def confirm_reservation(
pharmacy_id: UUID,
reservation_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
updated = await service.confirm_reservation(reservation_id)
return _entity_to_response(updated)
@router.post("/{reservation_id}/cancel", response_model=ReservationResponse)
async def cancel_reservation(
pharmacy_id: UUID,
reservation_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
updated = await service.cancel_reservation(reservation_id)
return _entity_to_response(updated)
@@ -0,0 +1,199 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.core.exceptions import NotFoundException
from src.domain.entities import SyncJobStatus, SyncStrategy, UserRole
from src.infrastructure.database.repositories import SyncJobRepository, SyncLogRepository
from src.infrastructure.database.session import get_db_session
from src.services.auth.security import TokenData
from src.services.synchronization.sync_service import SyncService
router = APIRouter(prefix="/pharmacies/{pharmacy_id}/sync-jobs", tags=["Sync Jobs"])
class SyncJobCreateRequest(BaseModel):
connector_id: UUID
strategy: SyncStrategy = SyncStrategy.INCREMENTAL
class SyncJobResponse(BaseModel):
id: UUID
pharmacy_id: UUID
connector_id: UUID
strategy: str
status: str
started_at: datetime | None
completed_at: datetime | None
items_total: int | None
items_processed: int | None
items_failed: int | None
error_message: str | None
metadata: dict[str, Any] | None
created_at: datetime | None
updated_at: datetime | None
class SyncJobListResponse(BaseModel):
items: list[SyncJobResponse]
total: int
offset: int
limit: int
class SyncLogResponse(BaseModel):
id: UUID
sync_job_id: UUID
level: str
message: str
details: dict[str, Any] | None
created_at: datetime | None
def _entity_to_response(e: Any) -> SyncJobResponse:
return SyncJobResponse(
id=e.id, pharmacy_id=e.pharmacy_id, connector_id=e.connector_id,
strategy=e.strategy, status=e.status, started_at=e.started_at,
completed_at=e.completed_at, items_total=e.items_total,
items_processed=e.items_processed, items_failed=e.items_failed,
error_message=e.error_message, metadata=e.metadata_,
created_at=e.created_at, updated_at=e.updated_at,
)
def _get_service(session: AsyncSession) -> SyncService:
return SyncService(
sync_job_repo=SyncJobRepository(session),
sync_log_repo=SyncLogRepository(session),
)
@router.get("", response_model=SyncJobListResponse)
async def list_sync_jobs(
pharmacy_id: UUID,
status: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entities, total = await service.list_sync_jobs(pharmacy_id, status=status, offset=offset, limit=limit)
return SyncJobListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/{job_id}", response_model=SyncJobResponse)
async def get_sync_job(
pharmacy_id: UUID,
job_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entity = await service.get_sync_job(job_id)
if not entity or entity.pharmacy_id != pharmacy_id:
raise NotFoundException("SyncJob", str(job_id))
return _entity_to_response(entity)
@router.post("", response_model=SyncJobResponse, status_code=201)
async def create_sync_job(
pharmacy_id: UUID,
body: SyncJobCreateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
created = await service.create_sync_job(
pharmacy_id=pharmacy_id,
connector_id=body.connector_id,
strategy=body.strategy,
)
return _entity_to_response(created)
@router.post("/{job_id}/trigger", response_model=SyncJobResponse)
async def trigger_sync(
pharmacy_id: UUID,
job_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
updated = await service.trigger_sync(job_id)
return _entity_to_response(updated)
@router.post("/{job_id}/cancel", response_model=SyncJobResponse)
async def cancel_sync(
pharmacy_id: UUID,
job_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
updated = await service.cancel_sync(job_id)
return _entity_to_response(updated)
class SyncCompleteRequest(BaseModel):
items_total: int | None = None
items_processed: int | None = None
items_failed: int | None = None
duration_ms: int = 0
class SyncFailRequest(BaseModel):
error_message: str
@router.post("/{job_id}/complete", response_model=SyncJobResponse)
async def complete_sync(
pharmacy_id: UUID,
job_id: UUID,
body: SyncCompleteRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
updated = await service.complete_sync(job_id, items_total=body.items_total, items_processed=body.items_processed, items_failed=body.items_failed)
return _entity_to_response(updated)
@router.post("/{job_id}/fail", response_model=SyncJobResponse)
async def fail_sync(
pharmacy_id: UUID,
job_id: UUID,
body: SyncFailRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
updated = await service.fail_sync(job_id, error_message=body.error_message)
return _entity_to_response(updated)
@router.get("/{job_id}/logs", response_model=list[SyncLogResponse])
async def get_sync_logs(
pharmacy_id: UUID,
job_id: UUID,
level: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
logs = await service.get_sync_logs(job_id, level=level, offset=offset, limit=limit)
return [
SyncLogResponse(
id=l.id, sync_job_id=l.sync_job_id, level=l.level,
message=l.message, details=l.details, created_at=l.created_at,
)
for l in logs
]
@@ -0,0 +1,77 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from src.infrastructure.cache.redis_cache import redis_cache
from src.infrastructure.database.session import get_db_session
from src.infrastructure.messaging.rabbitmq import rabbitmq_client
from src.api.v1.dependencies.auth import get_current_user
from src.services.auth.security import TokenData
router = APIRouter(prefix="/system", tags=["System"])
class HealthStatus(BaseModel):
status: str
version: str
services: dict[str, str]
class ReadinessStatus(BaseModel):
status: str
checks: dict[str, bool]
@router.get("/health", response_model=HealthStatus)
async def health_check():
return HealthStatus(
status="healthy",
version="0.1.0",
services={
"api": "healthy",
},
)
@router.get("/ready", response_model=ReadinessStatus)
async def readiness_check(db: AsyncSession = Depends(get_db_session)):
checks: dict[str, bool] = {}
try:
# Check database connection
await db.execute(text("SELECT 1"))
db_ok = True
except Exception:
db_ok = False
checks["database"] = db_ok
redis_ok = await redis_cache.health_check()
checks["redis"] = redis_ok
mq_ok = await rabbitmq_client.health_check()
checks["rabbitmq"] = mq_ok
overall = all(checks.values())
return ReadinessStatus(
status="ready" if overall else "degraded",
checks=checks,
)
@router.get("/status")
async def system_status(current_user: TokenData = Depends(get_current_user)):
return {
"platform": "Pharmacy Integration Platform",
"version": "0.1.0",
"environment": "production",
"services": {
"auth": "active",
"pharmacy": "active",
"medication": "active",
"connector": "active",
"synchronization": "active",
"audit": "active",
"notification": "active",
},
}
@@ -0,0 +1,70 @@
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.exceptions import NotFoundException
from src.domain.entities import TechnicalUserEntity, UserRole
from src.domain.interfaces import ITechnicalUserRepository
from src.infrastructure.database.repositories import TechnicalUserRepository
from src.infrastructure.database.session import get_db_session
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.services.auth.security import TokenData
router = APIRouter(prefix="/users", tags=["Technical Users"])
class UserResponse(BaseModel):
id: UUID
username: str
email: str | None
full_name: str | None
role: str
is_active: bool
scopes: list[str]
last_login_at: datetime | None
created_at: datetime | None
class UserListResponse(BaseModel):
items: list[UserResponse]
total: int
offset: int
limit: int
def _entity_to_response(e: TechnicalUserEntity) -> UserResponse:
return UserResponse(
id=e.id, username=e.username, email=e.email, full_name=e.full_name,
role=e.role, is_active=e.is_active, scopes=e.scopes,
last_login_at=e.last_login_at, created_at=e.created_at,
)
@router.get("", response_model=UserListResponse)
async def list_users(
role: str | None = None,
is_active: bool | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = TechnicalUserRepository(session)
entities, total = await repo.list_users(role=role, is_active=is_active, offset=offset, limit=limit)
return UserListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = TechnicalUserRepository(session)
entity = await repo.get_by_id(user_id)
if not entity:
raise NotFoundException("User", str(user_id))
return _entity_to_response(entity)
+31
View File
@@ -0,0 +1,31 @@
from fastapi import APIRouter
from src.api.v1.endpoints.auth import router as auth_router
from src.api.v1.endpoints.pharmacies import router as pharmacies_router
from src.api.v1.endpoints.users import router as users_router
from src.api.v1.endpoints.api_clients import router as api_clients_router
from src.api.v1.endpoints.audit import router as audit_router
from src.api.v1.endpoints.system import router as system_router
from src.api.v1.endpoints.connectors import router as connectors_router
from src.api.v1.endpoints.erp_systems import router as erp_systems_router
from src.api.v1.endpoints.medications import router as medications_router
from src.api.v1.endpoints.catalog import router as catalog_router
from src.api.v1.endpoints.inventory import router as inventory_router
from src.api.v1.endpoints.reservations import router as reservations_router
from src.api.v1.endpoints.sync_jobs import router as sync_jobs_router
v1_router = APIRouter(prefix="/api/v1")
v1_router.include_router(auth_router)
v1_router.include_router(pharmacies_router)
v1_router.include_router(users_router)
v1_router.include_router(api_clients_router)
v1_router.include_router(audit_router)
v1_router.include_router(system_router)
v1_router.include_router(connectors_router)
v1_router.include_router(erp_systems_router)
v1_router.include_router(medications_router)
v1_router.include_router(catalog_router)
v1_router.include_router(inventory_router)
v1_router.include_router(reservations_router)
v1_router.include_router(sync_jobs_router)