Restructure with Turborepo
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user