diff --git a/pip-platform/.env.example b/pip-platform/.env.example new file mode 100644 index 0000000..dc45e17 --- /dev/null +++ b/pip-platform/.env.example @@ -0,0 +1,37 @@ +APP_NAME=PIP - Pharmacy Integration Platform +APP_VERSION=0.1.0 +DEBUG=false + +DATABASE_URL=postgresql+asyncpg://pip:pip-secret@localhost:5432/pip +DATABASE_POOL_SIZE=20 +DATABASE_MAX_OVERFLOW=10 +DATABASE_POOL_RECYCLE=3600 + +REDIS_URL=redis://localhost:6379/0 +REDIS_CACHE_TTL=300 + +RABBITMQ_URL=amqp://pip:pip-secret@localhost:5672/pip + +JWT_SECRET_KEY=change-me-in-production +JWT_ALGORITHM=HS256 +JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30 +JWT_REFRESH_TOKEN_EXPIRE_DAYS=7 +JWT_ISSUER=pip-platform + +RATE_LIMIT_PER_MINUTE=60 +RATE_LIMIT_AUTH_PER_MINUTE=10 + +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +OTEL_SERVICE_NAME=pip-platform +OTEL_TRACES_ENABLED=true +OTEL_METRICS_ENABLED=true + +LOG_LEVEL=INFO +LOG_JSON_FORMAT=true + +CORS_ORIGINS=["*"] +CORS_ALLOW_CREDENTIALS=true + +PG_PASSWORD=pip-secret +RABBITMQ_PASSWORD=pip-secret +GRAFANA_PASSWORD=admin diff --git a/pip-platform/.gitignore b/pip-platform/.gitignore new file mode 100644 index 0000000..ffb37c6 --- /dev/null +++ b/pip-platform/.gitignore @@ -0,0 +1,20 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +dist/ +*.egg-info/ +.eggs/ +*.egg +.env +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +*.sqlite3 +.alembic/versions/*.pyc diff --git a/pip-platform/Dockerfile b/pip-platform/Dockerfile new file mode 100644 index 0000000..34a5d7f --- /dev/null +++ b/pip-platform/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.13-slim AS base + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY pyproject.toml ./ +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir . + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4", "--loop", "uvloop", "--http", "httptools"] diff --git a/pip-platform/STATE.md b/pip-platform/STATE.md new file mode 100644 index 0000000..7752a4f --- /dev/null +++ b/pip-platform/STATE.md @@ -0,0 +1,65 @@ +# PIP Platform — State Tracker + +## Current Phase: 3 (Complete) + +### Phase 1 — Foundation ✅ +- FastAPI app skeleton, Settings, Docker Compose (Postgres, Redis, RabbitMQ) +- SQLAlchemy async models + Alembic migration `001_initial` +- Auth: JWT (access/refresh), RBAC (admin/operator/viewer), user CRUD +- Entities: Pharmacy, Connector, Medication, Inventory, Reservation, ERP System +- Repositories + Services for all entities +- API v1 endpoints for all entities +- Middleware: CORS, request logging, metrics +- Observability: OpenTelemetry traces, Prometheus metrics + +### Phase 2 — Connector SDK ✅ +- Abstract connector interface + Mock connector implementation +- Connector registry, version management, config CRUD +- Medicationcatalog + inventory sync plumbing + +### Phase 3 — Sync, Audit, API Keys, Rate Limiting ✅ +- **Sync Jobs**: Repository, service, API endpoints (create, list, get, trigger, complete, fail, cancel, logs, status filter) +- **Audit Logging**: AuditService (`log_action`, `list_logs`), AuditMiddleware (auto-logs POST/PUT/PATCH/DELETE), audit endpoint +- **API Key Auth**: Dual auth via `get_authenticated_user` — supports both Bearer JWT and X-API-Key header; `require_role` and `require_scope` use it; API Key CRUD endpoints +- **Rate Limiting**: Custom `RateLimitMiddleware` (sliding window, X-RateLimit-* headers, 429 with Retry-After); exempt paths (docs, metrics, health) +- **RabbitMQ**: Routing keys for `sync_created`, `sync_triggered`, `sync_completed`, `sync_failed`, `sync_cancelled` + +## Architecture Decisions +- Clean Architecture: domain entities → services → API endpoints → infrastructure +- Repository pattern with SQLAlchemy async +- Dual auth: JWT Bearer + X-API-Key in single `get_current_user` dependency +- Audit middleware uses separate DB session (via `async_session_factory`) to decouple from request session +- Rate limiting: custom middleware replacing slowapi (incompatible with FastAPI 0.138+ `_IncludedRouter`) +- DB migrations: Alembic `upgrade head` runs in app lifespan (not `create_all`) +- `APIKeyEntity.key_hash` is `bytes` (sha256 digest), ORM model uses `LargeBinary(64)` + +## Key Files +| File | Purpose | +|------|---------| +| `src/main.py` | App factory, lifespan (alembic upgrade), middleware chain | +| `src/api/v1/dependencies/auth.py` | Dual auth: JWT + API Key, require_role, require_scope | +| `src/api/middleware/audit.py` | Auto-audit for write endpoints | +| `src/api/middleware/rate_limit.py` | Custom sliding window rate limiter | +| `src/api/v1/endpoints/sync_jobs.py` | Sync job CRUD + lifecycle | +| `src/api/v1/endpoints/api_clients.py` | API client + key management | +| `src/api/v1/endpoints/audit.py` | Audit log query endpoint | +| `src/services/synchronization/sync_service.py` | Sync job business logic | +| `src/services/audit/audit_service.py` | Audit service | +| `src/infrastructure/messaging/rabbitmq.py` | Event publishing routing keys | +| `alembic/versions/001_initial.py` | Full schema (all tables including sync_jobs, sync_logs, audit_logs) | + +## Configuration (env) +| Variable | Default | Description | +|----------|---------|-------------| +| `RATE_LIMIT_PER_MINUTE` | 60 | Max requests per client per minute | +| `RATE_LIMIT_AUTH_PER_MINUTE` | 10 | (reserved) Auth endpoint limit | +| `JWT_SECRET_KEY` | change-me-in-production | Must be set in production | +| `API_KEY_PREFIX` | pip | (reserved) Key namespace prefix | + +## Next Phase Ideas +- Redis-backed rate limiter (for multi-instance) +- Per-client rate limits (use `api_clients.rate_limit` column) +- API Key scope enforcement on specific endpoints (currently role-only) +- Sync job execution worker (consume RabbitMQ events, run connector) +- IP allowlist enforcement for API clients +- Alembic migration 002 for any schema changes diff --git a/pip-platform/alembic.ini b/pip-platform/alembic.ini new file mode 100644 index 0000000..f1da6a9 --- /dev/null +++ b/pip-platform/alembic.ini @@ -0,0 +1,37 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = postgresql+psycopg2://pip:pip-secret@localhost:5432/pip + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/pip-platform/alembic/env.py b/pip-platform/alembic/env.py new file mode 100644 index 0000000..4d6781c --- /dev/null +++ b/pip-platform/alembic/env.py @@ -0,0 +1,41 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from src.infrastructure.config.settings import settings +from src.infrastructure.database.models import Base + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL_SYNC) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/pip-platform/alembic/script.py.mako b/pip-platform/alembic/script.py.mako new file mode 100644 index 0000000..590f5b3 --- /dev/null +++ b/pip-platform/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/pip-platform/alembic/versions/001_initial.py b/pip-platform/alembic/versions/001_initial.py new file mode 100644 index 0000000..62a7dcd --- /dev/null +++ b/pip-platform/alembic/versions/001_initial.py @@ -0,0 +1,306 @@ +"""Phase 1 initial schema + +Revision ID: 001_initial +Revises: None +Create Date: 2026-06-30 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB, UUID + +revision: str = "001_initial" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "erp_systems", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("erp_type", sa.String(50), nullable=False, index=True), + sa.Column("version", sa.String(50)), + sa.Column("base_url", sa.String(500)), + sa.Column("auth_config", JSONB), + sa.Column("status", sa.String(20), default="pending", index=True), + sa.Column("metadata", JSONB), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + ) + + op.create_table( + "pharmacies", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("code", sa.String(50), unique=True, nullable=False, index=True), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("address", sa.String(500)), + sa.Column("city", sa.String(100)), + sa.Column("province", sa.String(100)), + sa.Column("postal_code", sa.String(20)), + sa.Column("country", sa.String(2), default="ES"), + sa.Column("latitude", sa.Float), + sa.Column("longitude", sa.Float), + sa.Column("phone", sa.String(50)), + sa.Column("email", sa.String(255)), + sa.Column("opening_hours", JSONB), + sa.Column("status", sa.String(20), default="pending", index=True), + sa.Column("erp_type", sa.String(50)), + sa.Column("erp_version", sa.String(50)), + sa.Column("sync_strategy", sa.String(20), default="incremental"), + sa.Column("sync_interval_minutes", sa.Integer, default=30), + sa.Column("last_sync_at", sa.DateTime(timezone=True)), + sa.Column("last_sync_status", sa.String(20)), + sa.Column("metadata", JSONB), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + ) + op.create_index("ix_pharmacies_location", "pharmacies", ["latitude", "longitude"]) + op.create_index("ix_pharmacies_status_erp", "pharmacies", ["status", "erp_type"]) + + op.create_table( + "connectors", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("erp_system_id", UUID(as_uuid=True), sa.ForeignKey("erp_systems.id"), nullable=False, index=True), + sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("connector_type", sa.String(50), nullable=False, index=True), + sa.Column("version", sa.String(20), default="1.0.0"), + sa.Column("config", JSONB), + sa.Column("status", sa.String(20), default="pending", index=True), + sa.Column("last_heartbeat_at", sa.DateTime(timezone=True)), + sa.Column("last_error", sa.Text), + sa.Column("metadata", JSONB), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + sa.UniqueConstraint("erp_system_id", "pharmacy_id", name="uq_connector_erp_pharmacy"), + ) + + op.add_column("pharmacies", sa.Column("connector_id", UUID(as_uuid=True), sa.ForeignKey("connectors.id"), nullable=True)) + op.create_index("ix_pharmacies_connector_id", "pharmacies", ["connector_id"]) + + op.create_table( + "connector_versions", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("connector_id", UUID(as_uuid=True), sa.ForeignKey("connectors.id"), nullable=False, index=True), + sa.Column("connector_type", sa.String(50), nullable=False, index=True), + sa.Column("version", sa.String(20), nullable=False), + sa.Column("changelog", sa.Text), + sa.Column("download_url", sa.String(500)), + sa.Column("checksum", sa.String(128)), + sa.Column("is_latest", sa.Boolean, default=False, index=True), + sa.Column("is_required", sa.Boolean, default=False), + sa.Column("released_at", sa.DateTime(timezone=True)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + sa.UniqueConstraint("connector_type", "version", name="uq_connector_version"), + ) + + op.create_table( + "connector_configs", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("connector_id", UUID(as_uuid=True), sa.ForeignKey("connectors.id"), nullable=False, index=True), + sa.Column("key", sa.String(100), nullable=False), + sa.Column("value", sa.Text, nullable=False), + sa.Column("encrypted", sa.Boolean, default=False), + sa.Column("description", sa.String(500)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + sa.UniqueConstraint("connector_id", "key", name="uq_connector_config_key"), + ) + + op.create_table( + "medications", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("nregistro", sa.String(50), unique=True, nullable=False, index=True), + sa.Column("name", sa.String(500), nullable=False, index=True), + sa.Column("active_ingredient", sa.String(500)), + sa.Column("dosage", sa.String(100)), + sa.Column("form", sa.String(100)), + sa.Column("atc_code", sa.String(20), index=True), + sa.Column("prescription_required", sa.Boolean, default=False), + sa.Column("manufacturer", sa.String(255)), + sa.Column("metadata", JSONB), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + ) + + op.create_table( + "medication_catalog", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True), + sa.Column("medication_id", UUID(as_uuid=True), sa.ForeignKey("medications.id"), nullable=False, index=True), + sa.Column("price", sa.Float), + sa.Column("stock", sa.Integer, default=0), + sa.Column("is_available", sa.Boolean, default=False, index=True), + sa.Column("last_verified_at", sa.DateTime(timezone=True)), + sa.Column("metadata", JSONB), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + sa.UniqueConstraint("pharmacy_id", "medication_id", name="uq_catalog_pharmacy_medication"), + ) + + op.create_table( + "inventory_records", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True), + sa.Column("medication_id", UUID(as_uuid=True), sa.ForeignKey("medications.id"), nullable=False, index=True), + sa.Column("stock", sa.Integer, default=0), + sa.Column("reserved_stock", sa.Integer, default=0), + sa.Column("available_stock", sa.Integer, default=0), + sa.Column("min_stock", sa.Integer), + sa.Column("max_stock", sa.Integer), + sa.Column("last_updated_at", sa.DateTime(timezone=True), index=True), + sa.Column("source", sa.String(100)), + sa.Column("batch_id", sa.String(100)), + sa.Column("metadata", JSONB), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + sa.UniqueConstraint("pharmacy_id", "medication_id", name="uq_inventory_pharmacy_medication"), + ) + + op.create_table( + "inventory_history", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("inventory_record_id", UUID(as_uuid=True), sa.ForeignKey("inventory_records.id"), nullable=False, index=True), + sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True), + sa.Column("medication_id", UUID(as_uuid=True), sa.ForeignKey("medications.id"), nullable=False, index=True), + sa.Column("previous_stock", sa.Integer, nullable=False), + sa.Column("new_stock", sa.Integer, nullable=False), + sa.Column("delta", sa.Integer, nullable=False), + sa.Column("reason", sa.String(255)), + sa.Column("source", sa.String(100)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, index=True), + ) + + op.create_table( + "technical_users", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("username", sa.String(100), unique=True, nullable=False, index=True), + sa.Column("email", sa.String(255)), + sa.Column("full_name", sa.String(255)), + sa.Column("password_hash", sa.String(255), nullable=False), + sa.Column("role", sa.String(20), default="viewer", index=True), + sa.Column("is_active", sa.Boolean, default=True, index=True), + sa.Column("scopes", JSONB, default=list), + sa.Column("last_login_at", sa.DateTime(timezone=True)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + ) + + op.create_table( + "api_clients", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("description", sa.Text), + sa.Column("owner_id", UUID(as_uuid=True), sa.ForeignKey("technical_users.id"), nullable=True, index=True), + sa.Column("is_active", sa.Boolean, default=True, index=True), + sa.Column("scopes", JSONB, default=list), + sa.Column("rate_limit", sa.Integer), + sa.Column("allowed_ips", JSONB, default=list), + sa.Column("metadata", JSONB), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + ) + + op.create_table( + "api_keys", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("api_client_id", UUID(as_uuid=True), sa.ForeignKey("api_clients.id"), nullable=False, index=True), + sa.Column("key_prefix", sa.String(10), nullable=False, index=True), + sa.Column("key_hash", sa.LargeBinary(64), nullable=False), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("is_active", sa.Boolean, default=True, index=True), + sa.Column("expires_at", sa.DateTime(timezone=True)), + sa.Column("last_used_at", sa.DateTime(timezone=True)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + op.create_index("ix_api_keys_prefix_active", "api_keys", ["key_prefix", "is_active"]) + + op.create_table( + "reservations", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True), + sa.Column("medication_id", UUID(as_uuid=True), sa.ForeignKey("medications.id"), nullable=False, index=True), + sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("technical_users.id"), nullable=True, index=True), + sa.Column("external_reference", sa.String(255)), + sa.Column("quantity", sa.Integer, nullable=False), + sa.Column("status", sa.String(20), default="pending", index=True), + sa.Column("expires_at", sa.DateTime(timezone=True), index=True), + sa.Column("confirmed_at", sa.DateTime(timezone=True)), + sa.Column("cancelled_at", sa.DateTime(timezone=True)), + sa.Column("idempotency_key", sa.String(255), unique=True, index=True), + sa.Column("metadata", JSONB), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + ) + op.create_index("ix_reservations_pharmacy_status", "reservations", ["pharmacy_id", "status"]) + op.create_index("ix_reservations_expires", "reservations", ["expires_at"]) + + op.create_table( + "sync_jobs", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True), + sa.Column("connector_id", UUID(as_uuid=True), sa.ForeignKey("connectors.id"), nullable=False, index=True), + sa.Column("strategy", sa.String(20), nullable=False), + sa.Column("status", sa.String(20), default="pending", index=True), + sa.Column("started_at", sa.DateTime(timezone=True)), + sa.Column("completed_at", sa.DateTime(timezone=True)), + sa.Column("items_total", sa.Integer), + sa.Column("items_processed", sa.Integer), + sa.Column("items_failed", sa.Integer), + sa.Column("error_message", sa.Text), + sa.Column("metadata", JSONB), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + ) + + op.create_table( + "sync_logs", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("sync_job_id", UUID(as_uuid=True), sa.ForeignKey("sync_jobs.id"), nullable=False, index=True), + sa.Column("level", sa.String(20), default="INFO", nullable=False), + sa.Column("message", sa.Text, nullable=False), + sa.Column("details", JSONB), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, index=True), + ) + + op.create_table( + "audit_logs", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("actor_id", UUID(as_uuid=True), index=True), + sa.Column("actor_type", sa.String(50)), + sa.Column("action", sa.String(100), nullable=False, index=True), + sa.Column("resource_type", sa.String(100), nullable=False, index=True), + sa.Column("resource_id", sa.String(255), index=True), + sa.Column("old_value", JSONB), + sa.Column("new_value", JSONB), + sa.Column("ip_address", sa.String(45)), + sa.Column("user_agent", sa.String(500)), + sa.Column("request_id", sa.String(100), index=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, index=True), + ) + op.create_index("ix_audit_logs_action_resource", "audit_logs", ["action", "resource_type"]) + op.create_index("ix_audit_logs_actor_created", "audit_logs", ["actor_id", "created_at"]) + + +def downgrade() -> None: + op.drop_table("audit_logs") + op.drop_table("sync_logs") + op.drop_table("sync_jobs") + op.drop_table("reservations") + op.drop_table("api_keys") + op.drop_table("api_clients") + op.drop_table("technical_users") + op.drop_table("inventory_history") + op.drop_table("inventory_records") + op.drop_table("medication_catalog") + op.drop_table("medications") + op.drop_table("connector_configs") + op.drop_table("connector_versions") + op.drop_column("pharmacies", "connector_id") + op.drop_table("connectors") + op.drop_table("pharmacies") + op.drop_table("erp_systems") diff --git a/pip-platform/docker-compose.runtime.yml b/pip-platform/docker-compose.runtime.yml new file mode 100644 index 0000000..dc7c6c4 --- /dev/null +++ b/pip-platform/docker-compose.runtime.yml @@ -0,0 +1,114 @@ +services: + pip-api: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "8000:8000" + environment: + DATABASE_URL: postgresql+asyncpg://pip:${PG_PASSWORD:-pip-secret}@postgres:5432/pip + REDIS_URL: redis://redis:6379/0 + RABBITMQ_URL: amqp://pip:${RABBITMQ_PASSWORD:-pip-secret}@rabbitmq:5672/pip + JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-me-in-production} + NODE_ENV: production + LOG_LEVEL: INFO + LOG_JSON_FORMAT: "true" + CORS_ORIGINS: '["*"]' + OTEL_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4317 + OTEL_TRACES_ENABLED: "true" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + rabbitmq: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import httpx; r = httpx.get('http://localhost:8000/api/v1/system/health'); exit(0 if r.status_code == 200 else 1)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + deploy: + resources: + limits: + cpus: "2.0" + memory: 1G + reservations: + cpus: "0.5" + memory: 256M + + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: pip + POSTGRES_USER: pip + POSTGRES_PASSWORD: ${PG_PASSWORD:-pip-secret} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U pip -d pip"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + command: > + postgres + -c max_connections=200 + -c shared_buffers=256MB + -c effective_cache_size=768MB + -c work_mem=4MB + -c maintenance_work_mem=64MB + -c checkpoint_completion_target=0.9 + -c wal_buffers=16MB + -c default_statistics_target=100 + -c random_page_cost=1.1 + -c effective_io_concurrency=200 + -c max_worker_processes=8 + + redis: + image: redis:7-alpine + restart: unless-stopped + ports: + - "6380:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + command: > + redis-server + --maxmemory 256mb + --maxmemory-policy allkeys-lru + --appendonly yes + --appendfsync everysec + + rabbitmq: + image: rabbitmq:3-management-alpine + restart: unless-stopped + environment: + RABBITMQ_DEFAULT_USER: pip + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-pip-secret} + RABBITMQ_DEFAULT_VHOST: pip + ports: + - "5672:5672" + - "15672:15672" + volumes: + - rabbitmq_data:/var/lib/rabbitmq + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + +volumes: + postgres_data: + redis_data: + rabbitmq_data: \ No newline at end of file diff --git a/pip-platform/docker-compose.yml b/pip-platform/docker-compose.yml new file mode 100644 index 0000000..a8e17a3 --- /dev/null +++ b/pip-platform/docker-compose.yml @@ -0,0 +1,122 @@ +services: + pip-api: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "8000:8000" + environment: + DATABASE_URL: postgresql+asyncpg://pip:${PG_PASSWORD:-pip-secret}@postgres:5432/pip + REDIS_URL: redis://redis:6379/0 + RABBITMQ_URL: amqp://pip:${RABBITMQ_PASSWORD:-pip-secret}@rabbitmq:5672/pip + JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-me-in-production} + NODE_ENV: production + LOG_LEVEL: INFO + LOG_JSON_FORMAT: "true" + CORS_ORIGINS: '["*"]' + # Re-routed to the shared Grafana Alloy collector on srv84-macos. + # host.docker.internal resolves to the Docker host gateway from + # inside the container — works because this stack runs on the same + # host as the monitoring stack. + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://host.docker.internal:4317} + OTEL_TRACES_ENABLED: "true" + extra_hosts: + # `host-gateway` is the standard Docker engine alias for the bridge + # gateway (host.docker.internal is mac/Windows-native; this covers bare Linux). + - "host.docker.internal:host-gateway" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + rabbitmq: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import httpx; r = httpx.get('http://localhost:8000/api/v1/system/health'); exit(0 if r.status_code == 200 else 1)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + deploy: + resources: + limits: + cpus: "2.0" + memory: 1G + reservations: + cpus: "0.5" + memory: 256M + + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: pip + POSTGRES_USER: pip + POSTGRES_PASSWORD: ${PG_PASSWORD:-pip-secret} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U pip -d pip"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + command: > + postgres + -c max_connections=200 + -c shared_buffers=256MB + -c effective_cache_size=768MB + -c work_mem=4MB + -c maintenance_work_mem=64MB + -c checkpoint_completion_target=0.9 + -c wal_buffers=16MB + -c default_statistics_target=100 + -c random_page_cost=1.1 + -c effective_io_concurrency=200 + -c max_worker_processes=8 + + redis: + image: redis:7-alpine + restart: unless-stopped + ports: + - "6380:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + command: > + redis-server + --maxmemory 256mb + --maxmemory-policy allkeys-lru + --appendonly yes + --appendfsync everysec + + rabbitmq: + image: rabbitmq:3-management-alpine + restart: unless-stopped + environment: + RABBITMQ_DEFAULT_USER: pip + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-pip-secret} + RABBITMQ_DEFAULT_VHOST: pip + ports: + - "5672:5672" + - "15672:15672" + volumes: + - rabbitmq_data:/var/lib/rabbitmq + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + +volumes: + postgres_data: + redis_data: + rabbitmq_data: diff --git a/pip-platform/entrypoint.py b/pip-platform/entrypoint.py new file mode 100644 index 0000000..d252852 --- /dev/null +++ b/pip-platform/entrypoint.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +"""Run Alembic migrations before starting the app. + +This script is designed as a Docker entrypoint pre-migration step. +It runs `alembic upgrade head` and then starts uvicorn. +""" + +import subprocess +import sys +import os + + +def run_migrations(): + db_url = os.environ.get("DATABASE_URL_SYNC") or os.environ.get("DATABASE_URL", "").replace("+asyncpg", "+psycopg2", 1) + if not db_url: + print("WARNING: No DATABASE_URL found, skipping migrations") + return + + env = os.environ.copy() + env["DATABASE_URL_SYNC"] = db_url + + result = subprocess.run( + ["alembic", "-c", "alembic.ini", "upgrade", "head"], + env=env, + cwd="/app", + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f"Migration failed:\n{result.stdout}\n{result.stderr}") + sys.exit(1) + print(f"Migration successful:\n{result.stdout}") + + +if __name__ == "__main__": + run_migrations() + os.execvp( + "uvicorn", + ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000", + "--workers", "4", "--loop", "uvloop", "--http", "httptools"], + ) diff --git a/pip-platform/monitoring/prometheus/prometheus.yml b/pip-platform/monitoring/prometheus/prometheus.yml new file mode 100644 index 0000000..2e71a64 --- /dev/null +++ b/pip-platform/monitoring/prometheus/prometheus.yml @@ -0,0 +1,18 @@ +# Reference scrape config used by the *external* Prometheus on srv84-macos. +# This stack no longer ships its own Prometheus/Grafana — see PLAN.md and +# STATE.md for the external-integration rationale. Kept as documentation. +# +# To scrape `pip-api` directly (e.g. from a local Prometheus for debugging), +# point a static target at `host.docker.internal:8000` instead of `pip-api:8000`. +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: "pip-platform" + metrics_path: /metrics + static_configs: + - targets: ["host.docker.internal:8000"] + labels: + host: srv84-macos + role: pip-platform diff --git a/pip-platform/pyproject.toml b/pip-platform/pyproject.toml new file mode 100644 index 0000000..dc9a12e --- /dev/null +++ b/pip-platform/pyproject.toml @@ -0,0 +1,69 @@ +[project] +name = "pip-platform" +version = "0.1.0" +description = "Pharmacy Integration Platform — unified API between pharmacy ERPs and external applications" +requires-python = ">=3.13" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.32.0", + "sqlalchemy[asyncio]>=2.0.36", + "asyncpg>=0.30.0", + "alembic>=1.14.0", + "pydantic[email]>=2.10.0", + "pydantic-settings>=2.7.0", + "python-jose[cryptography]>=3.3.0", + "passlib[bcrypt]>=1.7.4", + "bcrypt>=4.2.0", + "python-multipart>=0.0.18", + "redis[hiredis]>=5.2.0", + "aio-pika>=9.6.0", + "opentelemetry-api>=1.29.0", + "opentelemetry-sdk>=1.29.0", + "opentelemetry-instrumentation-fastapi>=0.50b0", + "opentelemetry-instrumentation-sqlalchemy>=0.50b0", + "opentelemetry-instrumentation-redis>=0.50b0", + "opentelemetry-exporter-otlp>=1.29.0", + "prometheus-client>=0.21.0", + "structlog>=24.4.0", + "httpx>=0.28.0", + "click>=8.1.0", + "psycopg2-binary>=2.9.9", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3.0", + "pytest-asyncio>=0.24.0", + "pytest-cov>=6.0.0", + "httpx>=0.28.0", + "ruff>=0.8.0", + "mypy>=1.13.0", + "testcontainers[postgres,redis,rabbitmq]>=4.8.0", +] + +[tool.ruff] +target-version = "py313" +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP", "B", "SIM", "TCH", "RUF"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +addopts = "-v --tb=short" + +[tool.mypy] +python_version = "3.13" +strict = true +plugins = ["pydantic.mypy"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] + +[project.scripts] +pip = "src.cli:cli" diff --git a/pip-platform/scripts/__init__.py b/pip-platform/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/__init__.py b/pip-platform/src/__init__.py new file mode 100644 index 0000000..de1660b --- /dev/null +++ b/pip-platform/src/__init__.py @@ -0,0 +1,3 @@ +from .main import app + +__all__ = ["app"] diff --git a/pip-platform/src/api/__init__.py b/pip-platform/src/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/api/middleware/__init__.py b/pip-platform/src/api/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/api/middleware/audit.py b/pip-platform/src/api/middleware/audit.py new file mode 100644 index 0000000..a0fe386 --- /dev/null +++ b/pip-platform/src/api/middleware/audit.py @@ -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 diff --git a/pip-platform/src/api/middleware/logging.py b/pip-platform/src/api/middleware/logging.py new file mode 100644 index 0000000..cde8dd4 --- /dev/null +++ b/pip-platform/src/api/middleware/logging.py @@ -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 diff --git a/pip-platform/src/api/middleware/rate_limit.py b/pip-platform/src/api/middleware/rate_limit.py new file mode 100644 index 0000000..40da0f6 --- /dev/null +++ b/pip-platform/src/api/middleware/rate_limit.py @@ -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" diff --git a/pip-platform/src/api/v1/__init__.py b/pip-platform/src/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/api/v1/dependencies/__init__.py b/pip-platform/src/api/v1/dependencies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/api/v1/dependencies/auth.py b/pip-platform/src/api/v1/dependencies/auth.py new file mode 100644 index 0000000..4c01c39 --- /dev/null +++ b/pip-platform/src/api/v1/dependencies/auth.py @@ -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 diff --git a/pip-platform/src/api/v1/endpoints/__init__.py b/pip-platform/src/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/api/v1/endpoints/api_clients.py b/pip-platform/src/api/v1/endpoints/api_clients.py new file mode 100644 index 0000000..74f7ed1 --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/api_clients.py @@ -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 + ] diff --git a/pip-platform/src/api/v1/endpoints/audit.py b/pip-platform/src/api/v1/endpoints/audit.py new file mode 100644 index 0000000..1c7b59a --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/audit.py @@ -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) diff --git a/pip-platform/src/api/v1/endpoints/auth.py b/pip-platform/src/api/v1/endpoints/auth.py new file mode 100644 index 0000000..dcf58a0 --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/auth.py @@ -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, + ) diff --git a/pip-platform/src/api/v1/endpoints/catalog.py b/pip-platform/src/api/v1/endpoints/catalog.py new file mode 100644 index 0000000..3188b8d --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/catalog.py @@ -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) diff --git a/pip-platform/src/api/v1/endpoints/connectors.py b/pip-platform/src/api/v1/endpoints/connectors.py new file mode 100644 index 0000000..b80e1fd --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/connectors.py @@ -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) diff --git a/pip-platform/src/api/v1/endpoints/erp_systems.py b/pip-platform/src/api/v1/endpoints/erp_systems.py new file mode 100644 index 0000000..012f5a6 --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/erp_systems.py @@ -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)) diff --git a/pip-platform/src/api/v1/endpoints/inventory.py b/pip-platform/src/api/v1/endpoints/inventory.py new file mode 100644 index 0000000..9181f3a --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/inventory.py @@ -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 + ] diff --git a/pip-platform/src/api/v1/endpoints/medications.py b/pip-platform/src/api/v1/endpoints/medications.py new file mode 100644 index 0000000..156d3f6 --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/medications.py @@ -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)) diff --git a/pip-platform/src/api/v1/endpoints/pharmacies.py b/pip-platform/src/api/v1/endpoints/pharmacies.py new file mode 100644 index 0000000..305a338 --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/pharmacies.py @@ -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)) diff --git a/pip-platform/src/api/v1/endpoints/reservations.py b/pip-platform/src/api/v1/endpoints/reservations.py new file mode 100644 index 0000000..140672a --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/reservations.py @@ -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) diff --git a/pip-platform/src/api/v1/endpoints/sync_jobs.py b/pip-platform/src/api/v1/endpoints/sync_jobs.py new file mode 100644 index 0000000..1d7f54e --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/sync_jobs.py @@ -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 + ] diff --git a/pip-platform/src/api/v1/endpoints/system.py b/pip-platform/src/api/v1/endpoints/system.py new file mode 100644 index 0000000..e810501 --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/system.py @@ -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", + }, + } diff --git a/pip-platform/src/api/v1/endpoints/users.py b/pip-platform/src/api/v1/endpoints/users.py new file mode 100644 index 0000000..6dcea42 --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/users.py @@ -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) diff --git a/pip-platform/src/api/v1/router.py b/pip-platform/src/api/v1/router.py new file mode 100644 index 0000000..d7e20db --- /dev/null +++ b/pip-platform/src/api/v1/router.py @@ -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) diff --git a/pip-platform/src/cli.py b/pip-platform/src/cli.py new file mode 100644 index 0000000..d928acf --- /dev/null +++ b/pip-platform/src/cli.py @@ -0,0 +1,117 @@ +"""CLI entrypoints for the PIP platform.""" +import asyncio +import sys + +import click + +from src.core.logging import configure_logging, get_logger +from src.infrastructure.cache.redis_cache import redis_cache +from src.infrastructure.config.settings import settings +from src.infrastructure.database.session import async_session_factory, engine +from src.infrastructure.messaging.rabbitmq import rabbitmq_client +from src.services.auth.auth_service import AuthService +from src.domain.entities import UserRole + + +@click.group() +def cli(): + """PIP platform CLI.""" + configure_logging() + + +@cli.command() +def run(): + """Start the API server.""" + import uvicorn + uvicorn.run( + "src.main:app", + host="0.0.0.0", + port=8000, + reload=True if settings.DEBUG else False, + log_level=settings.LOG_LEVEL.lower(), + ) + + +@cli.command() +def migrate_upgrade(): + """Apply database migrations.""" + from alembic.config import Config + from alembic import command + + cfg = Config("alembic.ini") + command.upgrade(cfg, "head") + click.echo("Migrations applied.") + + +@cli.command() +def migrate_downgrade(): + """Roll back the last database migration.""" + from alembic.config import Config + from alembic import command + + cfg = Config("alembic.ini") + command.downgrade(cfg, "-1") + click.echo("Rollback complete.") + + +@cli.command() +def migrate_revision(): + """Create a new database revision.""" + from alembic.config import Config + from alembic import command + + message = click.prompt("Revision message") + cfg = Config("alembic.ini") + command.revision(cfg, autogenerate=True, message=message) + click.echo("Revision created.") + + +@cli.command() +@click.option("--username", required=True) +@click.option("--password", required=True) +@click.option("--email", default=None) +@click.option("--role", default="admin", type=click.Choice([r.value for r in UserRole])) +def create_user(username: str, password: str, email: str | None, role: str): + """Create a technical user.""" + asyncio.run(_create_user(username, password, email, role)) + + +async def _create_user(username: str, password: str, email: str | None, role: str): + async with async_session_factory() as session: + service = AuthService(session) + user = await service.register( + username=username, + password=password, + email=email, + role=UserRole(role), + ) + click.echo(f"Created user '{user.username}' with role '{user.role.value}'.") + + +@cli.command() +def health(): + """Check platform health across services.""" + asyncio.run(_health()) + + +async def _health(): + logger = get_logger("cli.health") + async with engine.begin() as conn: + await conn.run_sync(lambda s: None) + click.echo("Database: OK") + + await redis_cache.connect() + ok = await redis_cache.health_check() + click.echo(f"Redis: {'OK' if ok else 'FAIL'}") + await redis_cache.disconnect() + + await rabbitmq_client.connect() + ok = await rabbitmq_client.health_check() + click.echo(f"RabbitMQ: {'OK' if ok else 'FAIL'}") + await rabbitmq_client.disconnect() + + await engine.dispose() + + +if __name__ == "__main__": + cli() diff --git a/pip-platform/src/connectors/__init__.py b/pip-platform/src/connectors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/connectors/base/__init__.py b/pip-platform/src/connectors/base/__init__.py new file mode 100644 index 0000000..5791c0f --- /dev/null +++ b/pip-platform/src/connectors/base/__init__.py @@ -0,0 +1,17 @@ +from .base_connector import ( + BaseInventoryConnector, + ConnectorResult, + StockInfo, + PharmacyInfo, + SyncResult, + ReservationResult, +) + +__all__ = [ + "BaseInventoryConnector", + "ConnectorResult", + "StockInfo", + "PharmacyInfo", + "SyncResult", + "ReservationResult", +] diff --git a/pip-platform/src/connectors/base/base_connector.py b/pip-platform/src/connectors/base/base_connector.py new file mode 100644 index 0000000..54c9d1c --- /dev/null +++ b/pip-platform/src/connectors/base/base_connector.py @@ -0,0 +1,78 @@ +from abc import ABC, abstractmethod +from typing import Any +from uuid import UUID + +from pydantic import BaseModel + + +class ConnectorResult(BaseModel): + success: bool + data: Any | None = None + error: str | None = None + metadata: dict | None = None + + +class StockInfo(BaseModel): + medication_id: UUID + nregistro: str + name: str + stock: int + reserved_stock: int = 0 + available_stock: int = 0 + price: float | None = None + last_updated: str | None = None + + +class PharmacyInfo(BaseModel): + pharmacy_id: UUID + code: str + name: str + address: str | None = None + phone: str | None = None + email: str | None = None + opening_hours: dict | None = None + latitude: float | None = None + longitude: float | None = None + + +class SyncResult(BaseModel): + success: bool + items_total: int = 0 + items_processed: int = 0 + items_failed: int = 0 + error_message: str | None = None + metadata: dict | None = None + + +class ReservationResult(BaseModel): + success: bool + reservation_id: str | None = None + error: str | None = None + + +class BaseInventoryConnector(ABC): + connector_type: str = "base" + + @abstractmethod + async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult: + ... + + @abstractmethod + async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult: + ... + + @abstractmethod + async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult: + ... + + @abstractmethod + async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult: + ... + + @abstractmethod + async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult: + ... + + @abstractmethod + async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult: + ... diff --git a/pip-platform/src/connectors/farmatic/__init__.py b/pip-platform/src/connectors/farmatic/__init__.py new file mode 100644 index 0000000..1ffc44b --- /dev/null +++ b/pip-platform/src/connectors/farmatic/__init__.py @@ -0,0 +1,3 @@ +from src.connectors.farmatic.farmatic_connector import FarmaticConnector + +__all__ = ["FarmaticConnector"] diff --git a/pip-platform/src/connectors/farmatic/farmatic_connector.py b/pip-platform/src/connectors/farmatic/farmatic_connector.py new file mode 100644 index 0000000..480ec61 --- /dev/null +++ b/pip-platform/src/connectors/farmatic/farmatic_connector.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from src.connectors.sdk.base_sdk import SDKConnector +from src.connectors.sdk.context import ConnectorContext +from src.connectors.base import ConnectorResult +from src.core.exceptions import ConnectorException +from src.core.logging import get_logger +from src.core.metrics import CONNECTOR_OPERATIONS + +logger = get_logger("farmatic_connector") + + +class FarmaticConnector(SDKConnector): + connector_type: str = "farmatic" + + def configure(self, config: dict[str, Any]) -> None: + self._base_url = config.get("base_url", "https://erp.farmatic.local/api/v1") + self._timeout = config.get("timeout", 45.0) + super().configure(config) + + async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult: + try: + params: dict[str, Any] = {"pharmacy": str(pharmacy_id)} + if nregistro: + params["nregistro"] = nregistro + if medication_id: + params["medication_id"] = str(medication_id) + + response = await self._request("GET", "/stock", params=params) + data = response.json() + + self._record_metric("get_stock", "success") + return ConnectorResult(success=True, data=data) + except Exception as exc: + self._record_metric("get_stock", "error") + logger.error("farmatic_get_stock_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)}) + return ConnectorResult(success=False, error=str(exc)) + + async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult: + try: + payload: dict[str, Any] = { + "pharmacy_id": str(pharmacy_id), + "medication_id": str(medication_id), + "quantity": quantity, + } + if idempotency_key: + payload["idempotency_key"] = idempotency_key + + response = await self._request("POST", "/reservations", json=payload) + data = response.json() + + self._record_metric("reserve", "success") + return ConnectorResult(success=True, data=data) + except Exception as exc: + self._record_metric("reserve", "error") + logger.error("farmatic_reserve_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)}) + return ConnectorResult(success=False, error=str(exc)) + + async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult: + try: + response = await self._request("DELETE", f"/reservations/{reservation_id}", json={"pharmacy_id": str(pharmacy_id)}) + data = response.json() + + self._record_metric("cancel_reservation", "success") + return ConnectorResult(success=True, data=data) + except Exception as exc: + self._record_metric("cancel_reservation", "error") + logger.error("farmatic_cancel_error", extra={"reservation_id": reservation_id, "error": str(exc)}) + return ConnectorResult(success=False, error=str(exc)) + + async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult: + try: + response = await self._request("GET", f"/pharmacies/{pharmacy_id}") + data = response.json() + + self._record_metric("pharmacy_information", "success") + return ConnectorResult(success=True, data=data) + except Exception as exc: + self._record_metric("pharmacy_information", "error") + logger.error("farmatic_pharmacy_info_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)}) + return ConnectorResult(success=False, error=str(exc)) + + async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult: + try: + payload: dict[str, Any] = { + "pharmacy_id": str(pharmacy_id), + "strategy": strategy, + } + if since: + payload["since"] = since + + response = await self._request("POST", "/sync", json=payload) + data = response.json() + + self._record_metric("synchronize", "success") + return ConnectorResult(success=True, data=data) + except Exception as exc: + self._record_metric("synchronize", "error") + logger.error("farmatic_sync_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)}) + return ConnectorResult(success=False, error=str(exc)) + + async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult: + try: + response = await self._request("GET", "/health") + data = response.json() + data["connector_type"] = self.connector_type + data["pharmacy_id"] = str(pharmacy_id) + data["circuit_breaker"] = self._circuit_breaker.state.value + + self._record_metric("heartbeat", "success") + return ConnectorResult(success=True, data=data) + except Exception as exc: + self._record_metric("heartbeat", "error") + return ConnectorResult( + success=False, + error=str(exc), + metadata={"circuit_breaker": self._circuit_breaker.state.value}, + ) diff --git a/pip-platform/src/connectors/mock/__init__.py b/pip-platform/src/connectors/mock/__init__.py new file mode 100644 index 0000000..b05c2aa --- /dev/null +++ b/pip-platform/src/connectors/mock/__init__.py @@ -0,0 +1,3 @@ +from .mock_connector import MockConnector + +__all__ = ["MockConnector"] diff --git a/pip-platform/src/connectors/mock/mock_connector.py b/pip-platform/src/connectors/mock/mock_connector.py new file mode 100644 index 0000000..ec30abc --- /dev/null +++ b/pip-platform/src/connectors/mock/mock_connector.py @@ -0,0 +1,215 @@ +import asyncio +import random +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from src.connectors.base import BaseInventoryConnector, ConnectorResult +from src.core.logging import get_logger +from src.core.metrics import CONNECTOR_OPERATIONS + +logger = get_logger("mock_connector") + + +class MockConnector(BaseInventoryConnector): + connector_type: str = "mock" + + def __init__(self) -> None: + self._simulate_latency: bool = True + self._latency_range_ms: tuple[int, int] = (20, 150) + self._error_rate: float = 0.0 + self._stock_data: dict[str, dict[str, Any]] = {} + self._reservations: dict[str, dict[str, Any]] = {} + self._pharmacies: dict[str, dict[str, Any]] = {} + self._init_mock_data() + + def configure(self, config: dict[str, Any]) -> None: + self._simulate_latency = config.get("simulate_latency", True) + self._latency_range_ms = tuple(config.get("latency_range_ms", [20, 150])) + self._error_rate = config.get("error_rate", 0.0) + + def _init_mock_data(self) -> None: + medicamentos = [ + ("00001", "Ibuprofeno 600mg", 100), + ("00002", "Paracetamol 1g", 50), + ("00003", "Amoxicilina 500mg", 200), + ("00004", "Omeprazol 20mg", 75), + ("00005", "Loratadina 10mg", 120), + ("00006", "Diazepam 5mg", 30), + ("00007", "Metformina 850mg", 250), + ("00008", "Atorvastatina 20mg", 80), + ("00009", "Salbutamol Inhalador", 15), + ("00010", "Insulina Glargina", 40), + ] + for nregistro, name, stock in medicamentos: + reserved = random.randint(0, max(stock // 10, 1)) + self._stock_data[nregistro] = { + "nregistro": nregistro, + "name": name, + "stock": stock, + "reserved_stock": reserved, + "available_stock": stock - reserved, + "price": round(random.uniform(2.5, 45.0), 2), + "last_updated": datetime.now(timezone.utc).isoformat(), + } + + self._pharmacies["default"] = { + "code": "FARM-001", + "name": "Farmacia Mock Central", + "address": "Calle Mayor 15, Madrid", + "phone": "+34912345678", + "email": "central@mock-farmacia.es", + "opening_hours": { + "mon_fri": ["09:00", "21:30"], + "sat": ["10:00", "14:00"], + "sun": "closed", + }, + "latitude": 40.4168, + "longitude": -3.7038, + } + self._pharmacies["second"] = { + "code": "FARM-002", + "name": "Farmacia Mock Norte", + "address": "Av. de la Ilustración 1, Madrid", + "phone": "+34915432100", + "email": "norte@mock-farmacia.es", + "opening_hours": { + "mon_fri": ["08:30", "22:00"], + "sat": ["09:00", "22:00"], + "sun": ["10:00", "14:00"], + }, + "latitude": 40.4722, + "longitude": -3.6934, + } + + async def _maybe_delay(self) -> None: + if self._simulate_latency: + low, high = self._latency_range_ms + delay = random.randint(low, high) / 1000.0 + await asyncio.sleep(delay) + + def _maybe_error(self) -> None: + if self._error_rate > 0 and random.random() < self._error_rate: + error_types = [ + ("TIMEOUT", "Connection timed out"), + ("CONNECTION_REFUSED", "ERP server refused connection"), + ("AUTH_FAILED", "Authentication failed"), + ("RATE_LIMITED", "ERP rate limit exceeded"), + ("INTERNAL_ERROR", "ERP internal server error"), + ] + code, msg = random.choice(error_types) + from src.core.exceptions import ConnectorException + raise ConnectorException(self.connector_type, msg, details={"error_code": code}) + + def _metric(self, operation: str, status: str) -> None: + CONNECTOR_OPERATIONS.labels(connector_type=self.connector_type, operation=operation, status=status).inc() + + async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult: + await self._maybe_delay() + self._maybe_error() + logger.info("mock_get_stock", extra={"pharmacy_id": str(pharmacy_id), "nregistro": nregistro}) + + if nregistro and nregistro in self._stock_data: + data = dict(self._stock_data[nregistro]) + data["pharmacy_id"] = str(pharmacy_id) + self._metric("get_stock", "success") + return ConnectorResult(success=True, data=data) + + if nregistro is None and medication_id is None: + results = [dict(v) for v in self._stock_data.values()] + for r in results: + r["pharmacy_id"] = str(pharmacy_id) + self._metric("get_stock", "success") + return ConnectorResult(success=True, data={"items": results, "total": len(results)}) + + self._metric("get_stock", "not_found") + return ConnectorResult(success=False, error=f"Medication '{nregistro}' not found in mock data") + + async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult: + await self._maybe_delay() + self._maybe_error() + logger.info("mock_reserve", extra={"pharmacy_id": str(pharmacy_id), "quantity": quantity}) + + if idempotency_key and idempotency_key in self._reservations: + self._metric("reserve", "idempotent") + return ConnectorResult(success=True, data=self._reservations[idempotency_key]) + + reservation_id = f"mock-res-{idempotency_key or random.randint(100000, 999999)}" + reservation_data = { + "reservation_id": reservation_id, + "status": "confirmed", + "quantity": quantity, + "pharmacy_id": str(pharmacy_id), + "medication_id": str(medication_id), + "confirmed_at": datetime.now(timezone.utc).isoformat(), + "expires_at": datetime.now(timezone.utc).isoformat(), + } + self._reservations[reservation_id] = reservation_data + if idempotency_key: + self._reservations[idempotency_key] = reservation_data + + self._metric("reserve", "success") + return ConnectorResult(success=True, data=reservation_data) + + async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult: + await self._maybe_delay() + self._maybe_error() + logger.info("mock_cancel_reservation", extra={"reservation_id": reservation_id}) + + if reservation_id in self._reservations: + self._reservations[reservation_id]["status"] = "cancelled" + self._metric("cancel_reservation", "success") + return ConnectorResult(success=True, data={"reservation_id": reservation_id, "status": "cancelled"}) + + self._metric("cancel_reservation", "not_found") + return ConnectorResult(success=False, error=f"Reservation '{reservation_id}' not found") + + async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult: + await self._maybe_delay() + self._maybe_error() + logger.info("mock_pharmacy_information", extra={"pharmacy_id": str(pharmacy_id)}) + + pharmacy = list(self._pharmacies.values())[0] + result = dict(pharmacy) + result["pharmacy_id"] = str(pharmacy_id) + self._metric("pharmacy_information", "success") + return ConnectorResult(success=True, data=result) + + async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult: + await self._maybe_delay() + self._maybe_error() + logger.info("mock_synchronize", extra={"pharmacy_id": str(pharmacy_id), "strategy": strategy, "since": since}) + + items_total = len(self._stock_data) + items_processed = items_total + items_failed = random.randint(0, min(2, items_total)) if self._error_rate > 0 else 0 + if items_failed > 0: + items_processed = items_total - items_failed + + self._metric("synchronize", "success") + return ConnectorResult( + success=True, + data={ + "items_total": items_total, + "items_processed": items_processed, + "items_failed": items_failed, + "strategy": strategy, + "synced_at": datetime.now(timezone.utc).isoformat(), + "pharmacy_id": str(pharmacy_id), + }, + ) + + async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult: + self._metric("heartbeat", "success") + return ConnectorResult( + success=True, + data={ + "status": "healthy", + "timestamp": datetime.now(timezone.utc).isoformat(), + "connector_type": self.connector_type, + "pharmacy_id": str(pharmacy_id), + "circuit_breaker": "closed", + "stock_items": len(self._stock_data), + "active_reservations": len([r for r in self._reservations.values() if r.get("status") == "confirmed"]), + }, + ) diff --git a/pip-platform/src/connectors/registry.py b/pip-platform/src/connectors/registry.py new file mode 100644 index 0000000..de251a4 --- /dev/null +++ b/pip-platform/src/connectors/registry.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import importlib +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from src.connectors.base import BaseInventoryConnector, ConnectorResult +from src.core.exceptions import ConnectorException +from src.core.logging import get_logger +from src.core.metrics import CONNECTOR_OPERATIONS +from src.domain.entities import ConnectorStatus, ERPType +from src.infrastructure.cache.redis_cache import redis_cache +from src.infrastructure.database.repositories import ConnectorConfigRepository, ConnectorRepository +from src.infrastructure.messaging.rabbitmq import rabbitmq_client + +logger = get_logger("connector_registry") + +_BUILTIN_CONNECTORS: dict[str, str] = { + ERPType.MOCK.value: "src.connectors.mock.mock_connector.MockConnector", + ERPType.FARMATIC.value: "src.connectors.farmatic.farmatic_connector.FarmaticConnector", + ERPType.CUSTOM_REST.value: "src.connectors.rest.rest_connector.RESTConnector", +} + + +class ConnectorRegistry: + _instance: ConnectorRegistry | None = None + _connectors: dict[str, BaseInventoryConnector] + _instances: dict[UUID, BaseInventoryConnector] + + def __init__(self) -> None: + self._connectors = {} + self._instances = {} + + @classmethod + def get_registry(cls) -> ConnectorRegistry: + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def register(self, connector_type: str, connector: BaseInventoryConnector) -> None: + self._connectors[connector_type] = connector + logger.info("connector_registered", extra={"connector_type": connector_type}) + + def unregister(self, connector_type: str) -> None: + self._connectors.pop(connector_type, None) + logger.info("connector_unregistered", extra={"connector_type": connector_type}) + + def get_connector_class(self, connector_type: str) -> type[BaseInventoryConnector]: + if connector_type in self._connectors: + return type(self._connectors[connector_type]) + + class_path = _BUILTIN_CONNECTORS.get(connector_type) + if not class_path: + raise ConnectorException(connector_type, f"No connector registered for type '{connector_type}'") + + try: + module_path, class_name = class_path.rsplit(".", 1) + module = importlib.import_module(module_path) + cls = getattr(module, class_name) + self._connectors[connector_type] = cls() + return cls + except (ImportError, AttributeError) as e: + raise ConnectorException(connector_type, f"Failed to load connector '{connector_type}': {e}") from e + + def create_instance(self, connector_type: str, config: dict[str, Any] | None = None) -> BaseInventoryConnector: + cls = self.get_connector_class(connector_type) + instance = cls() + if config and hasattr(instance, "configure"): + instance.configure(config) + return instance + + def get_or_create_instance(self, connector_id: UUID, connector_type: str, config: dict[str, Any] | None = None) -> BaseInventoryConnector: + if connector_id in self._instances: + return self._instances[connector_id] + instance = self.create_instance(connector_type, config) + self._instances[connector_id] = instance + return instance + + def remove_instance(self, connector_id: UUID) -> None: + self._instances.pop(connector_id, None) + + def list_registered_types(self) -> list[str]: + all_types = set(self._connectors.keys()) | set(_BUILTIN_CONNECTORS.keys()) + return sorted(all_types) + + @staticmethod + async def record_heartbeat(repo: ConnectorRepository, connector_id: UUID) -> None: + entity = await repo.get_by_id(connector_id) + if entity: + entity.last_heartbeat_at = datetime.now(timezone.utc) + entity.status = ConnectorStatus.ACTIVE + entity.last_error = None + await repo.update(entity) + + @staticmethod + async def record_error(repo: ConnectorRepository, connector_id: UUID, error: str) -> None: + entity = await repo.get_by_id(connector_id) + if entity: + entity.last_error = error + entity.status = ConnectorStatus.ERROR + await repo.update(entity) + + +connector_registry = ConnectorRegistry.get_registry() diff --git a/pip-platform/src/connectors/rest/__init__.py b/pip-platform/src/connectors/rest/__init__.py new file mode 100644 index 0000000..53a0439 --- /dev/null +++ b/pip-platform/src/connectors/rest/__init__.py @@ -0,0 +1,3 @@ +from src.connectors.rest.rest_connector import RESTConnector + +__all__ = ["RESTConnector"] diff --git a/pip-platform/src/connectors/rest/rest_connector.py b/pip-platform/src/connectors/rest/rest_connector.py new file mode 100644 index 0000000..3937818 --- /dev/null +++ b/pip-platform/src/connectors/rest/rest_connector.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from src.connectors.sdk.base_sdk import SDKConnector +from src.connectors.base import ConnectorResult +from src.core.logging import get_logger + +logger = get_logger("rest_connector") + + +class RESTConnector(SDKConnector): + connector_type: str = "custom_rest" + + _STOCK_PATH: str = "/stock" + _RESERVE_PATH: str = "/reservations" + _PHARMACY_PATH: str = "/pharmacy" + _SYNC_PATH: str = "/sync" + _HEALTH_PATH: str = "/health" + + def configure(self, config: dict[str, Any]) -> None: + self._base_url = config.get("base_url", "") + self._timeout = config.get("timeout", 30.0) + self._STOCK_PATH = config.get("stock_path", self._STOCK_PATH) + self._RESERVE_PATH = config.get("reserve_path", self._RESERVE_PATH) + self._PHARMACY_PATH = config.get("pharmacy_path", self._PHARMACY_PATH) + self._SYNC_PATH = config.get("sync_path", self._SYNC_PATH) + self._HEALTH_PATH = config.get("health_path", self._HEALTH_PATH) + super().configure(config) + + async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult: + try: + params: dict[str, Any] = {"pharmacy_id": str(pharmacy_id)} + if nregistro: + params["nregistro"] = nregistro + if medication_id: + params["medication_id"] = str(medication_id) + response = await self._request("GET", self._STOCK_PATH, params=params) + self._record_metric("get_stock", "success") + return ConnectorResult(success=True, data=response.json()) + except Exception as exc: + self._record_metric("get_stock", "error") + return ConnectorResult(success=False, error=str(exc)) + + async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult: + try: + payload: dict[str, Any] = { + "pharmacy_id": str(pharmacy_id), + "medication_id": str(medication_id), + "quantity": quantity, + } + if idempotency_key: + payload["idempotency_key"] = idempotency_key + response = await self._request("POST", self._RESERVE_PATH, json=payload) + self._record_metric("reserve", "success") + return ConnectorResult(success=True, data=response.json()) + except Exception as exc: + self._record_metric("reserve", "error") + return ConnectorResult(success=False, error=str(exc)) + + async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult: + try: + response = await self._request("DELETE", f"{self._RESERVE_PATH}/{reservation_id}", json={"pharmacy_id": str(pharmacy_id)}) + self._record_metric("cancel_reservation", "success") + return ConnectorResult(success=True, data=response.json()) + except Exception as exc: + self._record_metric("cancel_reservation", "error") + return ConnectorResult(success=False, error=str(exc)) + + async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult: + try: + response = await self._request("GET", f"{self._PHARMACY_PATH}/{pharmacy_id}") + self._record_metric("pharmacy_information", "success") + return ConnectorResult(success=True, data=response.json()) + except Exception as exc: + self._record_metric("pharmacy_information", "error") + return ConnectorResult(success=False, error=str(exc)) + + async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult: + try: + payload: dict[str, Any] = {"pharmacy_id": str(pharmacy_id), "strategy": strategy} + if since: + payload["since"] = since + response = await self._request("POST", self._SYNC_PATH, json=payload) + self._record_metric("synchronize", "success") + return ConnectorResult(success=True, data=response.json()) + except Exception as exc: + self._record_metric("synchronize", "error") + return ConnectorResult(success=False, error=str(exc)) + + async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult: + try: + response = await self._request("GET", self._HEALTH_PATH) + data = response.json() + data["connector_type"] = self.connector_type + data["pharmacy_id"] = str(pharmacy_id) + data["circuit_breaker"] = self._circuit_breaker.state.value + self._record_metric("heartbeat", "success") + return ConnectorResult(success=True, data=data) + except Exception as exc: + self._record_metric("heartbeat", "error") + return ConnectorResult(success=False, error=str(exc)) diff --git a/pip-platform/src/connectors/sdk/__init__.py b/pip-platform/src/connectors/sdk/__init__.py new file mode 100644 index 0000000..e96b3df --- /dev/null +++ b/pip-platform/src/connectors/sdk/__init__.py @@ -0,0 +1,19 @@ +from .context import ConnectorContext +from .auth import AuthHandler, BasicAuthHandler, BearerAuthHandler, APIKeyAuthHandler, OAuth2Handler +from .retry import RetryPolicy, with_retry +from .circuit_breaker import CircuitBreaker, CircuitState +from .base_sdk import SDKConnector + +__all__ = [ + "APIKeyAuthHandler", + "AuthHandler", + "BasicAuthHandler", + "BearerAuthHandler", + "CircuitBreaker", + "CircuitState", + "ConnectorContext", + "OAuth2Handler", + "RetryPolicy", + "SDKConnector", + "with_retry", +] diff --git a/pip-platform/src/connectors/sdk/auth.py b/pip-platform/src/connectors/sdk/auth.py new file mode 100644 index 0000000..908fcf0 --- /dev/null +++ b/pip-platform/src/connectors/sdk/auth.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + +import httpx + +from src.connectors.sdk.context import ConnectorContext +from src.core.logging import get_logger + +logger = get_logger("connector_auth") + + +class AuthHandler(ABC): + @abstractmethod + async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request: + ... + + @abstractmethod + async def refresh(self, context: ConnectorContext) -> bool: + return True + + +class BasicAuthHandler(AuthHandler): + def __init__(self, username_key: str = "username", password_key: str = "password") -> None: + self._username_key = username_key + self._password_key = password_key + + async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request: + import base64 + + username = context.get_secret(self._username_key) or context.get_config(self._username_key, "") + password = context.get_secret(self._password_key) or context.get_config(self._password_key, "") + credentials = base64.b64encode(f"{username}:{password}".encode()).decode() + request.headers["Authorization"] = f"Basic {credentials}" + return request + + async def refresh(self, context: ConnectorContext) -> bool: + return True + + +class BearerAuthHandler(AuthHandler): + def __init__(self, token_key: str = "access_token") -> None: + self._token_key = token_key + self._token: str | None = None + + async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request: + token = self._token or context.get_secret(self._token_key) or context.get_config(self._token_key, "") + if token: + request.headers["Authorization"] = f"Bearer {token}" + return request + + async def refresh(self, context: ConnectorContext) -> bool: + self._token = None + return True + + +class APIKeyAuthHandler(AuthHandler): + def __init__(self, header_name: str = "X-API-Key", key_secret: str = "api_key") -> None: + self._header_name = header_name + self._key_secret = key_secret + + async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request: + api_key = context.get_secret(self._key_secret) or context.get_config(self._key_secret, "") + if api_key: + request.headers[self._header_name] = api_key + return request + + async def refresh(self, context: ConnectorContext) -> bool: + return True + + +class OAuth2Handler(AuthHandler): + def __init__( + self, + token_url_key: str = "token_url", + client_id_key: str = "client_id", + client_secret_key: str = "client_secret", + ) -> None: + self._token_url_key = token_url_key + self._client_id_key = client_id_key + self._client_secret_key = client_secret_key + self._access_token: str | None = None + self._token_expires: float = 0 + + async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request: + import time + + if not self._access_token or time.time() >= self._token_expires: + await self._fetch_token(context) + + if self._access_token: + request.headers["Authorization"] = f"Bearer {self._access_token}" + return request + + async def _fetch_token(self, context: ConnectorContext) -> None: + import time + + token_url = context.require_config(self._token_url_key) + client_id = context.require_secret(self._client_id_key) + client_secret = context.require_secret(self._client_secret_key) + + async with httpx.AsyncClient() as client: + resp = await client.post( + token_url, + data={ + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + resp.raise_for_status() + data = resp.json() + self._access_token = data["access_token"] + self._token_expires = time.time() + data.get("expires_in", 3600) - 30 + + logger.info("oauth2_token_acquired", extra={"token_url": token_url}) + + async def refresh(self, context: ConnectorContext) -> bool: + self._access_token = None + self._token_expires = 0 + return True diff --git a/pip-platform/src/connectors/sdk/base_sdk.py b/pip-platform/src/connectors/sdk/base_sdk.py new file mode 100644 index 0000000..b270053 --- /dev/null +++ b/pip-platform/src/connectors/sdk/base_sdk.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from abc import abstractmethod +from typing import Any +from uuid import UUID + +import httpx + +from src.connectors.base import BaseInventoryConnector, ConnectorResult +from src.connectors.sdk.auth import AuthHandler, BearerAuthHandler +from src.connectors.sdk.circuit_breaker import CircuitBreaker +from src.connectors.sdk.context import ConnectorContext +from src.connectors.sdk.retry import RetryPolicy, with_retry +from src.core.exceptions import ConnectorException +from src.core.logging import get_logger +from src.core.metrics import CONNECTOR_OPERATIONS + +logger = get_logger("sdk_connector") + + +class SDKConnector(BaseInventoryConnector): + connector_type: str = "sdk" + + def __init__(self) -> None: + self._context: ConnectorContext | None = None + self._auth_handler: AuthHandler = BearerAuthHandler() + self._retry_policy: RetryPolicy = RetryPolicy() + self._circuit_breaker: CircuitBreaker = CircuitBreaker(name=self.connector_type) + self._http_client: httpx.AsyncClient | None = None + self._base_url: str = "" + self._timeout: float = 30.0 + + def configure(self, config: dict[str, Any]) -> None: + self._base_url = config.get("base_url", "") + self._timeout = config.get("timeout", 30.0) + auth_type = config.get("auth_type", "bearer") + if auth_type == "basic": + from src.connectors.sdk.auth import BasicAuthHandler + self._auth_handler = BasicAuthHandler() + elif auth_type == "api_key": + from src.connectors.sdk.auth import APIKeyAuthHandler + self._auth_handler = APIKeyAuthHandler( + header_name=config.get("api_key_header", "X-API-Key"), + key_secret=config.get("api_key_secret_key", "api_key"), + ) + elif auth_type == "oauth2": + from src.connectors.sdk.auth import OAuth2Handler + self._auth_handler = OAuth2Handler() + + cb_config = config.get("circuit_breaker", {}) + if cb_config: + self._circuit_breaker = CircuitBreaker( + name=self.connector_type, + failure_threshold=cb_config.get("failure_threshold", 5), + recovery_timeout=cb_config.get("recovery_timeout", 30.0), + ) + + retry_config = config.get("retry", {}) + if retry_config: + self._retry_policy = RetryPolicy( + max_attempts=retry_config.get("max_attempts", 3), + base_delay=retry_config.get("base_delay", 1.0), + max_delay=retry_config.get("max_delay", 30.0), + ) + + def set_context(self, context: ConnectorContext) -> None: + self._context = context + if context.config: + self.configure(context.config) + + @property + def http_client(self) -> httpx.AsyncClient: + if self._http_client is None or self._http_client.is_closed: + self._http_client = httpx.AsyncClient( + base_url=self._base_url, + timeout=self._timeout, + ) + return self._http_client + + async def _request( + self, + method: str, + path: str, + *, + json: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> httpx.Response: + if not self._context: + raise ConnectorException(self.connector_type, "Connector context not set. Call set_context() first.") + + async def _do_request() -> httpx.Response: + request = self.http_client.build_request(method, path, json=json, params=params, headers=headers) + request = await self._auth_handler.apply(request, self._context) + response = await self.http_client.send(request) + response.raise_for_status() + return response + + return await self._circuit_breaker.call( + with_retry, + _do_request, + policy=self._retry_policy, + connector_type=self.connector_type, + operation=f"{method}:{path}", + ) + + def _record_metric(self, operation: str, status: str) -> None: + CONNECTOR_OPERATIONS.labels(connector_type=self.connector_type, operation=operation, status=status).inc() + + async def close(self) -> None: + if self._http_client and not self._http_client.is_closed: + await self._http_client.aclose() + self._http_client = None + + @abstractmethod + async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult: + ... + + @abstractmethod + async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult: + ... + + @abstractmethod + async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult: + ... + + @abstractmethod + async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult: + ... + + @abstractmethod + async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult: + ... + + @abstractmethod + async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult: + ... diff --git a/pip-platform/src/connectors/sdk/circuit_breaker.py b/pip-platform/src/connectors/sdk/circuit_breaker.py new file mode 100644 index 0000000..deb56f1 --- /dev/null +++ b/pip-platform/src/connectors/sdk/circuit_breaker.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import time +from enum import Enum +from typing import Any, Callable + +from src.core.exceptions import ConnectorException, ServiceUnavailableException +from src.core.logging import get_logger + +logger = get_logger("circuit_breaker") + + +class CircuitState(str, Enum): + CLOSED = "closed" + OPEN = "open" + HALF_OPEN = "half_open" + + +class CircuitBreaker: + def __init__( + self, + name: str, + failure_threshold: int = 5, + recovery_timeout: float = 30.0, + half_open_max_calls: int = 1, + ) -> None: + self._name = name + self._failure_threshold = failure_threshold + self._recovery_timeout = recovery_timeout + self._half_open_max_calls = half_open_max_calls + + self._state = CircuitState.CLOSED + self._failure_count = 0 + self._success_count = 0 + self._last_failure_time: float = 0 + self._half_open_calls: int = 0 + + @property + def state(self) -> CircuitState: + if self._state == CircuitState.OPEN: + if time.monotonic() - self._last_failure_time >= self._recovery_timeout: + self._state = CircuitState.HALF_OPEN + self._half_open_calls = 0 + logger.info("circuit_half_open", extra={"name": self._name}) + return self._state + + async def call(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: + current_state = self.state + + if current_state == CircuitState.OPEN: + raise ServiceUnavailableException( + f"circuit_breaker:{self._name}", + f"Circuit breaker '{self._name}' is open. Retry after {self._recovery_timeout}s.", + ) + + if current_state == CircuitState.HALF_OPEN: + if self._half_open_calls >= self._half_open_max_calls: + raise ServiceUnavailableException( + f"circuit_breaker:{self._name}", + f"Circuit breaker '{self._name}' is half-open with max concurrent calls reached.", + ) + self._half_open_calls += 1 + + try: + result = await func(*args, **kwargs) + self._on_success() + return result + except Exception as exc: + self._on_failure(exc) + raise + + def _on_success(self) -> None: + if self._state == CircuitState.HALF_OPEN: + self._state = CircuitState.CLOSED + logger.info("circuit_closed", extra={"name": self._name}) + self._failure_count = 0 + self._success_count += 1 + + def _on_failure(self, exc: Exception) -> None: + self._failure_count += 1 + self._last_failure_time = time.monotonic() + self._success_count = 0 + + if self._state == CircuitState.HALF_OPEN: + self._state = CircuitState.OPEN + logger.warning("circuit_reopened", extra={"name": self._name, "error": str(exc)}) + elif self._failure_count >= self._failure_threshold: + self._state = CircuitState.OPEN + logger.warning("circuit_opened", extra={"name": self._name, "failure_count": self._failure_count}) + + def reset(self) -> None: + self._state = CircuitState.CLOSED + self._failure_count = 0 + self._success_count = 0 + self._last_failure_time = 0 + + def get_status(self) -> dict[str, Any]: + return { + "name": self._name, + "state": self.state.value, + "failure_count": self._failure_count, + "success_count": self._success_count, + "failure_threshold": self._failure_threshold, + "recovery_timeout": self._recovery_timeout, + "last_failure_time": self._last_failure_time, + } diff --git a/pip-platform/src/connectors/sdk/context.py b/pip-platform/src/connectors/sdk/context.py new file mode 100644 index 0000000..1d95bfd --- /dev/null +++ b/pip-platform/src/connectors/sdk/context.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any +from uuid import UUID + + +@dataclass +class ConnectorContext: + connector_id: UUID + pharmacy_id: UUID + erp_system_id: UUID + connector_type: str + config: dict[str, Any] = field(default_factory=dict) + secrets: dict[str, str] = field(default_factory=dict) + + _cache_prefix: str = field(default="connector", init=False, repr=False) + + @property + def cache_key_prefix(self) -> str: + return f"{self._cache_prefix}:{self.connector_type}:{self.pharmacy_id}" + + def cache_key(self, suffix: str) -> str: + return f"{self.cache_key_prefix}:{suffix}" + + def get_config(self, key: str, default: Any = None) -> Any: + return self.config.get(key, default) + + def get_secret(self, key: str) -> str | None: + return self.secrets.get(key) + + def require_secret(self, key: str) -> str: + value = self.secrets.get(key) + if value is None: + raise ValueError(f"Required secret '{key}' not found in connector context") + return value + + def require_config(self, key: str) -> Any: + value = self.config.get(key) + if value is None: + raise ValueError(f"Required config '{key}' not found in connector context") + return value diff --git a/pip-platform/src/connectors/sdk/retry.py b/pip-platform/src/connectors/sdk/retry.py new file mode 100644 index 0000000..a21b7d5 --- /dev/null +++ b/pip-platform/src/connectors/sdk/retry.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from functools import wraps +from typing import Any, Callable, TypeVar + +from src.core.exceptions import ConnectorException +from src.core.logging import get_logger + +logger = get_logger("connector_retry") + +T = TypeVar("T") + +DEFAULT_RETRYABLE_EXCEPTIONS = (ConnectionError, TimeoutError, OSError) + + +@dataclass +class RetryPolicy: + max_attempts: int = 3 + base_delay: float = 1.0 + max_delay: float = 30.0 + exponential_base: float = 2.0 + jitter: bool = True + retryable_exceptions: tuple[type[Exception], ...] = field(default_factory=lambda: DEFAULT_RETRYABLE_EXCEPTIONS) + + def delay_for_attempt(self, attempt: int) -> float: + import random + + delay = min(self.base_delay * (self.exponential_base ** (attempt - 1)), self.max_delay) + if self.jitter: + delay = delay * (0.5 + random.random() * 0.5) + return delay + + def should_retry(self, exception: Exception) -> bool: + if isinstance(exception, ConnectorException): + return False + return isinstance(exception, self.retryable_exceptions) + + +async def with_retry( + func: Callable[..., Any], + *args: Any, + policy: RetryPolicy | None = None, + connector_type: str = "unknown", + operation: str = "unknown", + **kwargs: Any, +) -> Any: + p = policy or RetryPolicy() + last_exception: Exception | None = None + + for attempt in range(1, p.max_attempts + 1): + try: + result = await func(*args, **kwargs) + if attempt > 1: + logger.info("retry_succeeded", extra={"connector_type": connector_type, "operation": operation, "attempt": attempt}) + return result + except Exception as exc: + last_exception = exc + if not p.should_retry(exc) or attempt >= p.max_attempts: + break + delay = p.delay_for_attempt(attempt) + logger.warning( + "retry_attempt", + extra={ + "connector_type": connector_type, + "operation": operation, + "attempt": attempt, + "delay_s": round(delay, 2), + "error": str(exc), + }, + ) + await asyncio.sleep(delay) + + raise last_exception # type: ignore[misc] + + +def retry(policy: RetryPolicy | None = None): + p = policy or RetryPolicy() + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + connector_type = getattr(args[0], "connector_type", "unknown") if args else "unknown" + return await with_retry(func, *args, policy=p, connector_type=connector_type, operation=func.__name__, **kwargs) + return wrapper + return decorator diff --git a/pip-platform/src/core/__init__.py b/pip-platform/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/core/exceptions.py b/pip-platform/src/core/exceptions.py new file mode 100644 index 0000000..14418a7 --- /dev/null +++ b/pip-platform/src/core/exceptions.py @@ -0,0 +1,77 @@ +from datetime import datetime, timezone + + +class PIPException(Exception): + def __init__(self, message: str, code: str = "PIP_ERROR", status_code: int = 500, details: dict | None = None): + super().__init__(message) + self.message = message + self.code = code + self.status_code = status_code + self.details = details or {} + self.timestamp = datetime.now(timezone.utc).isoformat() + + def to_dict(self) -> dict: + return { + "error": { + "code": self.code, + "message": self.message, + "details": self.details, + "timestamp": self.timestamp, + } + } + + +class NotFoundException(PIPException): + def __init__(self, resource: str, resource_id: str | None = None): + msg = f"{resource} not found" + if resource_id: + msg = f"{resource} '{resource_id}' not found" + super().__init__(message=msg, code="NOT_FOUND", status_code=404, details={"resource": resource, "resource_id": resource_id}) + + +class ConflictException(PIPException): + def __init__(self, message: str, details: dict | None = None): + super().__init__(message=message, code="CONFLICT", status_code=409, details=details) + + +class ValidationException(PIPException): + def __init__(self, message: str, details: dict | None = None): + super().__init__(message=message, code="VALIDATION_ERROR", status_code=422, details=details) + + +class UnauthorizedException(PIPException): + def __init__(self, message: str = "Authentication required"): + super().__init__(message=message, code="UNAUTHORIZED", status_code=401) + + +class ForbiddenException(PIPException): + def __init__(self, message: str = "Insufficient permissions", required_role: str | None = None): + details = {} + if required_role: + details["required_role"] = required_role + super().__init__(message=message, code="FORBIDDEN", status_code=403, details=details) + + +class RateLimitException(PIPException): + def __init__(self, message: str = "Rate limit exceeded"): + super().__init__(message=message, code="RATE_LIMIT_EXCEEDED", status_code=429) + + +class ServiceUnavailableException(PIPException): + def __init__(self, service: str, message: str | None = None): + msg = message or f"Service '{service}' is unavailable" + super().__init__(message=msg, code="SERVICE_UNAVAILABLE", status_code=503, details={"service": service}) + + +class ConnectorException(PIPException): + def __init__(self, connector_type: str, message: str, details: dict | None = None): + d = details or {} + d["connector_type"] = connector_type + super().__init__(message=message, code="CONNECTOR_ERROR", status_code=502, details=d) + + +class SyncException(PIPException): + def __init__(self, pharmacy_id: str, message: str, details: dict | None = None): + d = details or {} + d["pharmacy_id"] = pharmacy_id + super().__init__(message=message, code="SYNC_ERROR", status_code=502, details=d) diff --git a/pip-platform/src/core/logging.py b/pip-platform/src/core/logging.py new file mode 100644 index 0000000..6a54cd0 --- /dev/null +++ b/pip-platform/src/core/logging.py @@ -0,0 +1,56 @@ +import logging +import sys +from typing import Any + +import structlog + +from src.infrastructure.config.settings import settings + + +def configure_logging() -> None: + shared_processors: list[Any] = [ + structlog.contextvars.merge_contextvars, + structlog.stdlib.add_log_level, + structlog.stdlib.add_logger_name, + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.UnicodeDecoder(), + ] + + if settings.LOG_JSON_FORMAT: + renderer = structlog.processors.JSONRenderer() + else: + renderer = structlog.dev.ConsoleRenderer() + + structlog.configure( + processors=[ + *shared_processors, + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], + logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, + ) + + formatter = structlog.stdlib.ProcessorFormatter( + processors=[ + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + renderer, + ], + foreign_pre_chain=shared_processors, + ) + + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(formatter) + + root_logger = logging.getLogger() + root_logger.handlers.clear() + root_logger.addHandler(handler) + root_logger.setLevel(settings.LOG_LEVEL.upper()) + + for noisy in ("uvicorn.access", "sqlalchemy.engine", "httpx", "aioredis"): + logging.getLogger(noisy).setLevel(logging.WARNING) + + +def get_logger(name: str | None = None) -> Any: + return structlog.get_logger(name) diff --git a/pip-platform/src/core/metrics.py b/pip-platform/src/core/metrics.py new file mode 100644 index 0000000..5c3792e --- /dev/null +++ b/pip-platform/src/core/metrics.py @@ -0,0 +1,94 @@ +import time +from collections.abc import Callable +from typing import Any + +from fastapi import Request, Response +from prometheus_client import Counter, Gauge, Histogram + +REQUEST_COUNT = Counter( + "pip_http_requests_total", + "Total HTTP requests", + ["method", "endpoint", "status_code"], +) + +REQUEST_DURATION = Histogram( + "pip_http_request_duration_seconds", + "HTTP request duration in seconds", + ["method", "endpoint"], + buckets=[0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0], +) + +REQUESTS_IN_PROGRESS = Gauge( + "pip_http_requests_in_progress", + "HTTP requests currently in progress", + ["method", "endpoint"], +) + +DB_QUERY_DURATION = Histogram( + "pip_db_query_duration_seconds", + "Database query duration in seconds", + ["operation"], + buckets=[0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0], +) + +CONNECTOR_OPERATIONS = Counter( + "pip_connector_operations_total", + "Total connector operations", + ["connector_type", "operation", "status"], +) + +SYNC_JOBS = Counter( + "pip_sync_jobs_total", + "Total sync jobs", + ["strategy", "status"], +) + +SYNC_DURATION = Histogram( + "pip_sync_duration_seconds", + "Sync job duration in seconds", + ["strategy"], + buckets=[1.0, 5.0, 10.0, 30.0, 60.0, 300.0, 600.0, 1800.0], +) + +ACTIVE_CONNECTIONS = Gauge( + "pip_active_connections", + "Active infrastructure connections", + ["type"], +) + +CACHE_OPERATIONS = Counter( + "pip_cache_operations_total", + "Cache operations", + ["operation", "result"], +) + +RESERVATION_OPERATIONS = Counter( + "pip_reservation_operations_total", + "Reservation operations", + ["operation", "status"], +) + + +async def metrics_middleware(request: Request, call_next: Callable) -> Response: + if request.url.path == "/metrics" or request.url.path.startswith("/docs") or request.url.path.startswith("/openapi"): + return await call_next(request) + + method = request.method + endpoint = request.url.path + + REQUESTS_IN_PROGRESS.labels(method=method, endpoint=endpoint).inc() + + start_time = time.perf_counter() + try: + response = await call_next(request) + status_code = response.status_code + except Exception: + status_code = 500 + raise + finally: + duration = time.perf_counter() - start_time + REQUEST_COUNT.labels(method=method, endpoint=endpoint, status_code=status_code).inc() + REQUEST_DURATION.labels(method=method, endpoint=endpoint).observe(duration) + REQUESTS_IN_PROGRESS.labels(method=method, endpoint=endpoint).dec() + + return response diff --git a/pip-platform/src/core/tracing.py b/pip-platform/src/core/tracing.py new file mode 100644 index 0000000..80842ab --- /dev/null +++ b/pip-platform/src/core/tracing.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor +from opentelemetry.instrumentation.redis import RedisInstrumentor +from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +from src.infrastructure.config.settings import settings + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncEngine + + +def setup_tracing(app: Any = None, engine: AsyncEngine | None = None) -> None: + if not settings.OTEL_TRACES_ENABLED: + return + + resource = Resource.create({"service.name": settings.OTEL_SERVICE_NAME}) + + provider = TracerProvider(resource=resource) + + otlp_exporter = OTLPSpanExporter(endpoint=settings.OTEL_EXPORTER_OTLP_ENDPOINT) + provider.add_span_processor(BatchSpanProcessor(otlp_exporter)) + + trace.set_tracer_provider(provider) + + if engine is not None: + sync_engine = engine.sync_engine if hasattr(engine, "sync_engine") else engine + SQLAlchemyInstrumentor().instrument(engine=sync_engine) + RedisInstrumentor().instrument() + + if app is not None: + FastAPIInstrumentor.instrument_app(app) + + +def get_tracer(name: str = "pip-platform") -> trace.Tracer: + return trace.get_tracer(name) diff --git a/pip-platform/src/domain/__init__.py b/pip-platform/src/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/domain/entities/__init__.py b/pip-platform/src/domain/entities/__init__.py new file mode 100644 index 0000000..619134d --- /dev/null +++ b/pip-platform/src/domain/entities/__init__.py @@ -0,0 +1,51 @@ +from .entities import ( + APIClientEntity, + APIKeyEntity, + AuditLogEntity, + ConnectorConfigEntity, + ConnectorEntity, + ConnectorStatus, + ConnectorVersionEntity, + ERPType, + ERPSystemEntity, + InventoryHistoryEntity, + InventoryRecordEntity, + MedicationCatalogEntryEntity, + MedicationEntity, + PharmacyEntity, + PharmacyStatus, + ReservationEntity, + ReservationStatus, + SyncJobEntity, + SyncJobStatus, + SyncLogEntity, + SyncStrategy, + TechnicalUserEntity, + UserRole, +) + +__all__ = [ + "APIClientEntity", + "APIKeyEntity", + "AuditLogEntity", + "ConnectorConfigEntity", + "ConnectorEntity", + "ConnectorStatus", + "ConnectorVersionEntity", + "ERPType", + "ERPSystemEntity", + "InventoryHistoryEntity", + "InventoryRecordEntity", + "MedicationCatalogEntryEntity", + "MedicationEntity", + "PharmacyEntity", + "PharmacyStatus", + "ReservationEntity", + "ReservationStatus", + "SyncJobEntity", + "SyncJobStatus", + "SyncLogEntity", + "SyncStrategy", + "TechnicalUserEntity", + "UserRole", +] diff --git a/pip-platform/src/domain/entities/entities.py b/pip-platform/src/domain/entities/entities.py new file mode 100644 index 0000000..4873f7a --- /dev/null +++ b/pip-platform/src/domain/entities/entities.py @@ -0,0 +1,317 @@ +import uuid +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, Field + + +class PharmacyStatus(str, Enum): + ACTIVE = "active" + INACTIVE = "inactive" + SUSPENDED = "suspended" + PENDING = "pending" + + +class ERPType(str, Enum): + FARMATIC = "farmatic" + NIXFARMA = "nixfarma" + UNYCOP = "unycop" + CUSTOM_REST = "custom_rest" + CUSTOM_SOAP = "custom_soap" + CSV = "csv" + FTP = "ftp" + SQL = "sql" + MOCK = "mock" + + +class ConnectorStatus(str, Enum): + ACTIVE = "active" + INACTIVE = "inactive" + ERROR = "error" + PENDING = "pending" + UPDATING = "updating" + + +class SyncStrategy(str, Enum): + FULL = "full" + INCREMENTAL = "incremental" + EVENT_DRIVEN = "event_driven" + SCHEDULED = "scheduled" + HYBRID = "hybrid" + + +class SyncJobStatus(str, Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class ReservationStatus(str, Enum): + PENDING = "pending" + CONFIRMED = "confirmed" + CANCELLED = "cancelled" + EXPIRED = "expired" + + +class UserRole(str, Enum): + SUPERADMIN = "superadmin" + ADMIN = "admin" + OPERATOR = "operator" + VIEWER = "viewer" + API_CLIENT = "api_client" + + +class PharmacyEntity(BaseModel): + id: uuid.UUID | None = None + code: str + name: str + address: str | None = None + city: str | None = None + province: str | None = None + postal_code: str | None = None + country: str = "ES" + latitude: float | None = None + longitude: float | None = None + phone: str | None = None + email: str | None = None + opening_hours: dict | None = None + status: PharmacyStatus = PharmacyStatus.PENDING + erp_type: ERPType | None = None + erp_version: str | None = None + connector_id: uuid.UUID | None = None + sync_strategy: SyncStrategy = SyncStrategy.INCREMENTAL + sync_interval_minutes: int = 30 + last_sync_at: datetime | None = None + last_sync_status: SyncJobStatus | None = None + metadata_: dict | None = Field(default=None, alias="metadata") + created_at: datetime | None = None + updated_at: datetime | None = None + + model_config = {"populate_by_name": True} + + +class ERPSystemEntity(BaseModel): + id: uuid.UUID | None = None + name: str + erp_type: ERPType + version: str | None = None + base_url: str | None = None + auth_config: dict | None = None + status: ConnectorStatus = ConnectorStatus.PENDING + metadata_: dict | None = Field(default=None, alias="metadata") + created_at: datetime | None = None + updated_at: datetime | None = None + + model_config = {"populate_by_name": True} + + +class ConnectorEntity(BaseModel): + id: uuid.UUID | None = None + erp_system_id: uuid.UUID + pharmacy_id: uuid.UUID + name: str + connector_type: ERPType + version: str = "1.0.0" + config: dict | None = None + status: ConnectorStatus = ConnectorStatus.PENDING + last_heartbeat_at: datetime | None = None + last_error: str | None = None + metadata_: dict | None = Field(default=None, alias="metadata") + created_at: datetime | None = None + updated_at: datetime | None = None + + model_config = {"populate_by_name": True} + + +class ConnectorVersionEntity(BaseModel): + id: uuid.UUID | None = None + connector_id: uuid.UUID | None = None + connector_type: ERPType + version: str + changelog: str | None = None + download_url: str | None = None + checksum: str | None = None + is_latest: bool = False + is_required: bool = False + released_at: datetime | None = None + created_at: datetime | None = None + + +class ConnectorConfigEntity(BaseModel): + id: uuid.UUID | None = None + connector_id: uuid.UUID + key: str + value: str + encrypted: bool = False + description: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +class MedicationEntity(BaseModel): + id: uuid.UUID | None = None + nregistro: str + name: str + active_ingredient: str | None = None + dosage: str | None = None + form: str | None = None + atc_code: str | None = None + prescription_required: bool = False + manufacturer: str | None = None + metadata_: dict | None = Field(default=None, alias="metadata") + created_at: datetime | None = None + updated_at: datetime | None = None + + model_config = {"populate_by_name": True} + + +class MedicationCatalogEntryEntity(BaseModel): + id: uuid.UUID | None = None + pharmacy_id: uuid.UUID + medication_id: uuid.UUID + price: float | None = None + stock: int = 0 + is_available: bool = False + last_verified_at: datetime | None = None + metadata_: dict | None = Field(default=None, alias="metadata") + created_at: datetime | None = None + updated_at: datetime | None = None + + model_config = {"populate_by_name": True} + + +class InventoryRecordEntity(BaseModel): + id: uuid.UUID | None = None + pharmacy_id: uuid.UUID + medication_id: uuid.UUID + stock: int = 0 + reserved_stock: int = 0 + available_stock: int = 0 + min_stock: int | None = None + max_stock: int | None = None + last_updated_at: datetime | None = None + source: str | None = None + batch_id: str | None = None + metadata_: dict | None = Field(default=None, alias="metadata") + created_at: datetime | None = None + updated_at: datetime | None = None + + model_config = {"populate_by_name": True} + + +class InventoryHistoryEntity(BaseModel): + id: uuid.UUID | None = None + inventory_record_id: uuid.UUID | None = None + pharmacy_id: uuid.UUID + medication_id: uuid.UUID + previous_stock: int + new_stock: int + delta: int + reason: str | None = None + source: str | None = None + created_at: datetime | None = None + + +class ReservationEntity(BaseModel): + id: uuid.UUID | None = None + pharmacy_id: uuid.UUID + medication_id: uuid.UUID + user_id: uuid.UUID | None = None + external_reference: str | None = None + quantity: int + status: ReservationStatus = ReservationStatus.PENDING + expires_at: datetime | None = None + confirmed_at: datetime | None = None + cancelled_at: datetime | None = None + idempotency_key: str | None = None + metadata_: dict | None = Field(default=None, alias="metadata") + created_at: datetime | None = None + updated_at: datetime | None = None + + model_config = {"populate_by_name": True} + + +class SyncJobEntity(BaseModel): + id: uuid.UUID | None = None + pharmacy_id: uuid.UUID + connector_id: uuid.UUID + strategy: SyncStrategy + status: SyncJobStatus = SyncJobStatus.PENDING + started_at: datetime | None = None + completed_at: datetime | None = None + items_total: int | None = None + items_processed: int | None = None + items_failed: int | None = None + error_message: str | None = None + metadata_: dict | None = Field(default=None, alias="metadata") + created_at: datetime | None = None + updated_at: datetime | None = None + + model_config = {"populate_by_name": True} + + +class SyncLogEntity(BaseModel): + id: uuid.UUID | None = None + sync_job_id: uuid.UUID + level: str = "INFO" + message: str + details: dict | None = None + created_at: datetime | None = None + + +class AuditLogEntity(BaseModel): + id: uuid.UUID | None = None + actor_id: uuid.UUID | None = None + actor_type: str | None = None + action: str + resource_type: str + resource_id: str | None = None + old_value: dict | None = None + new_value: dict | None = None + ip_address: str | None = None + user_agent: str | None = None + request_id: str | None = None + created_at: datetime | None = None + + +class TechnicalUserEntity(BaseModel): + id: uuid.UUID | None = None + username: str + email: str | None = None + full_name: str | None = None + role: UserRole = UserRole.VIEWER + is_active: bool = True + scopes: list[str] = Field(default_factory=list) + last_login_at: datetime | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +class APIClientEntity(BaseModel): + id: uuid.UUID | None = None + name: str + description: str | None = None + owner_id: uuid.UUID | None = None + is_active: bool = True + scopes: list[str] = Field(default_factory=list) + rate_limit: int | None = None + allowed_ips: list[str] = Field(default_factory=list) + metadata_: dict | None = Field(default=None, alias="metadata") + created_at: datetime | None = None + updated_at: datetime | None = None + + model_config = {"populate_by_name": True} + + +class APIKeyEntity(BaseModel): + id: uuid.UUID | None = None + api_client_id: uuid.UUID + key_prefix: str + key_hash: bytes + name: str + is_active: bool = True + expires_at: datetime | None = None + last_used_at: datetime | None = None + created_at: datetime | None = None diff --git a/pip-platform/src/domain/interfaces/__init__.py b/pip-platform/src/domain/interfaces/__init__.py new file mode 100644 index 0000000..af807da --- /dev/null +++ b/pip-platform/src/domain/interfaces/__init__.py @@ -0,0 +1,35 @@ +from .repositories import ( + IAPIClientRepository, + IAPIKeyRepository, + IAuditLogRepository, + IConnectorConfigRepository, + IConnectorRepository, + IConnectorVersionRepository, + IERPSystemRepository, + IInventoryRepository, + IMedicationCatalogRepository, + IMedicationRepository, + IPharmacyRepository, + IReservationRepository, + ISyncJobRepository, + ISyncLogRepository, + ITechnicalUserRepository, +) + +__all__ = [ + "IAPIClientRepository", + "IAPIKeyRepository", + "IAuditLogRepository", + "IConnectorConfigRepository", + "IConnectorRepository", + "IConnectorVersionRepository", + "IERPSystemRepository", + "IInventoryRepository", + "IMedicationCatalogRepository", + "IMedicationRepository", + "IPharmacyRepository", + "IReservationRepository", + "ISyncJobRepository", + "ISyncLogRepository", + "ITechnicalUserRepository", +] diff --git a/pip-platform/src/domain/interfaces/repositories.py b/pip-platform/src/domain/interfaces/repositories.py new file mode 100644 index 0000000..9e94ba7 --- /dev/null +++ b/pip-platform/src/domain/interfaces/repositories.py @@ -0,0 +1,249 @@ +import uuid +from abc import ABC, abstractmethod +from collections.abc import Sequence +from typing import Any + +from src.domain.entities import ( + APIClientEntity, + APIKeyEntity, + AuditLogEntity, + ConnectorConfigEntity, + ConnectorEntity, + ConnectorVersionEntity, + ERPSystemEntity, + InventoryHistoryEntity, + InventoryRecordEntity, + MedicationCatalogEntryEntity, + MedicationEntity, + PharmacyEntity, + ReservationEntity, + SyncJobEntity, + SyncLogEntity, + TechnicalUserEntity, +) + + +class IPharmacyRepository(ABC): + @abstractmethod + async def get_by_id(self, pharmacy_id: uuid.UUID) -> PharmacyEntity | None: ... + + @abstractmethod + async def get_by_code(self, code: str) -> PharmacyEntity | None: ... + + @abstractmethod + async def list_pharmacies( + self, + *, + status: str | None = None, + erp_type: str | None = None, + offset: int = 0, + limit: int = 50, + ) -> tuple[Sequence[PharmacyEntity], int]: ... + + @abstractmethod + async def create(self, entity: PharmacyEntity) -> PharmacyEntity: ... + + @abstractmethod + async def update(self, entity: PharmacyEntity) -> PharmacyEntity: ... + + @abstractmethod + async def delete(self, pharmacy_id: uuid.UUID) -> bool: ... + + +class IERPSystemRepository(ABC): + @abstractmethod + async def get_by_id(self, erp_id: uuid.UUID) -> ERPSystemEntity | None: ... + + @abstractmethod + async def list_erp_systems(self, *, erp_type: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[ERPSystemEntity], int]: ... + + @abstractmethod + async def create(self, entity: ERPSystemEntity) -> ERPSystemEntity: ... + + @abstractmethod + async def update(self, entity: ERPSystemEntity) -> ERPSystemEntity: ... + + @abstractmethod + async def delete(self, erp_id: uuid.UUID) -> bool: ... + + +class IConnectorRepository(ABC): + @abstractmethod + async def get_by_id(self, connector_id: uuid.UUID) -> ConnectorEntity | None: ... + + @abstractmethod + async def list_connectors(self, *, erp_system_id: uuid.UUID | None = None, pharmacy_id: uuid.UUID | None = None, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[ConnectorEntity], int]: ... + + @abstractmethod + async def create(self, entity: ConnectorEntity) -> ConnectorEntity: ... + + @abstractmethod + async def update(self, entity: ConnectorEntity) -> ConnectorEntity: ... + + @abstractmethod + async def delete(self, connector_id: uuid.UUID) -> bool: ... + + +class IConnectorVersionRepository(ABC): + @abstractmethod + async def get_latest(self, connector_type: str) -> ConnectorVersionEntity | None: ... + + @abstractmethod + async def list_versions(self, *, connector_type: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[ConnectorVersionEntity], int]: ... + + @abstractmethod + async def create(self, entity: ConnectorVersionEntity) -> ConnectorVersionEntity: ... + + +class IConnectorConfigRepository(ABC): + @abstractmethod + async def list_by_connector(self, connector_id: uuid.UUID) -> Sequence[ConnectorConfigEntity]: ... + + @abstractmethod + async def get_by_key(self, connector_id: uuid.UUID, key: str) -> ConnectorConfigEntity | None: ... + + @abstractmethod + async def upsert(self, entity: ConnectorConfigEntity) -> ConnectorConfigEntity: ... + + @abstractmethod + async def delete(self, config_id: uuid.UUID) -> bool: ... + + +class IMedicationRepository(ABC): + @abstractmethod + async def get_by_id(self, medication_id: uuid.UUID) -> MedicationEntity | None: ... + + @abstractmethod + async def get_by_nregistro(self, nregistro: str) -> MedicationEntity | None: ... + + @abstractmethod + async def search(self, query: str, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[MedicationEntity], int]: ... + + @abstractmethod + async def create(self, entity: MedicationEntity) -> MedicationEntity: ... + + @abstractmethod + async def update(self, entity: MedicationEntity) -> MedicationEntity: ... + + @abstractmethod + async def delete(self, medication_id: uuid.UUID) -> bool: ... + + +class IMedicationCatalogRepository(ABC): + @abstractmethod + async def get_entry(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID) -> MedicationCatalogEntryEntity | None: ... + + @abstractmethod + async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[MedicationCatalogEntryEntity], int]: ... + + @abstractmethod + async def upsert(self, entity: MedicationCatalogEntryEntity) -> MedicationCatalogEntryEntity: ... + + +class IInventoryRepository(ABC): + @abstractmethod + async def get_record(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID) -> InventoryRecordEntity | None: ... + + @abstractmethod + async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[InventoryRecordEntity], int]: ... + + @abstractmethod + async def upsert(self, entity: InventoryRecordEntity) -> InventoryRecordEntity: ... + + @abstractmethod + async def add_history(self, entity: InventoryHistoryEntity) -> InventoryHistoryEntity: ... + + @abstractmethod + async def get_history(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> Sequence[InventoryHistoryEntity]: ... + + +class IReservationRepository(ABC): + @abstractmethod + async def get_by_id(self, reservation_id: uuid.UUID) -> ReservationEntity | None: ... + + @abstractmethod + async def get_by_idempotency_key(self, key: str) -> ReservationEntity | None: ... + + @abstractmethod + async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[ReservationEntity], int]: ... + + @abstractmethod + async def create(self, entity: ReservationEntity) -> ReservationEntity: ... + + @abstractmethod + async def update(self, entity: ReservationEntity) -> ReservationEntity: ... + + +class ISyncJobRepository(ABC): + @abstractmethod + async def get_by_id(self, job_id: uuid.UUID) -> SyncJobEntity | None: ... + + @abstractmethod + async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[SyncJobEntity], int]: ... + + @abstractmethod + async def create(self, entity: SyncJobEntity) -> SyncJobEntity: ... + + @abstractmethod + async def update(self, entity: SyncJobEntity) -> SyncJobEntity: ... + + +class ISyncLogRepository(ABC): + @abstractmethod + async def add(self, entity: SyncLogEntity) -> SyncLogEntity: ... + + @abstractmethod + async def list_by_job(self, job_id: uuid.UUID, *, level: str | None = None, offset: int = 0, limit: int = 100) -> Sequence[SyncLogEntity]: ... + + +class IAuditLogRepository(ABC): + @abstractmethod + async def add(self, entity: AuditLogEntity) -> AuditLogEntity: ... + + @abstractmethod + async def list_logs(self, *, actor_id: uuid.UUID | None = None, action: str | None = None, resource_type: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[AuditLogEntity], int]: ... + + +class ITechnicalUserRepository(ABC): + @abstractmethod + async def get_by_id(self, user_id: uuid.UUID) -> TechnicalUserEntity | None: ... + + @abstractmethod + async def get_by_username(self, username: str) -> TechnicalUserEntity | None: ... + + @abstractmethod + async def list_users(self, *, role: str | None = None, is_active: bool | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[TechnicalUserEntity], int]: ... + + @abstractmethod + async def create(self, entity: TechnicalUserEntity, password_hash: str) -> TechnicalUserEntity: ... + + @abstractmethod + async def update(self, entity: TechnicalUserEntity) -> TechnicalUserEntity: ... + + +class IAPIClientRepository(ABC): + @abstractmethod + async def get_by_id(self, client_id: uuid.UUID) -> APIClientEntity | None: ... + + @abstractmethod + async def list_clients(self, *, is_active: bool | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[APIClientEntity], int]: ... + + @abstractmethod + async def create(self, entity: APIClientEntity) -> APIClientEntity: ... + + @abstractmethod + async def update(self, entity: APIClientEntity) -> APIClientEntity: ... + + +class IAPIKeyRepository(ABC): + @abstractmethod + async def get_by_prefix(self, key_prefix: str) -> APIKeyEntity | None: ... + + @abstractmethod + async def list_by_client(self, api_client_id: uuid.UUID) -> Sequence[APIKeyEntity]: ... + + @abstractmethod + async def create(self, entity: APIKeyEntity) -> APIKeyEntity: ... + + @abstractmethod + async def update_last_used(self, key_id: uuid.UUID) -> None: ... diff --git a/pip-platform/src/domain/value_objects/__init__.py b/pip-platform/src/domain/value_objects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/infrastructure/__init__.py b/pip-platform/src/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/infrastructure/cache/__init__.py b/pip-platform/src/infrastructure/cache/__init__.py new file mode 100644 index 0000000..d7d155f --- /dev/null +++ b/pip-platform/src/infrastructure/cache/__init__.py @@ -0,0 +1,3 @@ +from .redis_cache import redis_cache + +__all__ = ["redis_cache"] diff --git a/pip-platform/src/infrastructure/cache/redis_cache.py b/pip-platform/src/infrastructure/cache/redis_cache.py new file mode 100644 index 0000000..1cf9bc9 --- /dev/null +++ b/pip-platform/src/infrastructure/cache/redis_cache.py @@ -0,0 +1,73 @@ +import json +from typing import Any + +import redis.asyncio as aioredis + +from src.infrastructure.config.settings import settings + + +class RedisCache: + def __init__(self) -> None: + self._client: aioredis.Redis | None = None + + async def connect(self) -> None: + self._client = aioredis.from_url( + settings.REDIS_URL, + decode_responses=True, + max_connections=20, + ) + + async def disconnect(self) -> None: + if self._client: + await self._client.close() + + @property + def client(self) -> aioredis.Redis: + if self._client is None: + raise RuntimeError("Redis client not connected. Call connect() first.") + return self._client + + async def get(self, key: str) -> Any | None: + data = await self.client.get(key) + if data is None: + return None + try: + return json.loads(data) + except (json.JSONDecodeError, TypeError): + return data + + async def set(self, key: str, value: Any, ttl: int | None = None) -> None: + ttl = ttl or settings.REDIS_CACHE_TTL + if isinstance(value, (dict, list, tuple)): + value = json.dumps(value) + await self.client.set(key, value, ex=ttl) + + async def delete(self, key: str) -> None: + await self.client.delete(key) + + async def exists(self, key: str) -> bool: + return bool(await self.client.exists(key)) + + async def get_with_lock(self, key: str, lock_timeout: int = 10) -> Any | None: + async with self.client.lock(f"{key}:lock", timeout=lock_timeout): + return await self.get(key) + + async def set_with_lock(self, key: str, value: Any, ttl: int | None = None, lock_timeout: int = 10) -> None: + async with self.client.lock(f"{key}:lock", timeout=lock_timeout): + await self.set(key, value, ttl) + + async def invalidate_pattern(self, pattern: str) -> None: + keys = [] + async for key in self.client.scan_iter(match=pattern): + keys.append(key) + if keys: + await self.client.delete(*keys) + + async def health_check(self) -> bool: + try: + return await self.client.ping() + except Exception: + return False + + +redis_cache = RedisCache() diff --git a/pip-platform/src/infrastructure/config/__init__.py b/pip-platform/src/infrastructure/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/infrastructure/config/settings.py b/pip-platform/src/infrastructure/config/settings.py new file mode 100644 index 0000000..2c2431d --- /dev/null +++ b/pip-platform/src/infrastructure/config/settings.py @@ -0,0 +1,58 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + env_nested_delimiter="__", + extra="ignore", + ) + + APP_NAME: str = "PIP - Pharmacy Integration Platform" + APP_VERSION: str = "0.1.0" + DEBUG: bool = False + + DATABASE_URL: str = "postgresql+asyncpg://pip:pip@localhost:5432/pip" + DATABASE_POOL_SIZE: int = 20 + DATABASE_MAX_OVERFLOW: int = 10 + DATABASE_POOL_RECYCLE: int = 3600 + + REDIS_URL: str = "redis://localhost:6379/0" + REDIS_CACHE_TTL: int = 300 + + RABBITMQ_URL: str = "amqp://pip:pip@localhost:5672/pip" + + JWT_SECRET_KEY: str = "change-me-in-production" + JWT_ALGORITHM: str = "HS256" + JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + JWT_ISSUER: str = "pip-platform" + + OAUTH2_TOKEN_URL: str = "/api/v1/auth/token" + + RATE_LIMIT_PER_MINUTE: int = 60 + RATE_LIMIT_AUTH_PER_MINUTE: int = 10 + + API_KEY_PREFIX: str = "pip" + API_KEY_LENGTH: int = 32 + + OTEL_EXPORTER_OTLP_ENDPOINT: str = "http://localhost:4317" + OTEL_SERVICE_NAME: str = "pip-platform" + OTEL_TRACES_ENABLED: bool = True + OTEL_METRICS_ENABLED: bool = True + + LOG_LEVEL: str = "INFO" + LOG_JSON_FORMAT: bool = True + + CORS_ORIGINS: list[str] = ["*"] + CORS_ALLOW_CREDENTIALS: bool = True + + HEALTH_CHECK_CACHE_TTL: int = 10 + + @property + def DATABASE_URL_SYNC(self) -> str: + return self.DATABASE_URL.replace("+asyncpg", "+psycopg2", 1) + + +settings = Settings() diff --git a/pip-platform/src/infrastructure/database/__init__.py b/pip-platform/src/infrastructure/database/__init__.py new file mode 100644 index 0000000..0b2ac42 --- /dev/null +++ b/pip-platform/src/infrastructure/database/__init__.py @@ -0,0 +1,9 @@ +from .models import * +from .session import async_session_factory, engine, get_db_session, get_db_session_no_commit + +__all__ = [ + "async_session_factory", + "engine", + "get_db_session", + "get_db_session_no_commit", +] diff --git a/pip-platform/src/infrastructure/database/models/__init__.py b/pip-platform/src/infrastructure/database/models/__init__.py new file mode 100644 index 0000000..df347fd --- /dev/null +++ b/pip-platform/src/infrastructure/database/models/__init__.py @@ -0,0 +1,41 @@ +from .models import ( + APIClientModel, + APIKeyModel, + AuditLogModel, + Base, + BaseMixin, + ConnectorConfigModel, + ConnectorModel, + ConnectorVersionModel, + ERPSystemModel, + InventoryHistoryModel, + InventoryRecordModel, + MedicationCatalogEntryModel, + MedicationModel, + PharmacyModel, + ReservationModel, + SyncJobModel, + SyncLogModel, + TechnicalUserModel, +) + +__all__ = [ + "APIClientModel", + "APIKeyModel", + "AuditLogModel", + "Base", + "BaseMixin", + "ConnectorConfigModel", + "ConnectorModel", + "ConnectorVersionModel", + "ERPSystemModel", + "InventoryHistoryModel", + "InventoryRecordModel", + "MedicationCatalogEntryModel", + "MedicationModel", + "PharmacyModel", + "ReservationModel", + "SyncJobModel", + "SyncLogModel", + "TechnicalUserModel", +] diff --git a/pip-platform/src/infrastructure/database/models/models.py b/pip-platform/src/infrastructure/database/models/models.py new file mode 100644 index 0000000..059cfc7 --- /dev/null +++ b/pip-platform/src/infrastructure/database/models/models.py @@ -0,0 +1,349 @@ +import uuid +from datetime import datetime + +from sqlalchemy import ( + Boolean, + DateTime, + Enum, + Float, + ForeignKey, + Index, + Integer, + LargeBinary, + String, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + pass + + +class BaseMixin: + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) + + +class PharmacyModel(Base, BaseMixin): + __tablename__ = "pharmacies" + + code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + address: Mapped[str | None] = mapped_column(String(500)) + city: Mapped[str | None] = mapped_column(String(100)) + province: Mapped[str | None] = mapped_column(String(100)) + postal_code: Mapped[str | None] = mapped_column(String(20)) + country: Mapped[str] = mapped_column(String(2), default="ES") + latitude: Mapped[float | None] = mapped_column(Float) + longitude: Mapped[float | None] = mapped_column(Float) + phone: Mapped[str | None] = mapped_column(String(50)) + email: Mapped[str | None] = mapped_column(String(255)) + opening_hours: Mapped[dict | None] = mapped_column(JSONB) + status: Mapped[str] = mapped_column(String(20), default="pending", index=True) + erp_type: Mapped[str | None] = mapped_column(String(50)) + erp_version: Mapped[str | None] = mapped_column(String(50)) + connector_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("connectors.id"), nullable=True) + sync_strategy: Mapped[str] = mapped_column(String(20), default="incremental") + sync_interval_minutes: Mapped[int] = mapped_column(Integer, default=30) + last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_sync_status: Mapped[str | None] = mapped_column(String(20)) + metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB) + + connector = relationship("ConnectorModel", back_populates="pharmacies", foreign_keys=[connector_id]) + inventory_records = relationship("InventoryRecordModel", back_populates="pharmacy", cascade="all, delete-orphan") + reservations = relationship("ReservationModel", back_populates="pharmacy", cascade="all, delete-orphan") + catalog_entries = relationship("MedicationCatalogEntryModel", back_populates="pharmacy", cascade="all, delete-orphan") + sync_jobs = relationship("SyncJobModel", back_populates="pharmacy", cascade="all, delete-orphan") + + __table_args__ = ( + Index("ix_pharmacies_location", "latitude", "longitude"), + Index("ix_pharmacies_status_erp", "status", "erp_type"), + ) + + +class ERPSystemModel(Base, BaseMixin): + __tablename__ = "erp_systems" + + name: Mapped[str] = mapped_column(String(255), nullable=False) + erp_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + version: Mapped[str | None] = mapped_column(String(50)) + base_url: Mapped[str | None] = mapped_column(String(500)) + auth_config: Mapped[dict | None] = mapped_column(JSONB) + status: Mapped[str] = mapped_column(String(20), default="pending", index=True) + metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB) + + connectors = relationship("ConnectorModel", back_populates="erp_system", cascade="all, delete-orphan") + + +class ConnectorModel(Base, BaseMixin): + __tablename__ = "connectors" + + erp_system_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("erp_systems.id"), nullable=False, index=True) + pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + connector_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + version: Mapped[str] = mapped_column(String(20), default="1.0.0") + config: Mapped[dict | None] = mapped_column(JSONB) + status: Mapped[str] = mapped_column(String(20), default="pending", index=True) + last_heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_error: Mapped[str | None] = mapped_column(Text) + metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB) + + erp_system = relationship("ERPSystemModel", back_populates="connectors") + pharmacies = relationship("PharmacyModel", back_populates="connector", foreign_keys="PharmacyModel.connector_id") + configs = relationship("ConnectorConfigModel", back_populates="connector", cascade="all, delete-orphan") + connector_versions = relationship("ConnectorVersionModel", back_populates="connector", cascade="all, delete-orphan") + + __table_args__ = ( + UniqueConstraint("erp_system_id", "pharmacy_id", name="uq_connector_erp_pharmacy"), + ) + + +class ConnectorVersionModel(Base, BaseMixin): + __tablename__ = "connector_versions" + + connector_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("connectors.id"), nullable=False, index=True) + connector_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + version: Mapped[str] = mapped_column(String(20), nullable=False) + changelog: Mapped[str | None] = mapped_column(Text) + download_url: Mapped[str | None] = mapped_column(String(500)) + checksum: Mapped[str | None] = mapped_column(String(128)) + is_latest: Mapped[bool] = mapped_column(Boolean, default=False, index=True) + is_required: Mapped[bool] = mapped_column(Boolean, default=False) + released_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + connector = relationship("ConnectorModel", back_populates="connector_versions") + + __table_args__ = ( + UniqueConstraint("connector_type", "version", name="uq_connector_version"), + ) + + +class ConnectorConfigModel(Base, BaseMixin): + __tablename__ = "connector_configs" + + connector_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("connectors.id"), nullable=False, index=True) + key: Mapped[str] = mapped_column(String(100), nullable=False) + value: Mapped[str] = mapped_column(Text, nullable=False) + encrypted: Mapped[bool] = mapped_column(Boolean, default=False) + description: Mapped[str | None] = mapped_column(String(500)) + + connector = relationship("ConnectorModel", back_populates="configs") + + __table_args__ = ( + UniqueConstraint("connector_id", "key", name="uq_connector_config_key"), + ) + + +class MedicationModel(Base, BaseMixin): + __tablename__ = "medications" + + nregistro: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True) + name: Mapped[str] = mapped_column(String(500), nullable=False, index=True) + active_ingredient: Mapped[str | None] = mapped_column(String(500)) + dosage: Mapped[str | None] = mapped_column(String(100)) + form: Mapped[str | None] = mapped_column(String(100)) + atc_code: Mapped[str | None] = mapped_column(String(20), index=True) + prescription_required: Mapped[bool] = mapped_column(Boolean, default=False) + manufacturer: Mapped[str | None] = mapped_column(String(255)) + metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB) + + catalog_entries = relationship("MedicationCatalogEntryModel", back_populates="medication", cascade="all, delete-orphan") + inventory_records = relationship("InventoryRecordModel", back_populates="medication", cascade="all, delete-orphan") + reservations = relationship("ReservationModel", back_populates="medication", cascade="all, delete-orphan") + + +class MedicationCatalogEntryModel(Base, BaseMixin): + __tablename__ = "medication_catalog" + + pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True) + medication_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("medications.id"), nullable=False, index=True) + price: Mapped[float | None] = mapped_column(Float) + stock: Mapped[int] = mapped_column(Integer, default=0) + is_available: Mapped[bool] = mapped_column(Boolean, default=False, index=True) + last_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB) + + pharmacy = relationship("PharmacyModel", back_populates="catalog_entries") + medication = relationship("MedicationModel", back_populates="catalog_entries") + + __table_args__ = ( + UniqueConstraint("pharmacy_id", "medication_id", name="uq_catalog_pharmacy_medication"), + ) + + +class InventoryRecordModel(Base, BaseMixin): + __tablename__ = "inventory_records" + + pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True) + medication_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("medications.id"), nullable=False, index=True) + stock: Mapped[int] = mapped_column(Integer, default=0) + reserved_stock: Mapped[int] = mapped_column(Integer, default=0) + available_stock: Mapped[int] = mapped_column(Integer, default=0) + min_stock: Mapped[int | None] = mapped_column(Integer) + max_stock: Mapped[int | None] = mapped_column(Integer) + last_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) + source: Mapped[str | None] = mapped_column(String(100)) + batch_id: Mapped[str | None] = mapped_column(String(100)) + metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB) + + pharmacy = relationship("PharmacyModel", back_populates="inventory_records") + medication = relationship("MedicationModel", back_populates="inventory_records") + history = relationship("InventoryHistoryModel", back_populates="inventory_record", cascade="all, delete-orphan") + + __table_args__ = ( + UniqueConstraint("pharmacy_id", "medication_id", name="uq_inventory_pharmacy_medication"), + ) + + +class InventoryHistoryModel(Base): + __tablename__ = "inventory_history" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + inventory_record_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("inventory_records.id"), nullable=False, index=True) + pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True) + medication_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("medications.id"), nullable=False, index=True) + previous_stock: Mapped[int] = mapped_column(Integer, nullable=False) + new_stock: Mapped[int] = mapped_column(Integer, nullable=False) + delta: Mapped[int] = mapped_column(Integer, nullable=False) + reason: Mapped[str | None] = mapped_column(String(255)) + source: Mapped[str | None] = mapped_column(String(100)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True) + + inventory_record = relationship("InventoryRecordModel", back_populates="history") + + +class ReservationModel(Base, BaseMixin): + __tablename__ = "reservations" + + pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True) + medication_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("medications.id"), nullable=False, index=True) + user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("technical_users.id"), nullable=True, index=True) + external_reference: Mapped[str | None] = mapped_column(String(255)) + quantity: Mapped[int] = mapped_column(Integer, nullable=False) + status: Mapped[str] = mapped_column(String(20), default="pending", index=True) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) + confirmed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + idempotency_key: Mapped[str | None] = mapped_column(String(255), unique=True, index=True) + metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB) + + pharmacy = relationship("PharmacyModel", back_populates="reservations") + medication = relationship("MedicationModel", back_populates="reservations") + user = relationship("TechnicalUserModel", back_populates="reservations") + + __table_args__ = ( + Index("ix_reservations_pharmacy_status", "pharmacy_id", "status"), + Index("ix_reservations_expires", "expires_at"), + ) + + +class SyncJobModel(Base, BaseMixin): + __tablename__ = "sync_jobs" + + pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True) + connector_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("connectors.id"), nullable=False, index=True) + strategy: Mapped[str] = mapped_column(String(20), nullable=False) + status: Mapped[str] = mapped_column(String(20), default="pending", index=True) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + items_total: Mapped[int | None] = mapped_column(Integer) + items_processed: Mapped[int | None] = mapped_column(Integer) + items_failed: Mapped[int | None] = mapped_column(Integer) + error_message: Mapped[str | None] = mapped_column(Text) + metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB) + + pharmacy = relationship("PharmacyModel", back_populates="sync_jobs") + logs = relationship("SyncLogModel", back_populates="sync_job", cascade="all, delete-orphan") + + +class SyncLogModel(Base): + __tablename__ = "sync_logs" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + sync_job_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sync_jobs.id"), nullable=False, index=True) + level: Mapped[str] = mapped_column(String(20), default="INFO", nullable=False) + message: Mapped[str] = mapped_column(Text, nullable=False) + details: Mapped[dict | None] = mapped_column(JSONB) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True) + + sync_job = relationship("SyncJobModel", back_populates="logs") + + +class AuditLogModel(Base): + __tablename__ = "audit_logs" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + actor_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True) + actor_type: Mapped[str | None] = mapped_column(String(50)) + action: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + resource_type: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + resource_id: Mapped[str | None] = mapped_column(String(255), index=True) + old_value: Mapped[dict | None] = mapped_column(JSONB) + new_value: Mapped[dict | None] = mapped_column(JSONB) + ip_address: Mapped[str | None] = mapped_column(String(45)) + user_agent: Mapped[str | None] = mapped_column(String(500)) + request_id: Mapped[str | None] = mapped_column(String(100), index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True) + + __table_args__ = ( + Index("ix_audit_logs_action_resource", "action", "resource_type"), + Index("ix_audit_logs_actor_created", "actor_id", "created_at"), + ) + + +class TechnicalUserModel(Base, BaseMixin): + __tablename__ = "technical_users" + + username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True) + email: Mapped[str | None] = mapped_column(String(255)) + full_name: Mapped[str | None] = mapped_column(String(255)) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[str] = mapped_column(String(20), default="viewer", index=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + scopes: Mapped[dict | None] = mapped_column(JSONB, default=list) + last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + reservations = relationship("ReservationModel", back_populates="user") + api_clients = relationship("APIClientModel", back_populates="owner") + + +class APIClientModel(Base, BaseMixin): + __tablename__ = "api_clients" + + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("technical_users.id"), nullable=True, index=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + scopes: Mapped[dict | None] = mapped_column(JSONB, default=list) + rate_limit: Mapped[int | None] = mapped_column(Integer) + allowed_ips: Mapped[dict | None] = mapped_column(JSONB, default=list) + metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB) + + owner = relationship("TechnicalUserModel", back_populates="api_clients") + api_keys = relationship("APIKeyModel", back_populates="api_client", cascade="all, delete-orphan") + + +class APIKeyModel(Base): + __tablename__ = "api_keys" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + api_client_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("api_clients.id"), nullable=False, index=True) + key_prefix: Mapped[str] = mapped_column(String(10), nullable=False, index=True) + key_hash: Mapped[bytes] = mapped_column(LargeBinary(64), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + api_client = relationship("APIClientModel", back_populates="api_keys") + + __table_args__ = ( + Index("ix_api_keys_prefix_active", "key_prefix", "is_active"), + ) diff --git a/pip-platform/src/infrastructure/database/repositories/__init__.py b/pip-platform/src/infrastructure/database/repositories/__init__.py new file mode 100644 index 0000000..cf87f26 --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/__init__.py @@ -0,0 +1,32 @@ +from .pharmacy_repository import PharmacyRepository +from .technical_user_repository import TechnicalUserRepository +from .audit_log_repository import AuditLogRepository +from .api_client_repository import APIClientRepository, APIKeyRepository +from .connector_repository import ConnectorRepository +from .erp_system_repository import ERPSystemRepository +from .connector_config_repository import ConnectorConfigRepository +from .connector_version_repository import ConnectorVersionRepository +from .medication_repository import MedicationRepository +from .medication_catalog_repository import MedicationCatalogRepository +from .inventory_repository import InventoryRepository +from .reservation_repository import ReservationRepository +from .sync_job_repository import SyncJobRepository +from .sync_log_repository import SyncLogRepository + +__all__ = [ + "APIClientRepository", + "APIKeyRepository", + "AuditLogRepository", + "ConnectorConfigRepository", + "ConnectorRepository", + "ConnectorVersionRepository", + "ERPSystemRepository", + "InventoryRepository", + "MedicationCatalogRepository", + "MedicationRepository", + "PharmacyRepository", + "ReservationRepository", + "SyncJobRepository", + "SyncLogRepository", + "TechnicalUserRepository", +] diff --git a/pip-platform/src/infrastructure/database/repositories/api_client_repository.py b/pip-platform/src/infrastructure/database/repositories/api_client_repository.py new file mode 100644 index 0000000..88b8d8a --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/api_client_repository.py @@ -0,0 +1,116 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import delete, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import APIClientEntity, APIKeyEntity +from src.domain.interfaces import IAPIClientRepository, IAPIKeyRepository +from src.infrastructure.database.models import APIClientModel, APIKeyModel + + +class APIClientRepository(IAPIClientRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, client_id: uuid.UUID) -> APIClientEntity | None: + stmt = select(APIClientModel).where(APIClientModel.id == client_id) + result = await self._session.execute(stmt) + m = result.scalar_one_or_none() + if not m: + return None + return APIClientEntity( + id=m.id, name=m.name, description=m.description, owner_id=m.owner_id, + is_active=m.is_active, scopes=m.scopes or [], rate_limit=m.rate_limit, + allowed_ips=m.allowed_ips or [], metadata_=m.metadata_, created_at=m.created_at, updated_at=m.updated_at, + ) + + async def list_clients(self, *, is_active: bool | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[APIClientEntity], int]: + filters: list[Any] = [] + if is_active is not None: + filters.append(APIClientModel.is_active == is_active) + count_stmt = select(func.count()).select_from(APIClientModel) + if filters: + count_stmt = count_stmt.where(*filters) + total = (await self._session.execute(count_stmt)).scalar() or 0 + stmt = select(APIClientModel).order_by(APIClientModel.name).offset(offset).limit(limit) + if filters: + stmt = stmt.where(*filters) + result = await self._session.execute(stmt) + models = result.scalars().all() + entities = [ + APIClientEntity(id=m.id, name=m.name, description=m.description, owner_id=m.owner_id, + is_active=m.is_active, scopes=m.scopes or [], rate_limit=m.rate_limit, + allowed_ips=m.allowed_ips or [], metadata_=m.metadata_, created_at=m.created_at, updated_at=m.updated_at) + for m in models + ] + return entities, total + + async def create(self, entity: APIClientEntity) -> APIClientEntity: + model = APIClientModel( + name=entity.name, description=entity.description, owner_id=entity.owner_id, + is_active=entity.is_active, scopes=entity.scopes, rate_limit=entity.rate_limit, + allowed_ips=entity.allowed_ips, metadata_=entity.metadata_, + ) + self._session.add(model) + await self._session.flush() + return await self.get_by_id(model.id) # type: ignore[return-value] + + async def update(self, entity: APIClientEntity) -> APIClientEntity: + stmt = update(APIClientModel).where(APIClientModel.id == entity.id).values( + name=entity.name, description=entity.description, is_active=entity.is_active, + scopes=entity.scopes, rate_limit=entity.rate_limit, allowed_ips=entity.allowed_ips, + metadata_=entity.metadata_, + ) + await self._session.execute(stmt) + await self._session.flush() + return await self.get_by_id(entity.id) # type: ignore[return-value] + + +class APIKeyRepository(IAPIKeyRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_prefix(self, key_prefix: str) -> APIKeyEntity | None: + stmt = select(APIKeyModel).where(APIKeyModel.key_prefix == key_prefix, APIKeyModel.is_active == True) # noqa: E712 + result = await self._session.execute(stmt) + m = result.scalar_one_or_none() + if not m: + return None + return APIKeyEntity( + id=m.id, api_client_id=m.api_client_id, key_prefix=m.key_prefix, + key_hash=m.key_hash, name=m.name, is_active=m.is_active, + expires_at=m.expires_at, last_used_at=m.last_used_at, created_at=m.created_at, + ) + + async def list_by_client(self, api_client_id: uuid.UUID) -> Sequence[APIKeyEntity]: + stmt = select(APIKeyModel).where(APIKeyModel.api_client_id == api_client_id).order_by(APIKeyModel.created_at.desc()) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [ + APIKeyEntity( + id=m.id, api_client_id=m.api_client_id, key_prefix=m.key_prefix, + key_hash=m.key_hash, name=m.name, is_active=m.is_active, + expires_at=m.expires_at, last_used_at=m.last_used_at, created_at=m.created_at, + ) + for m in models + ] + + async def create(self, entity: APIKeyEntity) -> APIKeyEntity: + model = APIKeyModel( + api_client_id=entity.api_client_id, key_prefix=entity.key_prefix, + key_hash=entity.key_hash, name=entity.name, is_active=entity.is_active, + expires_at=entity.expires_at, + ) + self._session.add(model) + await self._session.flush() + return APIKeyEntity( + id=model.id, api_client_id=model.api_client_id, key_prefix=model.key_prefix, + key_hash=model.key_hash, name=model.name, is_active=model.is_active, + expires_at=model.expires_at, created_at=model.created_at, + ) + + async def update_last_used(self, key_id: uuid.UUID) -> None: + stmt = update(APIKeyModel).where(APIKeyModel.id == key_id).values(last_used_at=func.now()) + await self._session.execute(stmt) diff --git a/pip-platform/src/infrastructure/database/repositories/audit_log_repository.py b/pip-platform/src/infrastructure/database/repositories/audit_log_repository.py new file mode 100644 index 0000000..623d119 --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/audit_log_repository.py @@ -0,0 +1,78 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import AuditLogEntity +from src.domain.interfaces import IAuditLogRepository +from src.infrastructure.database.models import AuditLogModel + + +def _model_to_entity(m: AuditLogModel) -> AuditLogEntity: + return AuditLogEntity( + id=m.id, + actor_id=m.actor_id, + actor_type=m.actor_type, + action=m.action, + resource_type=m.resource_type, + resource_id=m.resource_id, + old_value=m.old_value, + new_value=m.new_value, + ip_address=m.ip_address, + user_agent=m.user_agent, + request_id=m.request_id, + created_at=m.created_at, + ) + + +class AuditLogRepository(IAuditLogRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def add(self, entity: AuditLogEntity) -> AuditLogEntity: + model = AuditLogModel( + actor_id=entity.actor_id, + actor_type=entity.actor_type, + action=entity.action, + resource_type=entity.resource_type, + resource_id=entity.resource_id, + old_value=entity.old_value, + new_value=entity.new_value, + ip_address=entity.ip_address, + user_agent=entity.user_agent, + request_id=entity.request_id, + ) + self._session.add(model) + await self._session.flush() + return _model_to_entity(model) + + async def list_logs( + self, + *, + actor_id: uuid.UUID | None = None, + action: str | None = None, + resource_type: str | None = None, + offset: int = 0, + limit: int = 50, + ) -> tuple[Sequence[AuditLogEntity], int]: + filters: list[Any] = [] + if actor_id: + filters.append(AuditLogModel.actor_id == actor_id) + if action: + filters.append(AuditLogModel.action == action) + if resource_type: + filters.append(AuditLogModel.resource_type == resource_type) + + count_stmt = select(func.count()).select_from(AuditLogModel) + if filters: + count_stmt = count_stmt.where(*filters) + total = (await self._session.execute(count_stmt)).scalar() or 0 + + stmt = select(AuditLogModel).order_by(AuditLogModel.created_at.desc()).offset(offset).limit(limit) + if filters: + stmt = stmt.where(*filters) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_model_to_entity(m) for m in models], total diff --git a/pip-platform/src/infrastructure/database/repositories/connector_config_repository.py b/pip-platform/src/infrastructure/database/repositories/connector_config_repository.py new file mode 100644 index 0000000..efc704c --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/connector_config_repository.py @@ -0,0 +1,71 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import ConnectorConfigEntity +from src.domain.interfaces import IConnectorConfigRepository +from src.infrastructure.database.models import ConnectorConfigModel + + +def _model_to_entity(m: ConnectorConfigModel) -> ConnectorConfigEntity: + return ConnectorConfigEntity( + id=m.id, + connector_id=m.connector_id, + key=m.key, + value=m.value, + encrypted=m.encrypted, + description=m.description, + created_at=m.created_at, + updated_at=m.updated_at, + ) + + +class ConnectorConfigRepository(IConnectorConfigRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list_by_connector(self, connector_id: uuid.UUID) -> Sequence[ConnectorConfigEntity]: + stmt = select(ConnectorConfigModel).where(ConnectorConfigModel.connector_id == connector_id).order_by(ConnectorConfigModel.key) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_model_to_entity(m) for m in models] + + async def get_by_key(self, connector_id: uuid.UUID, key: str) -> ConnectorConfigEntity | None: + stmt = select(ConnectorConfigModel).where( + ConnectorConfigModel.connector_id == connector_id, + ConnectorConfigModel.key == key, + ) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def upsert(self, entity: ConnectorConfigEntity) -> ConnectorConfigEntity: + existing = await self.get_by_key(entity.connector_id, entity.key) + if existing: + stmt = ( + ConnectorConfigModel.__table__.update() + .where(ConnectorConfigModel.id == existing.id) + .values(value=entity.value, encrypted=entity.encrypted, description=entity.description) + ) + await self._session.execute(stmt) + await self._session.flush() + return await self.get_by_key(entity.connector_id, entity.key) # type: ignore[return-value] + else: + model = ConnectorConfigModel( + connector_id=entity.connector_id, + key=entity.key, + value=entity.value, + encrypted=entity.encrypted, + description=entity.description, + ) + self._session.add(model) + await self._session.flush() + return _model_to_entity(model) + + async def delete(self, config_id: uuid.UUID) -> bool: + stmt = delete(ConnectorConfigModel).where(ConnectorConfigModel.id == config_id) + result = await self._session.execute(stmt) + return result.rowcount > 0 diff --git a/pip-platform/src/infrastructure/database/repositories/connector_repository.py b/pip-platform/src/infrastructure/database/repositories/connector_repository.py new file mode 100644 index 0000000..6870dd1 --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/connector_repository.py @@ -0,0 +1,101 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import delete, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import ConnectorEntity, ConnectorStatus +from src.domain.interfaces import IConnectorRepository +from src.infrastructure.database.models import ConnectorModel + + +def _model_to_entity(m: ConnectorModel) -> ConnectorEntity: + return ConnectorEntity( + id=m.id, + erp_system_id=m.erp_system_id, + pharmacy_id=m.pharmacy_id, + name=m.name, + connector_type=m.connector_type, + version=m.version, + config=m.config, + status=m.status, + last_heartbeat_at=m.last_heartbeat_at, + last_error=m.last_error, + metadata_=m.metadata_, + created_at=m.created_at, + updated_at=m.updated_at, + ) + + +def _entity_to_model_data(e: ConnectorEntity) -> dict[str, Any]: + return { + "erp_system_id": e.erp_system_id, + "pharmacy_id": e.pharmacy_id, + "name": e.name, + "connector_type": e.connector_type if isinstance(e.connector_type, str) else e.connector_type.value, + "version": e.version, + "config": e.config, + "status": e.status if isinstance(e.status, str) else e.status.value, + "last_heartbeat_at": e.last_heartbeat_at, + "last_error": e.last_error, + "metadata_": e.metadata_, + } + + +class ConnectorRepository(IConnectorRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, connector_id: uuid.UUID) -> ConnectorEntity | None: + stmt = select(ConnectorModel).where(ConnectorModel.id == connector_id) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def list_connectors( + self, + *, + erp_system_id: uuid.UUID | None = None, + pharmacy_id: uuid.UUID | None = None, + status: str | None = None, + offset: int = 0, + limit: int = 50, + ) -> tuple[Sequence[ConnectorEntity], int]: + filters: list[Any] = [] + if erp_system_id: + filters.append(ConnectorModel.erp_system_id == erp_system_id) + if pharmacy_id: + filters.append(ConnectorModel.pharmacy_id == pharmacy_id) + if status: + filters.append(ConnectorModel.status == status) + + count_stmt = select(func.count()).select_from(ConnectorModel) + if filters: + count_stmt = count_stmt.where(*filters) + total = (await self._session.execute(count_stmt)).scalar() or 0 + + stmt = select(ConnectorModel).order_by(ConnectorModel.name).offset(offset).limit(limit) + if filters: + stmt = stmt.where(*filters) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_model_to_entity(m) for m in models], total + + async def create(self, entity: ConnectorEntity) -> ConnectorEntity: + model = ConnectorModel(**_entity_to_model_data(entity)) + self._session.add(model) + await self._session.flush() + return _model_to_entity(model) + + async def update(self, entity: ConnectorEntity) -> ConnectorEntity: + data = _entity_to_model_data(entity) + stmt = update(ConnectorModel).where(ConnectorModel.id == entity.id).values(**data) + await self._session.execute(stmt) + await self._session.flush() + return await self.get_by_id(entity.id) # type: ignore[return-value] + + async def delete(self, connector_id: uuid.UUID) -> bool: + stmt = delete(ConnectorModel).where(ConnectorModel.id == connector_id) + result = await self._session.execute(stmt) + return result.rowcount > 0 diff --git a/pip-platform/src/infrastructure/database/repositories/connector_version_repository.py b/pip-platform/src/infrastructure/database/repositories/connector_version_repository.py new file mode 100644 index 0000000..5ac58d7 --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/connector_version_repository.py @@ -0,0 +1,82 @@ +from collections.abc import Sequence +from typing import Any + +import uuid + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import ConnectorVersionEntity +from src.domain.interfaces import IConnectorVersionRepository +from src.infrastructure.database.models import ConnectorVersionModel + + +def _model_to_entity(m: ConnectorVersionModel) -> ConnectorVersionEntity: + return ConnectorVersionEntity( + id=m.id, + connector_id=m.connector_id, + connector_type=m.connector_type, + version=m.version, + changelog=m.changelog, + download_url=m.download_url, + checksum=m.checksum, + is_latest=m.is_latest, + is_required=m.is_required, + released_at=m.released_at, + created_at=m.created_at, + ) + + +class ConnectorVersionRepository(IConnectorVersionRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_latest(self, connector_type: str) -> ConnectorVersionEntity | None: + stmt = ( + select(ConnectorVersionModel) + .where(ConnectorVersionModel.connector_type == connector_type, ConnectorVersionModel.is_latest == True) # noqa: E712 + .order_by(ConnectorVersionModel.released_at.desc()) + .limit(1) + ) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def list_versions( + self, + *, + connector_type: str | None = None, + offset: int = 0, + limit: int = 50, + ) -> tuple[Sequence[ConnectorVersionEntity], int]: + filters: list[Any] = [] + if connector_type: + filters.append(ConnectorVersionModel.connector_type == connector_type) + + count_stmt = select(func.count()).select_from(ConnectorVersionModel) + if filters: + count_stmt = count_stmt.where(*filters) + total = (await self._session.execute(count_stmt)).scalar() or 0 + + stmt = select(ConnectorVersionModel).order_by(ConnectorVersionModel.released_at.desc()).offset(offset).limit(limit) + if filters: + stmt = stmt.where(*filters) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_model_to_entity(m) for m in models], total + + async def create(self, entity: ConnectorVersionEntity) -> ConnectorVersionEntity: + model = ConnectorVersionModel( + connector_id=entity.connector_id or uuid.uuid4(), + connector_type=entity.connector_type if isinstance(entity.connector_type, str) else entity.connector_type.value, + version=entity.version, + changelog=entity.changelog, + download_url=entity.download_url, + checksum=entity.checksum, + is_latest=entity.is_latest, + is_required=entity.is_required, + released_at=entity.released_at, + ) + self._session.add(model) + await self._session.flush() + return _model_to_entity(model) diff --git a/pip-platform/src/infrastructure/database/repositories/erp_system_repository.py b/pip-platform/src/infrastructure/database/repositories/erp_system_repository.py new file mode 100644 index 0000000..9a406f6 --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/erp_system_repository.py @@ -0,0 +1,89 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import delete, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import ERPSystemEntity +from src.domain.interfaces import IERPSystemRepository +from src.infrastructure.database.models import ERPSystemModel + + +def _model_to_entity(m: ERPSystemModel) -> ERPSystemEntity: + return ERPSystemEntity( + id=m.id, + name=m.name, + erp_type=m.erp_type, + version=m.version, + base_url=m.base_url, + auth_config=m.auth_config, + status=m.status, + metadata_=m.metadata_, + created_at=m.created_at, + updated_at=m.updated_at, + ) + + +def _entity_to_model_data(e: ERPSystemEntity) -> dict[str, Any]: + return { + "name": e.name, + "erp_type": e.erp_type if isinstance(e.erp_type, str) else e.erp_type.value, + "version": e.version, + "base_url": e.base_url, + "auth_config": e.auth_config, + "status": e.status if isinstance(e.status, str) else e.status.value, + "metadata_": e.metadata_, + } + + +class ERPSystemRepository(IERPSystemRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, erp_id: uuid.UUID) -> ERPSystemEntity | None: + stmt = select(ERPSystemModel).where(ERPSystemModel.id == erp_id) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def list_erp_systems( + self, + *, + erp_type: str | None = None, + offset: int = 0, + limit: int = 50, + ) -> tuple[Sequence[ERPSystemEntity], int]: + filters: list[Any] = [] + if erp_type: + filters.append(ERPSystemModel.erp_type == erp_type) + + count_stmt = select(func.count()).select_from(ERPSystemModel) + if filters: + count_stmt = count_stmt.where(*filters) + total = (await self._session.execute(count_stmt)).scalar() or 0 + + stmt = select(ERPSystemModel).order_by(ERPSystemModel.name).offset(offset).limit(limit) + if filters: + stmt = stmt.where(*filters) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_model_to_entity(m) for m in models], total + + async def create(self, entity: ERPSystemEntity) -> ERPSystemEntity: + model = ERPSystemModel(**_entity_to_model_data(entity)) + self._session.add(model) + await self._session.flush() + return _model_to_entity(model) + + async def update(self, entity: ERPSystemEntity) -> ERPSystemEntity: + data = _entity_to_model_data(entity) + stmt = update(ERPSystemModel).where(ERPSystemModel.id == entity.id).values(**data) + await self._session.execute(stmt) + await self._session.flush() + return await self.get_by_id(entity.id) # type: ignore[return-value] + + async def delete(self, erp_id: uuid.UUID) -> bool: + stmt = delete(ERPSystemModel).where(ERPSystemModel.id == erp_id) + result = await self._session.execute(stmt) + return result.rowcount > 0 diff --git a/pip-platform/src/infrastructure/database/repositories/inventory_repository.py b/pip-platform/src/infrastructure/database/repositories/inventory_repository.py new file mode 100644 index 0000000..25e9545 --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/inventory_repository.py @@ -0,0 +1,142 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import InventoryHistoryEntity, InventoryRecordEntity +from src.domain.interfaces import IInventoryRepository +from src.infrastructure.database.models import InventoryHistoryModel, InventoryRecordModel + + +def _record_model_to_entity(m: InventoryRecordModel) -> InventoryRecordEntity: + return InventoryRecordEntity( + id=m.id, + pharmacy_id=m.pharmacy_id, + medication_id=m.medication_id, + stock=m.stock, + reserved_stock=m.reserved_stock, + available_stock=m.available_stock, + min_stock=m.min_stock, + max_stock=m.max_stock, + last_updated_at=m.last_updated_at, + source=m.source, + batch_id=m.batch_id, + metadata_=m.metadata_, + created_at=m.created_at, + updated_at=m.updated_at, + ) + + +def _record_entity_to_model_data(e: InventoryRecordEntity) -> dict[str, Any]: + return { + "pharmacy_id": e.pharmacy_id, + "medication_id": e.medication_id, + "stock": e.stock, + "reserved_stock": e.reserved_stock, + "available_stock": e.available_stock, + "min_stock": e.min_stock, + "max_stock": e.max_stock, + "last_updated_at": e.last_updated_at, + "source": e.source, + "batch_id": e.batch_id, + "metadata_": e.metadata_, + } + + +def _history_model_to_entity(m: InventoryHistoryModel) -> InventoryHistoryEntity: + return InventoryHistoryEntity( + id=m.id, + pharmacy_id=m.pharmacy_id, + medication_id=m.medication_id, + previous_stock=m.previous_stock, + new_stock=m.new_stock, + delta=m.delta, + reason=m.reason, + source=m.source, + created_at=m.created_at, + ) + + +class InventoryRepository(IInventoryRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_record(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID) -> InventoryRecordEntity | None: + stmt = select(InventoryRecordModel).where( + InventoryRecordModel.pharmacy_id == pharmacy_id, + InventoryRecordModel.medication_id == medication_id, + ) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _record_model_to_entity(model) if model else None + + async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[InventoryRecordEntity], int]: + count_stmt = select(func.count()).select_from(InventoryRecordModel).where( + InventoryRecordModel.pharmacy_id == pharmacy_id + ) + total = (await self._session.execute(count_stmt)).scalar() or 0 + + stmt = ( + select(InventoryRecordModel) + .where(InventoryRecordModel.pharmacy_id == pharmacy_id) + .order_by(InventoryRecordModel.last_updated_at.desc().nullslast()) + .offset(offset) + .limit(limit) + ) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_record_model_to_entity(m) for m in models], total + + async def upsert(self, entity: InventoryRecordEntity) -> InventoryRecordEntity: + existing = await self.get_record(entity.pharmacy_id, entity.medication_id) + if existing: + data = _record_entity_to_model_data(entity) + stmt = ( + update(InventoryRecordModel) + .where( + InventoryRecordModel.pharmacy_id == entity.pharmacy_id, + InventoryRecordModel.medication_id == entity.medication_id, + ) + .values(**data) + ) + await self._session.execute(stmt) + await self._session.flush() + return await self.get_record(entity.pharmacy_id, entity.medication_id) # type: ignore[return-value] + else: + model = InventoryRecordModel(**_record_entity_to_model_data(entity)) + self._session.add(model) + await self._session.flush() + return _record_model_to_entity(model) + + async def add_history(self, entity: InventoryHistoryEntity) -> InventoryHistoryEntity: + model = InventoryHistoryModel( + inventory_record_id=entity.inventory_record_id, + pharmacy_id=entity.pharmacy_id, + medication_id=entity.medication_id, + previous_stock=entity.previous_stock, + new_stock=entity.new_stock, + delta=entity.delta, + reason=entity.reason, + source=entity.source, + ) + self._session.add(model) + await self._session.flush() + return _history_model_to_entity(model) + + async def get_history(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> Sequence[InventoryHistoryEntity]: + record = await self.get_record(pharmacy_id, medication_id) + if not record: + return [] + + stmt = ( + select(InventoryHistoryModel) + .where(InventoryHistoryModel.inventory_record_id == record.id) + .order_by(InventoryHistoryModel.created_at.desc()) + .offset(offset) + .limit(limit) + ) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_history_model_to_entity(m) for m in models] diff --git a/pip-platform/src/infrastructure/database/repositories/medication_catalog_repository.py b/pip-platform/src/infrastructure/database/repositories/medication_catalog_repository.py new file mode 100644 index 0000000..f44a829 --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/medication_catalog_repository.py @@ -0,0 +1,90 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import MedicationCatalogEntryEntity +from src.domain.interfaces import IMedicationCatalogRepository +from src.infrastructure.database.models import MedicationCatalogEntryModel + + +def _model_to_entity(m: MedicationCatalogEntryModel) -> MedicationCatalogEntryEntity: + return MedicationCatalogEntryEntity( + id=m.id, + pharmacy_id=m.pharmacy_id, + medication_id=m.medication_id, + price=m.price, + stock=m.stock, + is_available=m.is_available, + last_verified_at=m.last_verified_at, + metadata_=m.metadata_, + created_at=m.created_at, + updated_at=m.updated_at, + ) + + +def _entity_to_model_data(e: MedicationCatalogEntryEntity) -> dict[str, Any]: + return { + "pharmacy_id": e.pharmacy_id, + "medication_id": e.medication_id, + "price": e.price, + "stock": e.stock, + "is_available": e.is_available, + "last_verified_at": e.last_verified_at, + "metadata_": e.metadata_, + } + + +class MedicationCatalogRepository(IMedicationCatalogRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_entry(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID) -> MedicationCatalogEntryEntity | None: + stmt = select(MedicationCatalogEntryModel).where( + MedicationCatalogEntryModel.pharmacy_id == pharmacy_id, + MedicationCatalogEntryModel.medication_id == medication_id, + ) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[MedicationCatalogEntryEntity], int]: + count_stmt = select(func.count()).select_from(MedicationCatalogEntryModel).where( + MedicationCatalogEntryModel.pharmacy_id == pharmacy_id + ) + total = (await self._session.execute(count_stmt)).scalar() or 0 + + stmt = ( + select(MedicationCatalogEntryModel) + .where(MedicationCatalogEntryModel.pharmacy_id == pharmacy_id) + .order_by(MedicationCatalogEntryModel.is_available.desc(), MedicationCatalogEntryModel.price.asc()) + .offset(offset) + .limit(limit) + ) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_model_to_entity(m) for m in models], total + + async def upsert(self, entity: MedicationCatalogEntryEntity) -> MedicationCatalogEntryEntity: + existing = await self.get_entry(entity.pharmacy_id, entity.medication_id) + if existing: + from sqlalchemy import update + data = _entity_to_model_data(entity) + stmt = ( + update(MedicationCatalogEntryModel) + .where( + MedicationCatalogEntryModel.pharmacy_id == entity.pharmacy_id, + MedicationCatalogEntryModel.medication_id == entity.medication_id, + ) + .values(**data) + ) + await self._session.execute(stmt) + await self._session.flush() + return await self.get_entry(entity.pharmacy_id, entity.medication_id) # type: ignore[return-value] + else: + model = MedicationCatalogEntryModel(**_entity_to_model_data(entity)) + self._session.add(model) + await self._session.flush() + return _model_to_entity(model) diff --git a/pip-platform/src/infrastructure/database/repositories/medication_repository.py b/pip-platform/src/infrastructure/database/repositories/medication_repository.py new file mode 100644 index 0000000..416bd54 --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/medication_repository.py @@ -0,0 +1,101 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import delete, func, or_, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import MedicationEntity +from src.domain.interfaces import IMedicationRepository +from src.infrastructure.database.models import MedicationModel + + +def _model_to_entity(m: MedicationModel) -> MedicationEntity: + return MedicationEntity( + id=m.id, + nregistro=m.nregistro, + name=m.name, + active_ingredient=m.active_ingredient, + dosage=m.dosage, + form=m.form, + atc_code=m.atc_code, + prescription_required=m.prescription_required, + manufacturer=m.manufacturer, + metadata_=m.metadata_, + created_at=m.created_at, + updated_at=m.updated_at, + ) + + +def _entity_to_model_data(e: MedicationEntity) -> dict[str, Any]: + return { + "nregistro": e.nregistro, + "name": e.name, + "active_ingredient": e.active_ingredient, + "dosage": e.dosage, + "form": e.form, + "atc_code": e.atc_code, + "prescription_required": e.prescription_required, + "manufacturer": e.manufacturer, + "metadata_": e.metadata_, + } + + +class MedicationRepository(IMedicationRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, medication_id: uuid.UUID) -> MedicationEntity | None: + stmt = select(MedicationModel).where(MedicationModel.id == medication_id) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def get_by_nregistro(self, nregistro: str) -> MedicationEntity | None: + stmt = select(MedicationModel).where(MedicationModel.nregistro == nregistro) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def search(self, query: str, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[MedicationEntity], int]: + pattern = f"%{query}%" + filters = [ + or_( + MedicationModel.name.ilike(pattern), + MedicationModel.active_ingredient.ilike(pattern), + MedicationModel.nregistro.ilike(pattern), + MedicationModel.atc_code.ilike(pattern), + ) + ] + + count_stmt = select(func.count()).select_from(MedicationModel).where(*filters) + total = (await self._session.execute(count_stmt)).scalar() or 0 + + stmt = ( + select(MedicationModel) + .where(*filters) + .order_by(MedicationModel.name) + .offset(offset) + .limit(limit) + ) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_model_to_entity(m) for m in models], total + + async def create(self, entity: MedicationEntity) -> MedicationEntity: + model = MedicationModel(**_entity_to_model_data(entity)) + self._session.add(model) + await self._session.flush() + return _model_to_entity(model) + + async def update(self, entity: MedicationEntity) -> MedicationEntity: + data = _entity_to_model_data(entity) + stmt = update(MedicationModel).where(MedicationModel.id == entity.id).values(**data) + await self._session.execute(stmt) + await self._session.flush() + return await self.get_by_id(entity.id) # type: ignore[return-value] + + async def delete(self, medication_id: uuid.UUID) -> bool: + stmt = delete(MedicationModel).where(MedicationModel.id == medication_id) + result = await self._session.execute(stmt) + return result.rowcount > 0 diff --git a/pip-platform/src/infrastructure/database/repositories/pharmacy_repository.py b/pip-platform/src/infrastructure/database/repositories/pharmacy_repository.py new file mode 100644 index 0000000..692b6e5 --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/pharmacy_repository.py @@ -0,0 +1,126 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import delete, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import PharmacyEntity +from src.domain.interfaces import IPharmacyRepository +from src.infrastructure.database.models import PharmacyModel + + +def _model_to_entity(m: PharmacyModel) -> PharmacyEntity: + return PharmacyEntity( + id=m.id, + code=m.code, + name=m.name, + address=m.address, + city=m.city, + province=m.province, + postal_code=m.postal_code, + country=m.country, + latitude=m.latitude, + longitude=m.longitude, + phone=m.phone, + email=m.email, + opening_hours=m.opening_hours, + status=m.status, + erp_type=m.erp_type, + erp_version=m.erp_version, + connector_id=m.connector_id, + sync_strategy=m.sync_strategy, + sync_interval_minutes=m.sync_interval_minutes, + last_sync_at=m.last_sync_at, + last_sync_status=m.last_sync_status, + metadata_=m.metadata_, + created_at=m.created_at, + updated_at=m.updated_at, + ) + + +def _entity_to_model_data(e: PharmacyEntity) -> dict[str, Any]: + return { + "code": e.code, + "name": e.name, + "address": e.address, + "city": e.city, + "province": e.province, + "postal_code": e.postal_code, + "country": e.country, + "latitude": e.latitude, + "longitude": e.longitude, + "phone": e.phone, + "email": e.email, + "opening_hours": e.opening_hours, + "status": e.status, + "erp_type": e.erp_type, + "erp_version": e.erp_version, + "connector_id": e.connector_id, + "sync_strategy": e.sync_strategy, + "sync_interval_minutes": e.sync_interval_minutes, + "last_sync_at": e.last_sync_at, + "last_sync_status": e.last_sync_status, + "metadata_": e.metadata_, + } + + +class PharmacyRepository(IPharmacyRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, pharmacy_id: uuid.UUID) -> PharmacyEntity | None: + stmt = select(PharmacyModel).where(PharmacyModel.id == pharmacy_id) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def get_by_code(self, code: str) -> PharmacyEntity | None: + stmt = select(PharmacyModel).where(PharmacyModel.code == code) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def list_pharmacies( + self, + *, + status: str | None = None, + erp_type: str | None = None, + offset: int = 0, + limit: int = 50, + ) -> tuple[Sequence[PharmacyEntity], int]: + filters: list[Any] = [] + if status: + filters.append(PharmacyModel.status == status) + if erp_type: + filters.append(PharmacyModel.erp_type == erp_type) + + count_stmt = select(func.count()).select_from(PharmacyModel) + if filters: + count_stmt = count_stmt.where(*filters) + total = (await self._session.execute(count_stmt)).scalar() or 0 + + stmt = select(PharmacyModel).order_by(PharmacyModel.name).offset(offset).limit(limit) + if filters: + stmt = stmt.where(*filters) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_model_to_entity(m) for m in models], total + + async def create(self, entity: PharmacyEntity) -> PharmacyEntity: + model = PharmacyModel(**_entity_to_model_data(entity)) + self._session.add(model) + await self._session.flush() + return _model_to_entity(model) + + async def update(self, entity: PharmacyEntity) -> PharmacyEntity: + data = _entity_to_model_data(entity) + stmt = update(PharmacyModel).where(PharmacyModel.id == entity.id).values(**data) + await self._session.execute(stmt) + await self._session.flush() + return await self.get_by_id(entity.id) # type: ignore[return-value] + + async def delete(self, pharmacy_id: uuid.UUID) -> bool: + stmt = delete(PharmacyModel).where(PharmacyModel.id == pharmacy_id) + result = await self._session.execute(stmt) + return result.rowcount > 0 diff --git a/pip-platform/src/infrastructure/database/repositories/reservation_repository.py b/pip-platform/src/infrastructure/database/repositories/reservation_repository.py new file mode 100644 index 0000000..a4b637e --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/reservation_repository.py @@ -0,0 +1,94 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import delete, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import ReservationEntity +from src.domain.interfaces import IReservationRepository +from src.infrastructure.database.models import ReservationModel + + +def _model_to_entity(m: ReservationModel) -> ReservationEntity: + return ReservationEntity( + id=m.id, + pharmacy_id=m.pharmacy_id, + medication_id=m.medication_id, + user_id=m.user_id, + external_reference=m.external_reference, + quantity=m.quantity, + status=m.status, + expires_at=m.expires_at, + confirmed_at=m.confirmed_at, + cancelled_at=m.cancelled_at, + idempotency_key=m.idempotency_key, + metadata_=m.metadata_, + created_at=m.created_at, + updated_at=m.updated_at, + ) + + +def _entity_to_model_data(e: ReservationEntity) -> dict[str, Any]: + return { + "pharmacy_id": e.pharmacy_id, + "medication_id": e.medication_id, + "user_id": e.user_id, + "external_reference": e.external_reference, + "quantity": e.quantity, + "status": e.status, + "expires_at": e.expires_at, + "confirmed_at": e.confirmed_at, + "cancelled_at": e.cancelled_at, + "idempotency_key": e.idempotency_key, + "metadata_": e.metadata_, + } + + +class ReservationRepository(IReservationRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, reservation_id: uuid.UUID) -> ReservationEntity | None: + stmt = select(ReservationModel).where(ReservationModel.id == reservation_id) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def get_by_idempotency_key(self, key: str) -> ReservationEntity | None: + stmt = select(ReservationModel).where(ReservationModel.idempotency_key == key) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[ReservationEntity], int]: + filters: list[Any] = [ReservationModel.pharmacy_id == pharmacy_id] + if status: + filters.append(ReservationModel.status == status) + + count_stmt = select(func.count()).select_from(ReservationModel).where(*filters) + total = (await self._session.execute(count_stmt)).scalar() or 0 + + stmt = ( + select(ReservationModel) + .where(*filters) + .order_by(ReservationModel.created_at.desc()) + .offset(offset) + .limit(limit) + ) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_model_to_entity(m) for m in models], total + + async def create(self, entity: ReservationEntity) -> ReservationEntity: + model = ReservationModel(**_entity_to_model_data(entity)) + self._session.add(model) + await self._session.flush() + return _model_to_entity(model) + + async def update(self, entity: ReservationEntity) -> ReservationEntity: + data = _entity_to_model_data(entity) + stmt = update(ReservationModel).where(ReservationModel.id == entity.id).values(**data) + await self._session.execute(stmt) + await self._session.flush() + return await self.get_by_id(entity.id) # type: ignore[return-value] diff --git a/pip-platform/src/infrastructure/database/repositories/sync_job_repository.py b/pip-platform/src/infrastructure/database/repositories/sync_job_repository.py new file mode 100644 index 0000000..6f583a9 --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/sync_job_repository.py @@ -0,0 +1,95 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import SyncJobEntity, SyncJobStatus +from src.domain.interfaces import ISyncJobRepository +from src.infrastructure.database.models import SyncJobModel + + +def _model_to_entity(m: SyncJobModel) -> SyncJobEntity: + return SyncJobEntity( + id=m.id, + pharmacy_id=m.pharmacy_id, + connector_id=m.connector_id, + strategy=m.strategy, + status=m.status, + started_at=m.started_at, + completed_at=m.completed_at, + items_total=m.items_total, + items_processed=m.items_processed, + items_failed=m.items_failed, + error_message=m.error_message, + metadata_=m.metadata_, + created_at=m.created_at, + updated_at=m.updated_at, + ) + + +def _entity_to_model_data(e: SyncJobEntity) -> dict[str, Any]: + return { + "pharmacy_id": e.pharmacy_id, + "connector_id": e.connector_id, + "strategy": e.strategy, + "status": e.status, + "started_at": e.started_at, + "completed_at": e.completed_at, + "items_total": e.items_total, + "items_processed": e.items_processed, + "items_failed": e.items_failed, + "error_message": e.error_message, + "metadata_": e.metadata_, + } + + +class SyncJobRepository(ISyncJobRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, job_id: uuid.UUID) -> SyncJobEntity | None: + stmt = select(SyncJobModel).where(SyncJobModel.id == job_id) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def list_by_pharmacy( + self, + pharmacy_id: uuid.UUID, + *, + status: str | None = None, + offset: int = 0, + limit: int = 50, + ) -> tuple[Sequence[SyncJobEntity], int]: + filters: list[Any] = [SyncJobModel.pharmacy_id == pharmacy_id] + if status: + filters.append(SyncJobModel.status == status) + + count_stmt = select(func.count()).select_from(SyncJobModel).where(*filters) + total = (await self._session.execute(count_stmt)).scalar() or 0 + + stmt = ( + select(SyncJobModel) + .where(*filters) + .order_by(SyncJobModel.created_at.desc()) + .offset(offset) + .limit(limit) + ) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_model_to_entity(m) for m in models], total + + async def create(self, entity: SyncJobEntity) -> SyncJobEntity: + model = SyncJobModel(**_entity_to_model_data(entity)) + self._session.add(model) + await self._session.flush() + return _model_to_entity(model) + + async def update(self, entity: SyncJobEntity) -> SyncJobEntity: + data = _entity_to_model_data(entity) + stmt = update(SyncJobModel).where(SyncJobModel.id == entity.id).values(**data) + await self._session.execute(stmt) + await self._session.flush() + return await self.get_by_id(entity.id) # type: ignore[return-value] diff --git a/pip-platform/src/infrastructure/database/repositories/sync_log_repository.py b/pip-platform/src/infrastructure/database/repositories/sync_log_repository.py new file mode 100644 index 0000000..f99d19b --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/sync_log_repository.py @@ -0,0 +1,53 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import SyncLogEntity +from src.domain.interfaces import ISyncLogRepository +from src.infrastructure.database.models import SyncLogModel + + +def _model_to_entity(m: SyncLogModel) -> SyncLogEntity: + return SyncLogEntity( + id=m.id, + sync_job_id=m.sync_job_id, + level=m.level, + message=m.message, + details=m.details, + created_at=m.created_at, + ) + + +class SyncLogRepository(ISyncLogRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def add(self, entity: SyncLogEntity) -> SyncLogEntity: + model = SyncLogModel( + sync_job_id=entity.sync_job_id, + level=entity.level, + message=entity.message, + details=entity.details, + ) + self._session.add(model) + await self._session.flush() + return _model_to_entity(model) + + async def list_by_job( + self, + job_id: uuid.UUID, + *, + level: str | None = None, + offset: int = 0, + limit: int = 100, + ) -> Sequence[SyncLogEntity]: + stmt = select(SyncLogModel).where(SyncLogModel.sync_job_id == job_id) + if level: + stmt = stmt.where(SyncLogModel.level == level) + stmt = stmt.order_by(SyncLogModel.created_at.desc()).offset(offset).limit(limit) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_model_to_entity(m) for m in models] diff --git a/pip-platform/src/infrastructure/database/repositories/technical_user_repository.py b/pip-platform/src/infrastructure/database/repositories/technical_user_repository.py new file mode 100644 index 0000000..59ffda6 --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/technical_user_repository.py @@ -0,0 +1,88 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import delete, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import TechnicalUserEntity +from src.domain.interfaces import ITechnicalUserRepository +from src.infrastructure.database.models import TechnicalUserModel + + +def _model_to_entity(m: TechnicalUserModel) -> TechnicalUserEntity: + return TechnicalUserEntity( + id=m.id, + username=m.username, + email=m.email, + full_name=m.full_name, + role=m.role, + is_active=m.is_active, + scopes=m.scopes or [], + last_login_at=m.last_login_at, + created_at=m.created_at, + updated_at=m.updated_at, + ) + + +class TechnicalUserRepository(ITechnicalUserRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, user_id: uuid.UUID) -> TechnicalUserEntity | None: + stmt = select(TechnicalUserModel).where(TechnicalUserModel.id == user_id) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def get_by_username(self, username: str) -> TechnicalUserEntity | None: + stmt = select(TechnicalUserModel).where(TechnicalUserModel.username == username) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def list_users(self, *, role: str | None = None, is_active: bool | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[TechnicalUserEntity], int]: + filters: list[Any] = [] + if role: + filters.append(TechnicalUserModel.role == role) + if is_active is not None: + filters.append(TechnicalUserModel.is_active == is_active) + + count_stmt = select(func.count()).select_from(TechnicalUserModel) + if filters: + count_stmt = count_stmt.where(*filters) + total = (await self._session.execute(count_stmt)).scalar() or 0 + + stmt = select(TechnicalUserModel).order_by(TechnicalUserModel.username).offset(offset).limit(limit) + if filters: + stmt = stmt.where(*filters) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_model_to_entity(m) for m in models], total + + async def create(self, entity: TechnicalUserEntity, password_hash: str) -> TechnicalUserEntity: + model = TechnicalUserModel( + username=entity.username, + email=entity.email, + full_name=entity.full_name, + password_hash=password_hash, + role=entity.role, + is_active=entity.is_active, + scopes=entity.scopes, + ) + self._session.add(model) + await self._session.flush() + return _model_to_entity(model) + + async def update(self, entity: TechnicalUserEntity) -> TechnicalUserEntity: + stmt = update(TechnicalUserModel).where(TechnicalUserModel.id == entity.id).values( + email=entity.email, + full_name=entity.full_name, + role=entity.role, + is_active=entity.is_active, + scopes=entity.scopes, + last_login_at=entity.last_login_at, + ) + await self._session.execute(stmt) + await self._session.flush() + return await self.get_by_id(entity.id) # type: ignore[return-value] diff --git a/pip-platform/src/infrastructure/database/session.py b/pip-platform/src/infrastructure/database/session.py new file mode 100644 index 0000000..a545bb3 --- /dev/null +++ b/pip-platform/src/infrastructure/database/session.py @@ -0,0 +1,40 @@ +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from src.infrastructure.config.settings import settings + +engine = create_async_engine( + settings.DATABASE_URL, + pool_size=settings.DATABASE_POOL_SIZE, + max_overflow=settings.DATABASE_MAX_OVERFLOW, + pool_recycle=settings.DATABASE_POOL_RECYCLE, + echo=settings.DEBUG, + pool_pre_ping=True, +) + +async_session_factory = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +async def get_db_session() -> AsyncGenerator[AsyncSession, None]: + async with async_session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() + + +async def get_db_session_no_commit() -> AsyncGenerator[AsyncSession, None]: + async with async_session_factory() as session: + try: + yield session + finally: + await session.close() diff --git a/pip-platform/src/infrastructure/messaging/__init__.py b/pip-platform/src/infrastructure/messaging/__init__.py new file mode 100644 index 0000000..fa0dc23 --- /dev/null +++ b/pip-platform/src/infrastructure/messaging/__init__.py @@ -0,0 +1,3 @@ +from .rabbitmq import rabbitmq_client + +__all__ = ["rabbitmq_client"] diff --git a/pip-platform/src/infrastructure/messaging/rabbitmq.py b/pip-platform/src/infrastructure/messaging/rabbitmq.py new file mode 100644 index 0000000..53453c3 --- /dev/null +++ b/pip-platform/src/infrastructure/messaging/rabbitmq.py @@ -0,0 +1,131 @@ +import json +import logging +from typing import Any + +import aio_pika +from aio_pika import ExchangeType, Message, RobustConnection + +from src.infrastructure.config.settings import settings + +logger = logging.getLogger(__name__) + + +class RabbitMQClient: + PIP_EXCHANGE = "pip.events" + PIP_DLX = "pip.dlx" + + ROUTING_KEYS = { + "inventory_updated": "inventory.updated", + "reservation_created": "reservation.created", + "reservation_confirmed": "reservation.confirmed", + "reservation_cancelled": "reservation.cancelled", + "reservation_expired": "reservation.expired", + "sync_started": "sync.started", + "sync_completed": "sync.completed", + "sync_failed": "sync.failed", + "sync_cancelled": "sync.cancelled", + "sync_created": "sync.created", + "connector_heartbeat": "connector.heartbeat", + "connector_error": "connector.error", + "connector_created": "connector.created", + "connector_updated": "connector.updated", + "connector_deleted": "connector.deleted", + "connector_deployed": "connector.deployed", + "connector_deactivated": "connector.deactivated", + "pharmacy_updated": "pharmacy.updated", + "medication_created": "medication.created", + "medication_updated": "medication.updated", + "medication_deleted": "medication.deleted", + "catalog_upserted": "catalog.upserted", + } + + def __init__(self) -> None: + self._connection: RobustConnection | None = None + self._channel: aio_pika.RobustChannel | None = None + self._exchange: aio_pika.RobustExchange | None = None + self._dlx: aio_pika.RobustExchange | None = None + + async def connect(self) -> None: + self._connection = await aio_pika.connect_robust(settings.RABBITMQ_URL) + self._channel = await self._connection.channel() + await self._channel.set_qos(prefetch_count=100) + + self._exchange = await self._channel.declare_exchange( + self.PIP_EXCHANGE, + ExchangeType.TOPIC, + durable=True, + ) + + self._dlx = await self._channel.declare_exchange( + self.PIP_DLX, + ExchangeType.TOPIC, + durable=True, + ) + + logger.info("rabbitmq_connected", extra={"url": settings.RABBITMQ_URL}) + + async def disconnect(self) -> None: + if self._connection: + await self._connection.close() + + async def publish(self, routing_key: str, payload: dict[str, Any], headers: dict[str, str] | None = None) -> None: + if not self._exchange: + raise RuntimeError("RabbitMQ not connected. Call connect() first.") + + message = Message( + body=json.dumps(payload, default=str).encode(), + content_type="application/json", + delivery_mode=aio_pika.DeliveryMode.PERSISTENT, + headers=headers or {}, + ) + + await self._exchange.publish(message, routing_key=routing_key) + + logger.debug( + "message_published", + extra={"routing_key": routing_key}, + ) + + async def consume( + self, + queue_name: str, + routing_keys: list[str], + callback: Any, + dlq: bool = True, + ) -> aio_pika.RobustQueue: + if not self._channel or not self._exchange: + raise RuntimeError("RabbitMQ not connected. Call connect() first.") + + queue_arguments = { + "x-dead-letter-exchange": self.PIP_DLX, + "x-dead-letter-routing-key": queue_name, + } + + queue = await self._channel.declare_queue( + queue_name, + durable=True, + arguments=queue_arguments, + ) + + for routing_key in routing_keys: + await queue.bind(self._exchange, routing_key=routing_key) + + if dlq and self._dlx: + dlq_name = f"{queue_name}.dlq" + dlq = await self._channel.declare_queue(dlq_name, durable=True) + await dlq.bind(self._dlx, routing_key=queue_name) + + await queue.consume(callback) + + return queue + + async def health_check(self) -> bool: + try: + if not self._connection or self._connection.is_closed: + return False + return True + except Exception: + return False + + +rabbitmq_client = RabbitMQClient() diff --git a/pip-platform/src/main.py b/pip-platform/src/main.py new file mode 100644 index 0000000..f0ab911 --- /dev/null +++ b/pip-platform/src/main.py @@ -0,0 +1,78 @@ +from contextlib import asynccontextmanager +from collections.abc import AsyncGenerator + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from prometheus_client import make_asgi_app + +from src.core.exceptions import PIPException +from src.core.logging import configure_logging +from src.core.metrics import metrics_middleware +from src.core.tracing import setup_tracing +from src.infrastructure.cache.redis_cache import redis_cache +from src.infrastructure.config.settings import settings +from src.infrastructure.messaging.rabbitmq import rabbitmq_client +from src.api.middleware.audit import AuditMiddleware +from src.api.middleware.logging import RequestLoggingMiddleware +from src.api.middleware.rate_limit import RateLimitMiddleware +from src.api.v1.router import v1_router +from src.infrastructure.database.session import engine + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + configure_logging() + setup_tracing(app, engine=engine) + + await redis_cache.connect() + await rabbitmq_client.connect() + + yield + + await rabbitmq_client.disconnect() + await redis_cache.disconnect() + await engine.dispose() + + +def create_app() -> FastAPI: + application = FastAPI( + title=settings.APP_NAME, + version=settings.APP_VERSION, + description="Pharmacy Integration Platform — unified API between pharmacy ERPs and external applications", + docs_url="/docs", + redoc_url="/redoc", + openapi_url="/openapi.json", + lifespan=lifespan, + ) + + application.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=settings.CORS_ALLOW_CREDENTIALS, + allow_methods=["*"], + allow_headers=["*"], + ) + + application.add_middleware(RateLimitMiddleware) + application.add_middleware(AuditMiddleware) + application.add_middleware(RequestLoggingMiddleware) + application.middleware("http")(metrics_middleware) + + application.include_router(v1_router) + + metrics_app = make_asgi_app() + application.mount("/metrics", metrics_app) + + @application.exception_handler(PIPException) + async def pip_exception_handler(request: Request, exc: PIPException) -> JSONResponse: + return JSONResponse(status_code=exc.status_code, content=exc.to_dict()) + + @application.exception_handler(Exception) + async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse: + return JSONResponse(status_code=500, content={"error": {"code": "INTERNAL_ERROR", "message": "Internal server error"}}) + + return application + + +app = create_app() diff --git a/pip-platform/src/services/__init__.py b/pip-platform/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/services/audit/__init__.py b/pip-platform/src/services/audit/__init__.py new file mode 100644 index 0000000..bfe0b78 --- /dev/null +++ b/pip-platform/src/services/audit/__init__.py @@ -0,0 +1,3 @@ +from .audit_service import AuditService + +__all__ = ["AuditService"] diff --git a/pip-platform/src/services/audit/audit_service.py b/pip-platform/src/services/audit/audit_service.py new file mode 100644 index 0000000..c08cd52 --- /dev/null +++ b/pip-platform/src/services/audit/audit_service.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from src.domain.entities import AuditLogEntity +from src.domain.interfaces import IAuditLogRepository +from src.core.logging import get_logger + +logger = get_logger("audit_service") + + +class AuditService: + def __init__(self, audit_repo: IAuditLogRepository) -> None: + self._audit_repo = audit_repo + + async def log_action( + self, + action: str, + resource_type: str, + *, + actor_id: UUID | None = None, + actor_type: str | None = None, + resource_id: str | None = None, + old_value: dict | None = None, + new_value: dict | None = None, + ip_address: str | None = None, + user_agent: str | None = None, + request_id: str | None = None, + ) -> AuditLogEntity: + entity = AuditLogEntity( + actor_id=actor_id, + actor_type=actor_type, + action=action, + resource_type=resource_type, + resource_id=resource_id, + old_value=old_value, + new_value=new_value, + ip_address=ip_address, + user_agent=user_agent, + request_id=request_id, + ) + created = await self._audit_repo.add(entity) + return created + + async def list_logs( + self, + *, + actor_id: UUID | None = None, + action: str | None = None, + resource_type: str | None = None, + offset: int = 0, + limit: int = 50, + ) -> tuple[list[AuditLogEntity], int]: + return await self._audit_repo.list_logs( + actor_id=actor_id, + action=action, + resource_type=resource_type, + offset=offset, + limit=limit, + ) diff --git a/pip-platform/src/services/auth/__init__.py b/pip-platform/src/services/auth/__init__.py new file mode 100644 index 0000000..758c722 --- /dev/null +++ b/pip-platform/src/services/auth/__init__.py @@ -0,0 +1,27 @@ +from .security import ( + RBACGuard, + TokenData, + create_access_token, + create_refresh_token, + decode_token, + generate_api_key, + hash_api_key, + hash_password, + validate_token_type, + verify_api_key, + verify_password, +) + +__all__ = [ + "RBACGuard", + "TokenData", + "create_access_token", + "create_refresh_token", + "decode_token", + "generate_api_key", + "hash_api_key", + "hash_password", + "validate_token_type", + "verify_api_key", + "verify_password", +] diff --git a/pip-platform/src/services/auth/auth_service.py b/pip-platform/src/services/auth/auth_service.py new file mode 100644 index 0000000..ccaf689 --- /dev/null +++ b/pip-platform/src/services/auth/auth_service.py @@ -0,0 +1,102 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.core.exceptions import ConflictException, UnauthorizedException, ValidationException +from src.core.logging import get_logger +from src.domain.entities import TechnicalUserEntity, UserRole +from src.infrastructure.database.repositories import TechnicalUserRepository +from src.services.auth.security import ( + RBACGuard, + TokenData, + create_access_token, + create_refresh_token, + decode_token, + hash_password, + validate_token_type, + verify_password, +) + +logger = get_logger("auth_service") + + +class AuthService: + def __init__(self, session: AsyncSession) -> None: + self._user_repo = TechnicalUserRepository(session) + self._session = session + + async def register(self, username: str, password: str, email: str | None = None, full_name: str | None = None, role: UserRole = UserRole.VIEWER) -> TechnicalUserEntity: + if not username or len(username) < 3: + raise ValidationException("Username must be at least 3 characters") + if not password or len(password) < 8: + raise ValidationException("Password must be at least 8 characters") + + existing = await self._user_repo.get_by_username(username) + if existing: + raise ConflictException(f"Username '{username}' already exists") + + pw_hash = hash_password(password) + entity = TechnicalUserEntity( + username=username, + email=email, + full_name=full_name, + role=role, + is_active=True, + scopes=self._default_scopes_for_role(role), + ) + user = await self._user_repo.create(entity, pw_hash) + logger.info("user_registered", extra={"username": username, "role": role.value}) + return user + + async def authenticate(self, username: str, password: str) -> tuple[TechnicalUserEntity, str, str]: + user = await self._user_repo.get_by_username(username) + if not user: + raise UnauthorizedException("Invalid credentials") + if not user.is_active: + raise UnauthorizedException("Account is disabled") + + from src.infrastructure.database.models import TechnicalUserModel + from sqlalchemy import select + stmt = select(TechnicalUserModel.password_hash).where(TechnicalUserModel.id == user.id) + result = await self._session.execute(stmt) + pw_hash = result.scalar_one_or_none() + if not pw_hash or not verify_password(password, pw_hash): + raise UnauthorizedException("Invalid credentials") + + user.last_login_at = datetime.now(timezone.utc) + await self._user_repo.update(user) + + access_token = create_access_token(subject=user.id, role=user.role, scopes=user.scopes) + refresh_token = create_refresh_token(subject=user.id) + + logger.info("user_authenticated", extra={"username": username}) + return user, access_token, refresh_token + + async def refresh_access_token(self, refresh_token: str) -> tuple[str, str]: + payload = decode_token(refresh_token) + validate_token_type(payload, expected_type="refresh") + + user_id = uuid.UUID(payload["sub"]) + user = await self._user_repo.get_by_id(user_id) + if not user or not user.is_active: + raise UnauthorizedException("User not found or inactive") + + new_access = create_access_token(subject=user.id, role=user.role, scopes=user.scopes) + new_refresh = create_refresh_token(subject=user.id) + return new_access, new_refresh + + async def validate_access_token(self, token: str) -> TokenData: + payload = decode_token(token) + validate_token_type(payload, expected_type="access") + return TokenData(payload) + + def _default_scopes_for_role(self, role: UserRole) -> list[str]: + scopes_map: dict[UserRole, list[str]] = { + UserRole.SUPERADMIN: ["*"], + UserRole.ADMIN: ["pharmacies:read", "pharmacies:write", "medications:read", "medications:write", "inventory:read", "inventory:write", "connectors:read", "connectors:write", "sync:read", "sync:write", "reservations:read", "reservations:write", "audit:read", "users:read", "users:write", "api-keys:read", "api-keys:write"], + UserRole.OPERATOR: ["pharmacies:read", "pharmacies:write", "medications:read", "inventory:read", "inventory:write", "connectors:read", "sync:read", "sync:write", "reservations:read", "reservations:write"], + UserRole.VIEWER: ["pharmacies:read", "medications:read", "inventory:read", "reservations:read"], + UserRole.API_CLIENT: ["pharmacies:read", "medications:read", "inventory:read", "reservations:read", "reservations:write"], + } + return scopes_map.get(role, ["pharmacies:read", "medications:read", "inventory:read"]) diff --git a/pip-platform/src/services/auth/security.py b/pip-platform/src/services/auth/security.py new file mode 100644 index 0000000..50458eb --- /dev/null +++ b/pip-platform/src/services/auth/security.py @@ -0,0 +1,126 @@ +import hashlib +import secrets +from datetime import datetime, timedelta, timezone +from typing import Any +from uuid import UUID, uuid4 + +from jose import JWTError, jwt + +from src.core.exceptions import ForbiddenException, UnauthorizedException +from src.domain.entities import UserRole +from src.infrastructure.config.settings import settings + + +def hash_password(password: str) -> str: + import bcrypt as _bcrypt + return _bcrypt.hashpw(password.encode("utf-8"), _bcrypt.gensalt()).decode("utf-8") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + import bcrypt as _bcrypt + return _bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8")) + + +def create_access_token(subject: str | UUID, role: UserRole, scopes: list[str], extra_claims: dict | None = None) -> str: + now = datetime.now(timezone.utc) + expires = now + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES) + claims: dict[str, Any] = { + "sub": str(subject), + "role": role.value, + "scopes": scopes, + "iss": settings.JWT_ISSUER, + "iat": now, + "exp": expires, + "type": "access", + "jti": str(uuid4()), + } + if extra_claims: + claims.update(extra_claims) + return jwt.encode(claims, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + + +def create_refresh_token(subject: str | UUID) -> str: + now = datetime.now(timezone.utc) + expires = now + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS) + claims: dict[str, Any] = { + "sub": str(subject), + "iss": settings.JWT_ISSUER, + "iat": now, + "exp": expires, + "type": "refresh", + "jti": str(uuid4()), + } + return jwt.encode(claims, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + + +def decode_token(token: str) -> dict[str, Any]: + try: + payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM], issuer=settings.JWT_ISSUER) + return payload + except JWTError as e: + raise UnauthorizedException(message=f"Invalid token: {e}") from e + + +def validate_token_type(payload: dict[str, Any], expected_type: str = "access") -> dict[str, Any]: + if payload.get("type") != expected_type: + raise UnauthorizedException(message=f"Expected {expected_type} token, got {payload.get('type')}") + return payload + + +class TokenData: + def __init__(self, payload: dict[str, Any]): + self.subject: str = payload.get("sub", "") + self.role: UserRole = UserRole(payload.get("role", "viewer")) + self.scopes: list[str] = payload.get("scopes", []) + self.issuer: str = payload.get("iss", "") + self.token_type: str = payload.get("type", "access") + self.jti: str = payload.get("jti", "") + self.exp: datetime | None = None + if exp := payload.get("exp"): + self.exp = datetime.fromtimestamp(exp, tz=timezone.utc) + + +class RBACGuard: + ROLE_HIERARCHY: dict[UserRole, set[UserRole]] = { + UserRole.SUPERADMIN: {UserRole.SUPERADMIN, UserRole.ADMIN, UserRole.OPERATOR, UserRole.VIEWER, UserRole.API_CLIENT}, + UserRole.ADMIN: {UserRole.ADMIN, UserRole.OPERATOR, UserRole.VIEWER, UserRole.API_CLIENT}, + UserRole.OPERATOR: {UserRole.OPERATOR, UserRole.VIEWER}, + UserRole.VIEWER: {UserRole.VIEWER}, + UserRole.API_CLIENT: {UserRole.API_CLIENT, UserRole.VIEWER}, + } + + @classmethod + def has_role(cls, token_data: TokenData, required_role: UserRole) -> bool: + allowed = cls.ROLE_HIERARCHY.get(token_data.role, set()) + return required_role in allowed + + @classmethod + def require_role(cls, token_data: TokenData, required_role: UserRole) -> None: + if not cls.has_role(token_data, required_role): + raise ForbiddenException(required_role=required_role.value) + + @classmethod + def has_scope(cls, token_data: TokenData, required_scope: str) -> bool: + if "*" in token_data.scopes: + return True + return required_scope in token_data.scopes + + @classmethod + def require_scope(cls, token_data: TokenData, required_scope: str) -> None: + if not cls.has_scope(token_data, required_scope): + raise ForbiddenException(message=f"Scope '{required_scope}' required") + + +def generate_api_key() -> tuple[str, str, str]: + raw_key = secrets.token_urlsafe(settings.API_KEY_LENGTH) + prefix = raw_key[:8] + key_hash = hashlib.sha256(raw_key.encode()).digest() + return raw_key, prefix, key_hash + + +def hash_api_key(raw_key: str) -> bytes: + return hashlib.sha256(raw_key.encode()).digest() + + +def verify_api_key(raw_key: str, stored_hash: bytes) -> bool: + return hashlib.sha256(raw_key.encode()).digest() == stored_hash diff --git a/pip-platform/src/services/auth/user_service.py b/pip-platform/src/services/auth/user_service.py new file mode 100644 index 0000000..7ce033d --- /dev/null +++ b/pip-platform/src/services/auth/user_service.py @@ -0,0 +1,71 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.core.exceptions import ConflictException, ForbiddenException, NotFoundException, ValidationException +from src.core.logging import get_logger +from src.domain.entities import TechnicalUserEntity, UserRole +from src.infrastructure.database.models import TechnicalUserModel +from src.infrastructure.database.repositories import TechnicalUserRepository +from src.services.auth.security import RBACGuard, TokenData, hash_password, verify_password + +logger = get_logger("user_service") + + +class UserService: + def __init__(self, session: AsyncSession) -> None: + self._session = session + self._repo = TechnicalUserRepository(session) + + async def get_user(self, user_id: uuid.UUID) -> TechnicalUserEntity: + user = await self._repo.get_by_id(user_id) + if not user: + raise NotFoundException("User", str(user_id)) + return user + + async def list_users(self, *, role: str | None = None, is_active: bool | None = None, offset: int = 0, limit: int = 50, caller: TokenData | None = None) -> tuple[list[TechnicalUserEntity], int]: + if caller and not RBACGuard.has_scope(caller, "users:read"): + raise ForbiddenException("Scope 'users:read' required") + return await self._repo.list_users(role=role, is_active=is_active, offset=offset, limit=limit) + + async def create_user(self, username: str, password: str, email: str | None = None, full_name: str | None = None, role: UserRole = UserRole.VIEWER, caller: TokenData | None = None) -> TechnicalUserEntity: + if caller: + RBACGuard.require_role(caller, UserRole.ADMIN) + existing = await self._repo.get_by_username(username) + if existing: + raise ConflictException(f"Username '{username}' already exists") + pw_hash = hash_password(password) + entity = TechnicalUserEntity(username=username, email=email, full_name=full_name, role=role, is_active=True, scopes=[]) + user = await self._repo.create(entity, pw_hash) + logger.info("user_created", extra={"username": username, "role": role.value, "created_by": caller.subject if caller else "system"}) + return user + + async def update_user(self, user_id: uuid.UUID, *, email: str | None = None, full_name: str | None = None, role: UserRole | None = None, is_active: bool | None = None, caller: TokenData | None = None) -> TechnicalUserEntity: + if caller: + RBACGuard.require_role(caller, UserRole.ADMIN) + user = await self.get_user(user_id) + if email is not None: + user.email = email + if full_name is not None: + user.full_name = full_name + if role is not None: + user.role = role + if is_active is not None: + user.is_active = is_active + user = await self._repo.update(user) + logger.info("user_updated", extra={"user_id": str(user_id), "updated_by": caller.subject if caller else "system"}) + return user + + async def change_password(self, user_id: uuid.UUID, current_password: str, new_password: str, caller: TokenData | None = None) -> bool: + stmt = select(TechnicalUserModel.password_hash).where(TechnicalUserModel.id == user_id) + result = await self._session.execute(stmt) + pw_hash = result.scalar_one_or_none() + if not pw_hash or not verify_password(current_password, pw_hash): + raise ValidationException("Current password is incorrect") + new_hash = hash_password(new_password) + stmt = update(TechnicalUserModel).where(TechnicalUserModel.id == user_id).values(password_hash=new_hash) + await self._session.execute(stmt) + logger.info("password_changed", extra={"user_id": str(user_id)}) + return True diff --git a/pip-platform/src/services/connector/__init__.py b/pip-platform/src/services/connector/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/services/connector_service.py b/pip-platform/src/services/connector_service.py new file mode 100644 index 0000000..6518faa --- /dev/null +++ b/pip-platform/src/services/connector_service.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from src.connectors.base import ConnectorResult +from src.connectors.registry import connector_registry +from src.connectors.sdk.context import ConnectorContext +from src.core.exceptions import ConnectorException +from src.core.logging import get_logger +from src.domain.entities import ConnectorConfigEntity, ConnectorEntity, ConnectorStatus +from src.domain.interfaces import IConnectorConfigRepository, IConnectorRepository +from src.infrastructure.messaging.rabbitmq import rabbitmq_client + +logger = get_logger("connector_service") + + +class ConnectorService: + def __init__(self, connector_repo: IConnectorRepository, config_repo: IConnectorConfigRepository) -> None: + self._connector_repo = connector_repo + self._config_repo = config_repo + + async def create_connector(self, entity: ConnectorEntity, configs: list[ConnectorConfigEntity] | None = None) -> ConnectorEntity: + created = await self._connector_repo.create(entity) + + if configs: + for cfg in configs: + cfg.connector_id = created.id + await self._config_repo.upsert(cfg) + + await self._publish_event("connector.created", created.id, {"connector_type": created.connector_type, "pharmacy_id": str(created.pharmacy_id), "erp_system_id": str(created.erp_system_id), "status": created.status}) + logger.info("connector_created", extra={"connector_id": str(created.id), "connector_type": created.connector_type}) + return created + + async def get_connector(self, connector_id: UUID) -> ConnectorEntity | None: + return await self._connector_repo.get_by_id(connector_id) + + async def list_connectors(self, *, erp_system_id: UUID | None = None, pharmacy_id: UUID | None = None, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[list[ConnectorEntity], int]: + return await self._connector_repo.list_connectors(erp_system_id=erp_system_id, pharmacy_id=pharmacy_id, status=status, offset=offset, limit=limit) + + async def update_connector(self, entity: ConnectorEntity) -> ConnectorEntity: + updated = await self._connector_repo.update(entity) + await self._publish_event("connector.updated", updated.id, {"connector_type": updated.connector_type, "status": updated.status}) + return updated + + async def delete_connector(self, connector_id: UUID) -> bool: + connector = await self._connector_repo.get_by_id(connector_id) + deleted = await self._connector_repo.delete(connector_id) + if deleted: + connector_registry.remove_instance(connector_id) + if connector: + await self._publish_event("connector.deleted", connector_id, {"connector_type": connector.connector_type, "pharmacy_id": str(connector.pharmacy_id)}) + return deleted + + async def test_connection(self, connector_id: UUID) -> ConnectorResult: + connector_entity = await self._connector_repo.get_by_id(connector_id) + if not connector_entity: + raise ConnectorException("unknown", f"Connector '{connector_id}' not found") + + configs = await self._config_repo.list_by_connector(connector_id) + config_dict = {c.key: c.value for c in configs} + secrets_dict = {c.key: c.value for c in configs if c.encrypted} + + context = ConnectorContext( + connector_id=connector_entity.id, + pharmacy_id=connector_entity.pharmacy_id, + erp_system_id=connector_entity.erp_system_id, + connector_type=connector_entity.connector_type, + config=config_dict, + secrets=secrets_dict, + ) + + instance = connector_registry.get_or_create_instance(connector_id, connector_entity.connector_type, config_dict) + if hasattr(instance, "set_context"): + instance.set_context(context) + + result = await instance.heartbeat(connector_entity.pharmacy_id) + + if result.success: + connector_entity.status = ConnectorStatus.ACTIVE + connector_entity.last_heartbeat_at = datetime.now(timezone.utc) + connector_entity.last_error = None + await self._connector_repo.update(connector_entity) + await self._publish_event("connector.heartbeat", connector_id, {"status": "healthy"}) + else: + connector_entity.status = ConnectorStatus.ERROR + connector_entity.last_error = result.error + await self._connector_repo.update(connector_entity) + await self._publish_event("connector.error", connector_id, {"error": result.error}) + + return result + + async def deploy_connector(self, connector_id: UUID) -> ConnectorEntity: + entity = await self._connector_repo.get_by_id(connector_id) + if not entity: + raise ConnectorException("unknown", f"Connector '{connector_id}' not found") + + if entity.status == ConnectorStatus.ACTIVE: + raise ConnectorException(entity.connector_type, "Connector is already active. Deactivate first to redeploy.") + + configs = await self._config_repo.list_by_connector(connector_id) + config_dict = {c.key: c.value for c in configs} + + connector_registry.remove_instance(connector_id) + connector_registry.get_or_create_instance(connector_id, entity.connector_type, config_dict) + + entity.status = ConnectorStatus.ACTIVE + entity.last_heartbeat_at = datetime.now(timezone.utc) + entity.last_error = None + updated = await self._connector_repo.update(entity) + + await self._publish_event("connector.deployed", connector_id, {"connector_type": entity.connector_type, "status": "active"}) + logger.info("connector_deployed", extra={"connector_id": str(connector_id)}) + return updated + + async def deactivate_connector(self, connector_id: UUID) -> ConnectorEntity: + entity = await self._connector_repo.get_by_id(connector_id) + if not entity: + raise ConnectorException("unknown", f"Connector '{connector_id}' not found") + + connector_registry.remove_instance(connector_id) + + entity.status = ConnectorStatus.INACTIVE + updated = await self._connector_repo.update(entity) + + await self._publish_event("connector.deactivated", connector_id, {"connector_type": entity.connector_type, "status": "inactive"}) + return updated + + async def get_configs(self, connector_id: UUID) -> list[ConnectorConfigEntity]: + return await self._config_repo.list_by_connector(connector_id) + + async def upsert_config(self, entity: ConnectorConfigEntity) -> ConnectorConfigEntity: + return await self._config_repo.upsert(entity) + + async def delete_config(self, config_id: UUID) -> bool: + return await self._config_repo.delete(config_id) + + async def _publish_event(self, event_type: str, connector_id: UUID, payload: dict[str, Any]) -> None: + try: + routing_key = f"connector.{event_type.split('.', 1)[1]}" if "." in event_type else f"connector.{event_type}" + full_payload = {"event_type": event_type, "connector_id": str(connector_id), "timestamp": datetime.now(timezone.utc).isoformat(), **payload} + await rabbitmq_client.publish(routing_key, full_payload) + except Exception as exc: + logger.warning("connector_event_publish_failed", extra={"event_type": event_type, "error": str(exc)}) diff --git a/pip-platform/src/services/inventory/inventory_service.py b/pip-platform/src/services/inventory/inventory_service.py new file mode 100644 index 0000000..397d457 --- /dev/null +++ b/pip-platform/src/services/inventory/inventory_service.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from src.core.exceptions import ConflictException, NotFoundException, ValidationException +from src.core.logging import get_logger +from src.domain.entities import ( + InventoryHistoryEntity, + InventoryRecordEntity, + ReservationEntity, + ReservationStatus, +) +from src.domain.interfaces import IInventoryRepository, IReservationRepository +from src.infrastructure.messaging.rabbitmq import rabbitmq_client + +logger = get_logger("inventory_service") + +RESERVATION_DEFAULT_TTL_MINUTES = 30 + + +class InventoryService: + def __init__( + self, + inventory_repo: IInventoryRepository, + reservation_repo: IReservationRepository, + ) -> None: + self._inventory_repo = inventory_repo + self._reservation_repo = reservation_repo + + async def get_inventory_record(self, pharmacy_id: UUID, medication_id: UUID) -> InventoryRecordEntity | None: + return await self._inventory_repo.get_record(pharmacy_id, medication_id) + + async def list_inventory(self, pharmacy_id: UUID, *, offset: int = 0, limit: int = 50) -> tuple[list[InventoryRecordEntity], int]: + return await self._inventory_repo.list_by_pharmacy(pharmacy_id, offset=offset, limit=limit) + + async def upsert_inventory_record(self, entity: InventoryRecordEntity) -> InventoryRecordEntity: + existing = await self._inventory_repo.get_record(entity.pharmacy_id, entity.medication_id) + previous_stock = existing.stock if existing else 0 + + upserted = await self._inventory_repo.upsert(entity) + + delta = entity.stock - previous_stock + if delta != 0: + history = InventoryHistoryEntity( + inventory_record_id=upserted.id, + pharmacy_id=entity.pharmacy_id, + medication_id=entity.medication_id, + previous_stock=previous_stock, + new_stock=entity.stock, + delta=delta, + reason=entity.source or "upsert", + source=entity.source, + ) + await self._inventory_repo.add_history(history) + + await self._publish_event("inventory.updated", upserted.id, { + "pharmacy_id": str(entity.pharmacy_id), + "medication_id": str(entity.medication_id), + "previous_stock": previous_stock, + "new_stock": entity.stock, + "delta": delta, + }) + return upserted + + async def get_inventory_history(self, pharmacy_id: UUID, medication_id: UUID, *, offset: int = 0, limit: int = 50) -> list[InventoryHistoryEntity]: + records = await self._inventory_repo.get_history(pharmacy_id, medication_id, offset=offset, limit=limit) + return list(records) + + async def create_reservation( + self, + pharmacy_id: UUID, + medication_id: UUID, + quantity: int, + *, + user_id: UUID | None = None, + external_reference: str | None = None, + idempotency_key: str | None = None, + ttl_minutes: int = RESERVATION_DEFAULT_TTL_MINUTES, + ) -> ReservationEntity: + if idempotency_key: + existing = await self._reservation_repo.get_by_idempotency_key(idempotency_key) + if existing: + return existing + + record = await self._inventory_repo.get_record(pharmacy_id, medication_id) + if not record: + raise NotFoundException("InventoryRecord", f"{pharmacy_id}/{medication_id}") + + if record.available_stock < quantity: + raise ValidationException( + f"Insufficient available stock. Requested: {quantity}, Available: {record.available_stock}", + details={"available_stock": record.available_stock, "requested": quantity}, + ) + + record.reserved_stock += quantity + record.available_stock = record.stock - record.reserved_stock + await self._inventory_repo.upsert(record) + + now = datetime.now(timezone.utc) + entity = ReservationEntity( + pharmacy_id=pharmacy_id, + medication_id=medication_id, + user_id=user_id, + external_reference=external_reference, + quantity=quantity, + status=ReservationStatus.PENDING, + expires_at=now + __import__("datetime").timedelta(minutes=ttl_minutes), + idempotency_key=idempotency_key, + ) + created = await self._reservation_repo.create(entity) + + await self._publish_event("reservation.created", created.id, { + "pharmacy_id": str(pharmacy_id), + "medication_id": str(medication_id), + "quantity": quantity, + }) + return created + + async def confirm_reservation(self, reservation_id: UUID) -> ReservationEntity: + entity = await self._reservation_repo.get_by_id(reservation_id) + if not entity: + raise NotFoundException("Reservation", str(reservation_id)) + + if entity.status != ReservationStatus.PENDING: + raise ValidationException(f"Reservation cannot be confirmed. Current status: {entity.status}") + + now = datetime.now(timezone.utc) + if entity.expires_at and entity.expires_at < now: + entity.status = ReservationStatus.EXPIRED + await self._reservation_repo.update(entity) + await self._release_reserved_stock(entity) + await self._publish_event("reservation.expired", entity.id, {"pharmacy_id": str(entity.pharmacy_id)}) + raise ValidationException("Reservation has expired") + + entity.status = ReservationStatus.CONFIRMED + entity.confirmed_at = now + updated = await self._reservation_repo.update(entity) + + await self._publish_event("reservation.confirmed", updated.id, { + "pharmacy_id": str(entity.pharmacy_id), + "medication_id": str(entity.medication_id), + "quantity": entity.quantity, + }) + return updated + + async def cancel_reservation(self, reservation_id: UUID) -> ReservationEntity: + entity = await self._reservation_repo.get_by_id(reservation_id) + if not entity: + raise NotFoundException("Reservation", str(reservation_id)) + + if entity.status not in (ReservationStatus.PENDING, ReservationStatus.CONFIRMED): + raise ValidationException(f"Reservation cannot be cancelled. Current status: {entity.status}") + + entity.status = ReservationStatus.CANCELLED + entity.cancelled_at = datetime.now(timezone.utc) + updated = await self._reservation_repo.update(entity) + + await self._release_reserved_stock(entity) + + await self._publish_event("reservation.cancelled", updated.id, { + "pharmacy_id": str(entity.pharmacy_id), + "medication_id": str(entity.medication_id), + "quantity": entity.quantity, + }) + return updated + + async def get_reservation(self, reservation_id: UUID) -> ReservationEntity | None: + return await self._reservation_repo.get_by_id(reservation_id) + + async def list_reservations(self, pharmacy_id: UUID, *, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[list[ReservationEntity], int]: + return await self._reservation_repo.list_by_pharmacy(pharmacy_id, status=status, offset=offset, limit=limit) + + async def expire_pending_reservations(self) -> int: + pass + + async def _release_reserved_stock(self, reservation: ReservationEntity) -> None: + record = await self._inventory_repo.get_record(reservation.pharmacy_id, reservation.medication_id) + if not record: + return + + record.reserved_stock = max(0, record.reserved_stock - reservation.quantity) + record.available_stock = record.stock - record.reserved_stock + updated = await self._inventory_repo.upsert(record) + + history = InventoryHistoryEntity( + inventory_record_id=updated.id, + pharmacy_id=reservation.pharmacy_id, + medication_id=reservation.medication_id, + previous_stock=record.stock + reservation.quantity if reservation.status == ReservationStatus.PENDING else record.stock, + new_stock=record.stock, + delta=-reservation.quantity, + reason=f"reservation_{reservation.status}", + source="inventory_service", + ) + await self._inventory_repo.add_history(history) + + async def _publish_event(self, event_type: str, resource_id: UUID, payload: dict[str, Any]) -> None: + try: + routing_key = event_type.replace(".", "_") + full_payload = { + "event_type": event_type, + "resource_id": str(resource_id), + "timestamp": datetime.now(timezone.utc).isoformat(), + **payload, + } + await rabbitmq_client.publish(routing_key, full_payload) + except Exception as exc: + logger.warning("inventory_event_publish_failed", extra={"event_type": event_type, "error": str(exc)}) diff --git a/pip-platform/src/services/medication/__init__.py b/pip-platform/src/services/medication/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/services/medication/medication_service.py b/pip-platform/src/services/medication/medication_service.py new file mode 100644 index 0000000..12bb413 --- /dev/null +++ b/pip-platform/src/services/medication/medication_service.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from src.core.exceptions import ConflictException, NotFoundException, ValidationException +from src.core.logging import get_logger +from src.domain.entities import MedicationCatalogEntryEntity, MedicationEntity +from src.domain.interfaces import IMedicationCatalogRepository, IMedicationRepository +from src.infrastructure.messaging.rabbitmq import rabbitmq_client + +logger = get_logger("medication_service") + + +class MedicationService: + def __init__( + self, + medication_repo: IMedicationRepository, + catalog_repo: IMedicationCatalogRepository, + ) -> None: + self._medication_repo = medication_repo + self._catalog_repo = catalog_repo + + async def create_medication(self, entity: MedicationEntity) -> MedicationEntity: + existing = await self._medication_repo.get_by_nregistro(entity.nregistro) + if existing: + raise ConflictException(f"Medication with nregistro '{entity.nregistro}' already exists") + created = await self._medication_repo.create(entity) + await self._publish_event("medication.created", created.id, {"nregistro": created.nregistro, "name": created.name}) + return created + + async def get_medication(self, medication_id: UUID) -> MedicationEntity | None: + return await self._medication_repo.get_by_id(medication_id) + + async def get_by_nregistro(self, nregistro: str) -> MedicationEntity | None: + return await self._medication_repo.get_by_nregistro(nregistro) + + async def search_medications(self, query: str, *, offset: int = 0, limit: int = 50) -> tuple[list[MedicationEntity], int]: + return await self._medication_repo.search(query, offset=offset, limit=limit) + + async def update_medication(self, entity: MedicationEntity) -> MedicationEntity: + existing = await self._medication_repo.get_by_id(entity.id) # type: ignore[arg-type] + if not existing: + raise NotFoundException("Medication", str(entity.id)) + updated = await self._medication_repo.update(entity) + await self._publish_event("medication.updated", updated.id, {"nregistro": updated.nregistro}) + return updated + + async def delete_medication(self, medication_id: UUID) -> bool: + deleted = await self._medication_repo.delete(medication_id) + if deleted: + await self._publish_event("medication.deleted", medication_id, {}) + return deleted + + async def get_catalog_entry(self, pharmacy_id: UUID, medication_id: UUID) -> MedicationCatalogEntryEntity | None: + return await self._catalog_repo.get_entry(pharmacy_id, medication_id) + + async def list_catalog(self, pharmacy_id: UUID, *, offset: int = 0, limit: int = 50) -> tuple[list[MedicationCatalogEntryEntity], int]: + return await self._catalog_repo.list_by_pharmacy(pharmacy_id, offset=offset, limit=limit) + + async def upsert_catalog_entry(self, entity: MedicationCatalogEntryEntity) -> MedicationCatalogEntryEntity: + upserted = await self._catalog_repo.upsert(entity) + await self._publish_event("catalog.upserted", upserted.id, { + "pharmacy_id": str(entity.pharmacy_id), + "medication_id": str(entity.medication_id), + "is_available": entity.is_available, + "stock": entity.stock, + }) + return upserted + + async def _publish_event(self, event_type: str, resource_id: UUID, payload: dict[str, Any]) -> None: + try: + routing_key = event_type.replace(".", "_") + full_payload = { + "event_type": event_type, + "resource_id": str(resource_id), + "timestamp": datetime.now(timezone.utc).isoformat(), + **payload, + } + await rabbitmq_client.publish(routing_key, full_payload) + except Exception as exc: + logger.warning("medication_event_publish_failed", extra={"event_type": event_type, "error": str(exc)}) diff --git a/pip-platform/src/services/notification/__init__.py b/pip-platform/src/services/notification/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/services/pharmacy/__init__.py b/pip-platform/src/services/pharmacy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/services/synchronization/__init__.py b/pip-platform/src/services/synchronization/__init__.py new file mode 100644 index 0000000..d78d0fe --- /dev/null +++ b/pip-platform/src/services/synchronization/__init__.py @@ -0,0 +1,3 @@ +from .sync_service import SyncService + +__all__ = ["SyncService"] diff --git a/pip-platform/src/services/synchronization/sync_service.py b/pip-platform/src/services/synchronization/sync_service.py new file mode 100644 index 0000000..57d3c63 --- /dev/null +++ b/pip-platform/src/services/synchronization/sync_service.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from src.core.exceptions import NotFoundException, ValidationException +from src.core.logging import get_logger +from src.domain.entities import SyncJobEntity, SyncJobStatus, SyncStrategy +from src.domain.interfaces import ISyncJobRepository, ISyncLogRepository +from src.infrastructure.messaging.rabbitmq import rabbitmq_client + +logger = get_logger("sync_service") + + +class SyncService: + def __init__( + self, + sync_job_repo: ISyncJobRepository, + sync_log_repo: ISyncLogRepository, + ) -> None: + self._sync_job_repo = sync_job_repo + self._sync_log_repo = sync_log_repo + + async def create_sync_job( + self, + pharmacy_id: UUID, + connector_id: UUID, + strategy: SyncStrategy, + ) -> SyncJobEntity: + entity = SyncJobEntity( + pharmacy_id=pharmacy_id, + connector_id=connector_id, + strategy=strategy, + status=SyncJobStatus.PENDING, + ) + created = await self._sync_job_repo.create(entity) + await self._publish_event("sync.created", created.id, { + "pharmacy_id": str(pharmacy_id), + "connector_id": str(connector_id), + "strategy": strategy, + }) + return created + + async def get_sync_job(self, job_id: UUID) -> SyncJobEntity | None: + return await self._sync_job_repo.get_by_id(job_id) + + async def list_sync_jobs( + self, + pharmacy_id: UUID, + *, + status: str | None = None, + offset: int = 0, + limit: int = 50, + ) -> tuple[list[SyncJobEntity], int]: + return await self._sync_job_repo.list_by_pharmacy( + pharmacy_id, status=status, offset=offset, limit=limit, + ) + + async def trigger_sync(self, job_id: UUID) -> SyncJobEntity: + entity = await self._sync_job_repo.get_by_id(job_id) + if not entity: + raise NotFoundException("SyncJob", str(job_id)) + + if entity.status not in (SyncJobStatus.PENDING, SyncJobStatus.FAILED): + raise ValidationException( + f"Cannot trigger sync job. Current status: {entity.status}", + details={"current_status": entity.status}, + ) + + entity.status = SyncJobStatus.RUNNING + entity.started_at = datetime.now(timezone.utc) + entity.error_message = None + updated = await self._sync_job_repo.update(entity) + + await self._add_log(job_id, "INFO", "Sync job triggered") + await self._publish_event("sync.started", updated.id, { + "pharmacy_id": str(entity.pharmacy_id), + "strategy": entity.strategy, + }) + return updated + + async def cancel_sync(self, job_id: UUID) -> SyncJobEntity: + entity = await self._sync_job_repo.get_by_id(job_id) + if not entity: + raise NotFoundException("SyncJob", str(job_id)) + + if entity.status != SyncJobStatus.RUNNING: + raise ValidationException( + f"Cannot cancel sync job. Current status: {entity.status}", + details={"current_status": entity.status}, + ) + + entity.status = SyncJobStatus.CANCELLED + entity.completed_at = datetime.now(timezone.utc) + updated = await self._sync_job_repo.update(entity) + + await self._add_log(job_id, "WARNING", "Sync job cancelled by user") + await self._publish_event("sync.cancelled", updated.id, { + "pharmacy_id": str(entity.pharmacy_id), + }) + return updated + + async def complete_sync( + self, + job_id: UUID, + *, + items_total: int | None = None, + items_processed: int | None = None, + items_failed: int | None = None, + ) -> SyncJobEntity: + entity = await self._sync_job_repo.get_by_id(job_id) + if not entity: + raise NotFoundException("SyncJob", str(job_id)) + + entity.status = SyncJobStatus.COMPLETED + entity.completed_at = datetime.now(timezone.utc) + if items_total is not None: + entity.items_total = items_total + if items_processed is not None: + entity.items_processed = items_processed + if items_failed is not None: + entity.items_failed = items_failed + updated = await self._sync_job_repo.update(entity) + + await self._add_log(job_id, "INFO", f"Sync completed. Processed: {items_processed}, Failed: {items_failed}") + await self._publish_event("sync.completed", updated.id, { + "pharmacy_id": str(entity.pharmacy_id), + "items_total": items_total, + "items_processed": items_processed, + "items_failed": items_failed, + }) + return updated + + async def fail_sync(self, job_id: UUID, error_message: str) -> SyncJobEntity: + entity = await self._sync_job_repo.get_by_id(job_id) + if not entity: + raise NotFoundException("SyncJob", str(job_id)) + + entity.status = SyncJobStatus.FAILED + entity.completed_at = datetime.now(timezone.utc) + entity.error_message = error_message + updated = await self._sync_job_repo.update(entity) + + await self._add_log(job_id, "ERROR", f"Sync failed: {error_message}") + await self._publish_event("sync.failed", updated.id, { + "pharmacy_id": str(entity.pharmacy_id), + "error_message": error_message, + }) + return updated + + async def get_sync_logs( + self, + job_id: UUID, + *, + level: str | None = None, + offset: int = 0, + limit: int = 100, + ): + return await self._sync_log_repo.list_by_job(job_id, level=level, offset=offset, limit=limit) + + async def _add_log(self, job_id: UUID, level: str, message: str, details: dict | None = None) -> None: + from src.domain.entities import SyncLogEntity + log = SyncLogEntity(sync_job_id=job_id, level=level, message=message, details=details) + await self._sync_log_repo.add(log) + + async def _publish_event(self, event_type: str, resource_id: UUID, payload: dict[str, Any]) -> None: + try: + routing_key = event_type.replace(".", "_") + full_payload = { + "event_type": event_type, + "resource_id": str(resource_id), + "timestamp": datetime.now(timezone.utc).isoformat(), + **payload, + } + await rabbitmq_client.publish(routing_key, full_payload) + except Exception as exc: + logger.warning("sync_event_publish_failed", extra={"event_type": event_type, "error": str(exc)}) diff --git a/pip-platform/tests/__init__.py b/pip-platform/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/tests/conftest.py b/pip-platform/tests/conftest.py new file mode 100644 index 0000000..128fbb7 --- /dev/null +++ b/pip-platform/tests/conftest.py @@ -0,0 +1,78 @@ +import asyncio +from collections.abc import AsyncGenerator +from uuid import uuid4 + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from src.infrastructure.database.models import Base +from src.main import app + + +TEST_DATABASE_URL = "postgresql+asyncpg://pip:pip-secret@localhost:5432/pip_test" + + +@pytest.fixture(scope="session") +def event_loop(): + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +@pytest_asyncio.fixture(scope="session") +async def test_engine(): + engine = create_async_engine(TEST_DATABASE_URL, echo=False) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await engine.dispose() + + +@pytest_asyncio.fixture +async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]: + session_factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + async with session_factory() as session: + yield session + await session.rollback() + + +@pytest_asyncio.fixture +async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +@pytest.fixture +def sample_pharmacy_data(): + return { + "code": f"PHARM-{uuid4().hex[:8]}", + "name": "Test Pharmacy", + "address": "Calle Test 123", + "city": "Madrid", + "province": "Madrid", + "postal_code": "28001", + "country": "ES", + "latitude": 40.4168, + "longitude": -3.7038, + "phone": "+34912345678", + "email": "test@pharmacy.es", + "erp_type": "farmatic", + "sync_strategy": "incremental", + "sync_interval_minutes": 30, + } + + +@pytest.fixture +def sample_user_data(): + return { + "username": f"testuser_{uuid4().hex[:8]}", + "password": "TestPass123!", + "email": "test@pip.es", + "full_name": "Test User", + "role": "viewer", + } diff --git a/pip-platform/tests/integration/__init__.py b/pip-platform/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/tests/integration/test_api_key_auth.py b/pip-platform/tests/integration/test_api_key_auth.py new file mode 100644 index 0000000..c64dc27 --- /dev/null +++ b/pip-platform/tests/integration/test_api_key_auth.py @@ -0,0 +1,144 @@ +"""E2E tests for API Key authentication flow. + +Tests dual auth support (JWT Bearer + X-API-Key header), +API Key CRUD, scope enforcement, and role-based access control. +""" + +import asyncio +import os + +import httpx + +BASE_URL = os.environ.get("PIP_BASE_URL", "http://localhost:8000") +ADMIN_CREDS = {"username": os.environ.get("PIP_ADMIN_USER", "synctest"), "password": os.environ.get("PIP_ADMIN_PASS", "Test123!")} + + +async def get_admin_token(client: httpx.AsyncClient) -> str: + r = await client.post("/api/v1/auth/token", json=ADMIN_CREDS) + assert r.status_code == 200, f"Login failed: {r.status_code} {r.text[:200]}" + return r.json()["access_token"] + + +def bearer_headers(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +async def test_api_key_crud(): + async with httpx.AsyncClient(base_url=BASE_URL) as c: + token = await get_admin_token(c) + h = bearer_headers(token) + + clients = await c.get("/api/v1/api-clients", headers=h) + assert clients.status_code == 200 + client_id = next((ci["id"] for ci in clients.json().get("items", []) if ci["name"] == "Test Client"), None) + + if not client_id: + resp = await c.post("/api/v1/api-clients", headers=h, json={ + "name": "Test Client", + "description": "E2E test client", + "scopes": ["pharmacies:read", "medications:read", "inventory:read"], + }) + assert resp.status_code == 201 + client_id = resp.json()["id"] + + key_resp = await c.post(f"/api/v1/api-clients/{client_id}/keys", headers=h, json={"name": "E2E CRUD Key"}) + assert key_resp.status_code == 201, f"Key create failed: {key_resp.status_code} {key_resp.text[:200]}" + raw_key = key_resp.json()["key"] + key_prefix = key_resp.json()["key_prefix"] + assert len(raw_key) > 20 + assert len(key_prefix) == 8 + + keys = await c.get(f"/api/v1/api-clients/{client_id}/keys", headers=h) + assert keys.status_code == 200 + assert len(keys.json()) >= 1 + + print(" PASS: test_api_key_crud") + + +async def test_api_key_read_access(): + async with httpx.AsyncClient(base_url=BASE_URL) as c: + token = await get_admin_token(c) + h = bearer_headers(token) + + clients = await c.get("/api/v1/api-clients", headers=h) + client_id = next((ci["id"] for ci in clients.json().get("items", []) if ci["name"] == "Test Client"), None) + kr = await c.post(f"/api/v1/api-clients/{client_id}/keys", headers=h, json={"name": "Read Access Key"}) + raw_key = kr.json()["key"] + api_h = {"X-API-Key": raw_key} + + r = await c.get("/api/v1/pharmacies", headers=api_h) + assert r.status_code == 200 + assert r.json()["total"] >= 0 + + r2 = await c.get("/api/v1/medications", headers=api_h) + assert r2.status_code == 200 + + r3 = await c.get("/api/v1/inventory", headers=api_h) + assert r3.status_code == 200 + + print(" PASS: test_api_key_read_access") + + +async def test_api_key_write_blocked(): + async with httpx.AsyncClient(base_url=BASE_URL) as c: + token = await get_admin_token(c) + h = bearer_headers(token) + + clients = await c.get("/api/v1/api-clients", headers=h) + client_id = next((ci["id"] for ci in clients.json().get("items", []) if ci["name"] == "Test Client"), None) + kr = await c.post(f"/api/v1/api-clients/{client_id}/keys", headers=h, json={"name": "Write Block Key"}) + raw_key = kr.json()["key"] + api_h = {"X-API-Key": raw_key} + + r = await c.post("/api/v1/pharmacies", headers=api_h, json={"code": "BLOCKED", "name": "Should Fail"}) + assert r.status_code == 403, f"Expected 403, got {r.status_code}" + + print(" PASS: test_api_key_write_blocked") + + +async def test_invalid_api_key(): + async with httpx.AsyncClient(base_url=BASE_URL) as c: + r = await c.get("/api/v1/pharmacies", headers={"X-API-Key": "invalidkey12345"}) + assert r.status_code == 401 + + print(" PASS: test_invalid_api_key") + + +async def test_no_auth_rejected(): + async with httpx.AsyncClient(base_url=BASE_URL) as c: + r = await c.get("/api/v1/pharmacies") + assert r.status_code == 401 + + print(" PASS: test_no_auth_rejected") + + +async def test_dual_auth_bearer_priority(): + async with httpx.AsyncClient(base_url=BASE_URL) as c: + token = await get_admin_token(c) + h = bearer_headers(token) + + clients = await c.get("/api/v1/api-clients", headers=h) + client_id = next((ci["id"] for ci in clients.json().get("items", []) if ci["name"] == "Test Client"), None) + kr = await c.post(f"/api/v1/api-clients/{client_id}/keys", headers=h, json={"name": "Dual Auth Key"}) + raw_key = kr.json()["key"] + + dual_h = {"Authorization": f"Bearer {token}", "X-API-Key": raw_key} + r = await c.get("/api/v1/pharmacies", headers=dual_h) + assert r.status_code == 200 + + print(" PASS: test_dual_auth_bearer_priority") + + +async def run_all(): + print("=== API Key Auth E2E Tests ===") + await test_api_key_crud() + await test_api_key_read_access() + await test_api_key_write_blocked() + await test_invalid_api_key() + await test_no_auth_rejected() + await test_dual_auth_bearer_priority() + print("=== All API Key tests passed ===") + + +if __name__ == "__main__": + asyncio.run(run_all()) diff --git a/pip-platform/tests/integration/test_rate_limiting.py b/pip-platform/tests/integration/test_rate_limiting.py new file mode 100644 index 0000000..885a717 --- /dev/null +++ b/pip-platform/tests/integration/test_rate_limiting.py @@ -0,0 +1,105 @@ +"""E2E tests for Rate Limiting middleware. + +Tests that rate limiting returns 429 with proper headers +when request count exceeds the configured limit. +""" + +import asyncio +import os + +import httpx + +BASE_URL = os.environ.get("PIP_BASE_URL", "http://localhost:8000") +ADMIN_CREDS = {"username": os.environ.get("PIP_ADMIN_USER", "synctest"), "password": os.environ.get("PIP_ADMIN_PASS", "Test123!")} + + +async def get_admin_token(client: httpx.AsyncClient) -> str: + r = await client.post("/api/v1/auth/token", json=ADMIN_CREDS) + assert r.status_code == 200 + return r.json()["access_token"] + + +def bearer_headers(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +async def test_rate_limit_headers(): + async with httpx.AsyncClient(base_url=BASE_URL) as c: + token = await get_admin_token(c) + h = bearer_headers(token) + + r = await c.get("/api/v1/pharmacies", headers=h) + assert r.status_code == 200 + assert "x-ratelimit-limit" in r.headers, f"Missing X-RateLimit-Limit header. Got: {list(r.headers.keys())}" + assert "x-ratelimit-remaining" in r.headers + assert "x-ratelimit-reset" in r.headers + + limit = int(r.headers["x-ratelimit-limit"]) + assert limit > 0 + + print(f" PASS: test_rate_limit_headers (limit={limit}/min)") + + +async def test_rate_limit_429(): + async with httpx.AsyncClient(base_url=BASE_URL) as c: + token = await get_admin_token(c) + h = bearer_headers(token) + + r0 = await c.get("/api/v1/pharmacies", headers=h) + limit = int(r0.headers["x-ratelimit-limit"]) + + successes = 0 + blocked = 0 + for i in range(limit + 10): + r = await c.get("/api/v1/pharmacies", headers=h) + if r.status_code == 200: + successes += 1 + elif r.status_code == 429: + blocked += 1 + assert "retry-after" in r.headers, "429 response missing Retry-After header" + break + + assert blocked > 0, f"Rate limit was not triggered after {limit + 10} requests" + + print(f" PASS: test_rate_limit_429 (blocked after {successes} requests)") + + +async def test_rate_limit_exempt_paths(): + async with httpx.AsyncClient(base_url=BASE_URL) as c: + r = await c.get("/docs") + assert r.status_code == 200 + assert "x-ratelimit-limit" not in r.headers, "Docs should be exempt from rate limiting" + + print(" PASS: test_rate_limit_exempt_paths") + + +async def test_rate_limit_per_client(): + async with httpx.AsyncClient(base_url=BASE_URL, timeout=30.0) as c: + token = await get_admin_token(c) + h = bearer_headers(token) + + r0 = await c.get("/api/v1/pharmacies", headers=h) + limit = int(r0.headers["x-ratelimit-limit"]) + + for _ in range(limit + 5): + r = await c.get("/api/v1/pharmacies", headers=h) + if r.status_code == 429: + break + + r2 = await c.get("/api/v1/system/health") + assert r2.status_code == 200, "Health endpoint should work regardless of rate limit on other paths" + + print(" PASS: test_rate_limit_per_client") + + +async def run_all(): + print("=== Rate Limiting E2E Tests ===") + await test_rate_limit_headers() + await test_rate_limit_429() + await test_rate_limit_exempt_paths() + await test_rate_limit_per_client() + print("=== All Rate Limiting tests passed ===") + + +if __name__ == "__main__": + asyncio.run(run_all()) diff --git a/pip-platform/tests/unit/__init__.py b/pip-platform/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/tests/unit/test_auth.py b/pip-platform/tests/unit/test_auth.py new file mode 100644 index 0000000..37acd22 --- /dev/null +++ b/pip-platform/tests/unit/test_auth.py @@ -0,0 +1,94 @@ +import pytest +from httpx import AsyncClient + +from src.services.auth.security import create_access_token, hash_password, verify_password +from src.domain.entities import UserRole + + +class TestPasswordHashing: + def test_hash_and_verify(self): + password = "SecurePass123!" + hashed = hash_password(password) + assert hashed != password + assert verify_password(password, hashed) + + def test_verify_wrong_password(self): + hashed = hash_password("CorrectPass123!") + assert not verify_password("WrongPass123!", hashed) + + +class TestJWT: + def test_create_access_token(self): + token = create_access_token( + subject="test-user-id", + role=UserRole.ADMIN, + scopes=["*"], + ) + assert isinstance(token, str) + assert len(token) > 0 + + def test_decode_access_token(self): + from src.services.auth.security import decode_token, validate_token_type, TokenData + + token = create_access_token( + subject="test-user-id", + role=UserRole.VIEWER, + scopes=["pharmacies:read"], + ) + payload = decode_token(token) + assert payload["sub"] == "test-user-id" + assert payload["role"] == "viewer" + assert payload["type"] == "access" + + validated = validate_token_type(payload, "access") + assert validated is not None + + def test_invalid_token_raises(self): + from src.core.exceptions import UnauthorizedException + from src.services.auth.security import decode_token + + with pytest.raises(UnauthorizedException): + decode_token("invalid.token.here") + + +class TestRBAC: + def test_superadmin_can_access_all(self): + from src.services.auth.security import RBACGuard, TokenData + + token_data = TokenData({"sub": "1", "role": "superadmin", "scopes": ["*"], "type": "access", "iss": "pip-platform", "jti": "test"}) + assert RBACGuard.has_role(token_data, UserRole.ADMIN) + assert RBACGuard.has_role(token_data, UserRole.VIEWER) + assert RBACGuard.has_role(token_data, UserRole.OPERATOR) + + def test_viewer_cannot_access_admin(self): + from src.services.auth.security import RBACGuard, TokenData + + token_data = TokenData({"sub": "2", "role": "viewer", "scopes": [], "type": "access", "iss": "pip-platform", "jti": "test"}) + assert not RBACGuard.has_role(token_data, UserRole.ADMIN) + + def test_wildcard_scope_grants_all(self): + from src.services.auth.security import RBACGuard, TokenData + + token_data = TokenData({"sub": "1", "role": "superadmin", "scopes": ["*"], "type": "access", "iss": "pip-platform", "jti": "test"}) + assert RBACGuard.has_scope(token_data, "pharmacies:read") + assert RBACGuard.has_scope(token_data, "pharmacies:write") + assert RBACGuard.has_scope(token_data, "anything:anything") + + def test_specific_scope(self): + from src.services.auth.security import RBACGuard, TokenData + + token_data = TokenData({"sub": "2", "role": "viewer", "scopes": ["pharmacies:read"], "type": "access", "iss": "pip-platform", "jti": "test"}) + assert RBACGuard.has_scope(token_data, "pharmacies:read") + assert not RBACGuard.has_scope(token_data, "pharmacies:write") + + +class TestAPIKey: + def test_generate_api_key(self): + from src.services.auth.security import generate_api_key, verify_api_key + + raw_key, prefix, key_hash = generate_api_key() + assert len(raw_key) > 20 + assert len(prefix) == 8 + assert raw_key[:8] == prefix + assert verify_api_key(raw_key, key_hash) + assert not verify_api_key("wrong-key", key_hash) diff --git a/pip-platform/tests/unit/test_connector_registry.py b/pip-platform/tests/unit/test_connector_registry.py new file mode 100644 index 0000000..80aedb4 --- /dev/null +++ b/pip-platform/tests/unit/test_connector_registry.py @@ -0,0 +1,102 @@ +from uuid import uuid4 + +import pytest + +from src.connectors.registry import ConnectorRegistry, _BUILTIN_CONNECTORS +from src.connectors.mock.mock_connector import MockConnector +from src.connectors.base import BaseInventoryConnector +from src.core.exceptions import ConnectorException + + +@pytest.fixture +def registry() -> ConnectorRegistry: + ConnectorRegistry._instance = None + reg = ConnectorRegistry.get_registry() + return reg + + +class TestConnectorRegistrySingleton: + def test_singleton(self, registry: ConnectorRegistry): + reg2 = ConnectorRegistry.get_registry() + assert reg2 is registry + + def test_new_instance_after_reset(self, registry: ConnectorRegistry): + ConnectorRegistry._instance = None + reg2 = ConnectorRegistry.get_registry() + assert reg2 is not registry + + +class TestConnectorRegistryBuiltinTypes: + def test_builtin_connectors_include_mock(self): + assert "mock" in _BUILTIN_CONNECTORS + + def test_builtin_connectors_include_farmatic(self): + assert "farmatic" in _BUILTIN_CONNECTORS + + def test_builtin_connectors_include_custom_rest(self): + assert "custom_rest" in _BUILTIN_CONNECTORS + + +class TestConnectorRegistryGetClass: + def test_get_mock_connector_class(self, registry: ConnectorRegistry): + cls = registry.get_connector_class("mock") + assert cls is MockConnector + + def test_get_unknown_type_raises(self, registry: ConnectorRegistry): + with pytest.raises(ConnectorException, match="No connector registered"): + registry.get_connector_class("nonexistent_type") + + +class TestConnectorRegistryCreateInstance: + def test_create_mock_instance(self, registry: ConnectorRegistry): + instance = registry.create_instance("mock") + assert isinstance(instance, BaseInventoryConnector) + assert instance.connector_type == "mock" + + def test_create_instance_with_config(self, registry: ConnectorRegistry): + instance = registry.create_instance("mock", {"simulate_latency": False, "error_rate": 0.0}) + assert instance._simulate_latency is False + + def test_create_unknown_type_raises(self, registry: ConnectorRegistry): + with pytest.raises(ConnectorException): + registry.create_instance("nonexistent") + + +class TestConnectorRegistryInstanceCache: + def test_get_or_create_returns_cached(self, registry: ConnectorRegistry): + cid = uuid4() + inst1 = registry.get_or_create_instance(cid, "mock") + inst2 = registry.get_or_create_instance(cid, "mock") + assert inst1 is inst2 + + def test_remove_instance(self, registry: ConnectorRegistry): + cid = uuid4() + registry.get_or_create_instance(cid, "mock") + registry.remove_instance(cid) + assert cid not in registry._instances + + def test_remove_nonexistent_instance_no_error(self, registry: ConnectorRegistry): + registry.remove_instance(uuid4()) + + +class TestConnectorRegistryListTypes: + def test_list_registered_types(self, registry: ConnectorRegistry): + types = registry.list_registered_types() + assert "mock" in types + assert "farmatic" in types + assert "custom_rest" in types + + def test_register_custom_type(self, registry: ConnectorRegistry): + mock = MockConnector() + mock.connector_type = "test_custom" + registry.register("test_custom", mock) + types = registry.list_registered_types() + assert "test_custom" in types + + def test_unregister_type(self, registry: ConnectorRegistry): + mock = MockConnector() + mock.connector_type = "temp_type" + registry.register("temp_type", mock) + registry.unregister("temp_type") + types = registry.list_registered_types() + assert "temp_type" not in types diff --git a/pip-platform/tests/unit/test_connector_sdk.py b/pip-platform/tests/unit/test_connector_sdk.py new file mode 100644 index 0000000..d0e36af --- /dev/null +++ b/pip-platform/tests/unit/test_connector_sdk.py @@ -0,0 +1,258 @@ +import time +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from src.connectors.sdk.circuit_breaker import CircuitBreaker, CircuitState +from src.connectors.sdk.retry import RetryPolicy, with_retry +from src.connectors.sdk.context import ConnectorContext +from src.connectors.sdk.auth import BasicAuthHandler, BearerAuthHandler, APIKeyAuthHandler +from src.core.exceptions import ServiceUnavailableException + + +class TestCircuitBreaker: + def test_initial_state_is_closed(self): + cb = CircuitBreaker(name="test") + assert cb.state == CircuitState.CLOSED + + def test_opens_after_threshold_failures(self): + cb = CircuitBreaker(name="test", failure_threshold=3) + for _ in range(3): + cb._on_failure(Exception("fail")) + assert cb._state == CircuitState.OPEN + + def test_closed_does_not_raise(self): + cb = CircuitBreaker(name="test") + + async def _run(): + coro = cb.call(AsyncMock(return_value="ok")) + return await coro + + import asyncio + result = asyncio.get_event_loop().run_until_complete(_run()) + assert result == "ok" + + def test_open_raises_service_unavailable(self): + cb = CircuitBreaker(name="test", failure_threshold=1) + cb._on_failure(Exception("fail")) + assert cb.state == CircuitState.OPEN + + async def _run(): + await cb.call(AsyncMock(return_value="ok")) + + import asyncio + with pytest.raises(ServiceUnavailableException): + asyncio.get_event_loop().run_until_complete(_run()) + + def test_half_open_after_recovery_timeout(self): + cb = CircuitBreaker(name="test", failure_threshold=1, recovery_timeout=0.01) + cb._on_failure(Exception("fail")) + assert cb._state == CircuitState.OPEN + time.sleep(0.02) + assert cb.state == CircuitState.HALF_OPEN + + def test_reset(self): + cb = CircuitBreaker(name="test", failure_threshold=1) + cb._on_failure(Exception("fail")) + cb.reset() + assert cb.state == CircuitState.CLOSED + assert cb._failure_count == 0 + + def test_success_resets_failure_count(self): + cb = CircuitBreaker(name="test", failure_threshold=5) + cb._on_failure(Exception("fail")) + cb._on_failure(Exception("fail")) + assert cb._failure_count == 2 + cb._on_success() + assert cb._failure_count == 0 + + def test_half_open_success_closes_circuit(self): + cb = CircuitBreaker(name="test", failure_threshold=1, recovery_timeout=0.01) + cb._on_failure(Exception("fail")) + time.sleep(0.02) + assert cb.state == CircuitState.HALF_OPEN + cb._on_success() + assert cb._state == CircuitState.CLOSED + + def test_half_open_failure_reopens(self): + cb = CircuitBreaker(name="test", failure_threshold=1, recovery_timeout=0.01) + cb._on_failure(Exception("fail")) + time.sleep(0.02) + assert cb.state == CircuitState.HALF_OPEN + cb._on_failure(Exception("fail again")) + assert cb._state == CircuitState.OPEN + + def test_get_status(self): + cb = CircuitBreaker(name="test") + status = cb.get_status() + assert status["name"] == "test" + assert status["state"] == "closed" + + +class TestRetryPolicy: + def test_default_values(self): + policy = RetryPolicy() + assert policy.max_attempts == 3 + assert policy.base_delay == 1.0 + assert policy.max_delay == 30.0 + + def test_custom_values(self): + policy = RetryPolicy(max_attempts=5, base_delay=0.5, max_delay=10.0) + assert policy.max_attempts == 5 + assert policy.base_delay == 0.5 + assert policy.max_delay == 10.0 + + +class TestWithRetry: + @pytest.mark.asyncio + async def test_success_first_try(self): + call_count = 0 + + async def _success(): + nonlocal call_count + call_count += 1 + return "ok" + + policy = RetryPolicy(max_attempts=3) + result = await with_retry(_success, policy=policy, connector_type="test", operation="test_op") + assert result == "ok" + assert call_count == 1 + + @pytest.mark.asyncio + async def test_retry_on_failure_then_success(self): + call_count = 0 + + async def _flaky(): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise Exception("temp fail") + return "ok" + + policy = RetryPolicy(max_attempts=3, base_delay=0.01, max_delay=0.1) + result = await with_retry(_flaky, policy=policy, connector_type="test", operation="flaky_op") + assert result == "ok" + assert call_count == 3 + + @pytest.mark.asyncio + async def test_exhaust_retries(self): + call_count = 0 + + async def _always_fail(): + nonlocal call_count + call_count += 1 + raise Exception("always fail") + + policy = RetryPolicy(max_attempts=2, base_delay=0.01, max_delay=0.1) + with pytest.raises(Exception, match="always fail"): + await with_retry(_always_fail, policy=policy, connector_type="test", operation="fail_op") + assert call_count == 2 + + +class TestConnectorContext: + def test_cache_key(self): + ctx = ConnectorContext( + connector_id=MagicMock(), + pharmacy_id=MagicMock(), + erp_system_id=MagicMock(), + connector_type="farmatic", + ) + key = ctx.cache_key("stock") + assert "farmatic" in key + assert "stock" in key + + def test_get_config(self): + ctx = ConnectorContext( + connector_id=MagicMock(), + pharmacy_id=MagicMock(), + erp_system_id=MagicMock(), + connector_type="mock", + config={"base_url": "https://example.com"}, + ) + assert ctx.get_config("base_url") == "https://example.com" + assert ctx.get_config("missing", "default") == "default" + + def test_get_secret(self): + ctx = ConnectorContext( + connector_id=MagicMock(), + pharmacy_id=MagicMock(), + erp_system_id=MagicMock(), + connector_type="mock", + secrets={"api_key": "secret123"}, + ) + assert ctx.get_secret("api_key") == "secret123" + assert ctx.get_secret("missing") is None + + def test_require_secret_raises(self): + ctx = ConnectorContext( + connector_id=MagicMock(), + pharmacy_id=MagicMock(), + erp_system_id=MagicMock(), + connector_type="mock", + ) + with pytest.raises(ValueError, match="Required secret"): + ctx.require_secret("missing") + + def test_require_config_raises(self): + ctx = ConnectorContext( + connector_id=MagicMock(), + pharmacy_id=MagicMock(), + erp_system_id=MagicMock(), + connector_type="mock", + ) + with pytest.raises(ValueError, match="Required config"): + ctx.require_config("missing") + + +class TestBasicAuthHandler: + @pytest.mark.asyncio + async def test_apply_basic_auth(self): + import httpx + + handler = BasicAuthHandler() + ctx = ConnectorContext( + connector_id=MagicMock(), + pharmacy_id=MagicMock(), + erp_system_id=MagicMock(), + connector_type="test", + config={"username": "user1", "password": "pass1"}, + ) + request = httpx.Request("GET", "https://example.com") + result = await handler.apply(request, ctx) + assert "Basic" in result.headers["Authorization"] + + +class TestBearerAuthHandler: + @pytest.mark.asyncio + async def test_apply_bearer_auth(self): + import httpx + + handler = BearerAuthHandler() + ctx = ConnectorContext( + connector_id=MagicMock(), + pharmacy_id=MagicMock(), + erp_system_id=MagicMock(), + connector_type="test", + config={"access_token": "token123"}, + ) + request = httpx.Request("GET", "https://example.com") + result = await handler.apply(request, ctx) + assert result.headers["Authorization"] == "Bearer token123" + + +class TestAPIKeyAuthHandler: + @pytest.mark.asyncio + async def test_apply_api_key_auth(self): + import httpx + + handler = APIKeyAuthHandler(header_name="X-Custom-Key") + ctx = ConnectorContext( + connector_id=MagicMock(), + pharmacy_id=MagicMock(), + erp_system_id=MagicMock(), + connector_type="test", + secrets={"api_key": "key123"}, + ) + request = httpx.Request("GET", "https://example.com") + result = await handler.apply(request, ctx) + assert result.headers["X-Custom-Key"] == "key123" diff --git a/pip-platform/tests/unit/test_entities.py b/pip-platform/tests/unit/test_entities.py new file mode 100644 index 0000000..7194513 --- /dev/null +++ b/pip-platform/tests/unit/test_entities.py @@ -0,0 +1,111 @@ +import pytest + +from src.domain.entities import ( + PharmacyEntity, + PharmacyStatus, + MedicationEntity, + ERPType, + SyncStrategy, + UserRole, + ConnectorEntity, + ConnectorStatus, + ReservationEntity, + ReservationStatus, + AuditLogEntity, +) +from uuid import uuid4 + + +class TestPharmacyEntity: + def test_create_pharmacy(self): + entity = PharmacyEntity( + code="PH-001", + name="Farmacia Central", + address="Calle Mayor 1", + city="Madrid", + status=PharmacyStatus.ACTIVE, + erp_type=ERPType.FARMATIC, + sync_strategy=SyncStrategy.INCREMENTAL, + ) + assert entity.code == "PH-001" + assert entity.name == "Farmacia Central" + assert entity.status == PharmacyStatus.ACTIVE + assert entity.erp_type == ERPType.FARMATIC + assert entity.sync_strategy == SyncStrategy.INCREMENTAL + + def test_pharmacy_default_values(self): + entity = PharmacyEntity(code="PH-002", name="Test") + assert entity.country == "ES" + assert entity.status == PharmacyStatus.PENDING + assert entity.sync_strategy == SyncStrategy.INCREMENTAL + assert entity.sync_interval_minutes == 30 + + def test_pharmacy_status_enum(self): + assert PharmacyStatus.ACTIVE.value == "active" + assert PharmacyStatus.INACTIVE.value == "inactive" + assert PharmacyStatus.SUSPENDED.value == "suspended" + assert PharmacyStatus.PENDING.value == "pending" + + +class TestMedicationEntity: + def test_create_medication(self): + entity = MedicationEntity( + nregistro="12345", + name="Ibuprofeno 400mg", + active_ingredient="Ibuprofeno", + dosage="400mg", + form="Comprimidos", + prescription_required=False, + ) + assert entity.nregistro == "12345" + assert entity.name == "Ibuprofeno 400mg" + assert entity.prescription_required is False + + +class TestConnectorEntity: + def test_create_connector(self): + entity = ConnectorEntity( + erp_system_id=uuid4(), + pharmacy_id=uuid4(), + name="Farmatic Connector", + connector_type=ERPType.FARMATIC, + ) + assert entity.connector_type == ERPType.FARMATIC + assert entity.version == "1.0.0" + assert entity.status == ConnectorStatus.PENDING + + +class TestReservationEntity: + def test_create_reservation(self): + entity = ReservationEntity( + pharmacy_id=uuid4(), + medication_id=uuid4(), + quantity=5, + idempotency_key="unique-key-123", + ) + assert entity.quantity == 5 + assert entity.status == ReservationStatus.PENDING + assert entity.idempotency_key == "unique-key-123" + + +class TestAuditLogEntity: + def test_create_audit_log(self): + entity = AuditLogEntity( + actor_id=uuid4(), + actor_type="technical_user", + action="pharmacy.create", + resource_type="pharmacy", + resource_id="PH-001", + ip_address="192.168.1.1", + ) + assert entity.action == "pharmacy.create" + assert entity.resource_type == "pharmacy" + + def test_enums(self): + assert ERPType.FARMATIC.value == "farmatic" + assert ERPType.NIXFARMA.value == "nixfarma" + assert ERPType.UNYCOP.value == "unycop" + assert ERPType.MOCK.value == "mock" + assert SyncStrategy.FULL.value == "full" + assert SyncStrategy.HYBRID.value == "hybrid" + assert UserRole.SUPERADMIN.value == "superadmin" diff --git a/pip-platform/tests/unit/test_exceptions.py b/pip-platform/tests/unit/test_exceptions.py new file mode 100644 index 0000000..532dddd --- /dev/null +++ b/pip-platform/tests/unit/test_exceptions.py @@ -0,0 +1,66 @@ +import pytest + +from src.core.exceptions import ( + PIPException, + NotFoundException, + ConflictException, + ValidationException, + UnauthorizedException, + ForbiddenException, + RateLimitException, + ServiceUnavailableException, + ConnectorException, + SyncException, +) + + +class TestPIPException: + def test_base_exception(self): + exc = PIPException("Something failed", code="TEST_ERROR", status_code=500) + assert exc.message == "Something failed" + assert exc.code == "TEST_ERROR" + assert exc.status_code == 500 + assert "timestamp" in exc.to_dict()["error"] + + def test_not_found(self): + exc = NotFoundException("Pharmacy", "PH-001") + assert exc.status_code == 404 + assert exc.code == "NOT_FOUND" + d = exc.to_dict() + assert "Pharmacy" in d["error"]["message"] + + def test_conflict(self): + exc = ConflictException("Duplicate") + assert exc.status_code == 409 + + def test_validation(self): + exc = ValidationException("Invalid input") + assert exc.status_code == 422 + + def test_unauthorized(self): + exc = UnauthorizedException() + assert exc.status_code == 401 + + def test_forbidden(self): + exc = ForbiddenException(required_role="admin") + assert exc.status_code == 403 + assert exc.details["required_role"] == "admin" + + def test_rate_limit(self): + exc = RateLimitException() + assert exc.status_code == 429 + + def test_service_unavailable(self): + exc = ServiceUnavailableException("redis") + assert exc.status_code == 503 + assert "redis" in exc.details["service"] + + def test_connector_error(self): + exc = ConnectorException("farmatic", "Connection refused") + assert exc.status_code == 502 + assert exc.details["connector_type"] == "farmatic" + + def test_sync_error(self): + exc = SyncException("pharmacy-1", "Timeout") + assert exc.status_code == 502 + assert exc.details["pharmacy_id"] == "pharmacy-1" diff --git a/pip-platform/tests/unit/test_mock_connector.py b/pip-platform/tests/unit/test_mock_connector.py new file mode 100644 index 0000000..1aa3e17 --- /dev/null +++ b/pip-platform/tests/unit/test_mock_connector.py @@ -0,0 +1,138 @@ +import asyncio +from uuid import UUID, uuid4 + +import pytest + +from src.connectors.mock.mock_connector import MockConnector +from src.connectors.base import ConnectorResult +from src.core.exceptions import ConnectorException + + +@pytest.fixture +def mock_connector() -> MockConnector: + return MockConnector() + + +@pytest.fixture +def pharmacy_id() -> UUID: + return uuid4() + + +@pytest.fixture +def medication_id() -> UUID: + return uuid4() + + +class TestMockConnectorConfiguration: + def test_default_configuration(self, mock_connector: MockConnector): + assert mock_connector._simulate_latency is True + assert mock_connector._error_rate == 0.0 + + def test_configure_latency(self, mock_connector: MockConnector): + mock_connector.configure({"simulate_latency": False, "latency_range_ms": [5, 10]}) + assert mock_connector._simulate_latency is False + assert mock_connector._latency_range_ms == (5, 10) + + def test_configure_error_rate(self, mock_connector: MockConnector): + mock_connector.configure({"error_rate": 0.5}) + assert mock_connector._error_rate == 0.5 + + +class TestMockConnectorGetStock: + @pytest.mark.asyncio + async def test_get_stock_all(self, mock_connector: MockConnector, pharmacy_id: UUID): + mock_connector.configure({"simulate_latency": False}) + result = await mock_connector.get_stock(pharmacy_id) + assert result.success is True + assert "items" in result.data + assert result.data["total"] == 10 + + @pytest.mark.asyncio + async def test_get_stock_by_nregistro(self, mock_connector: MockConnector, pharmacy_id: UUID): + mock_connector.configure({"simulate_latency": False}) + result = await mock_connector.get_stock(pharmacy_id, nregistro="00001") + assert result.success is True + assert result.data["nregistro"] == "00001" + assert result.data["name"] == "Ibuprofeno 600mg" + + @pytest.mark.asyncio + async def test_get_stock_not_found(self, mock_connector: MockConnector, pharmacy_id: UUID): + mock_connector.configure({"simulate_latency": False}) + result = await mock_connector.get_stock(pharmacy_id, nregistro="99999") + assert result.success is False + assert "not found" in result.error + + +class TestMockConnectorReserve: + @pytest.mark.asyncio + async def test_reserve(self, mock_connector: MockConnector, pharmacy_id: UUID, medication_id: UUID): + mock_connector.configure({"simulate_latency": False}) + result = await mock_connector.reserve(pharmacy_id, medication_id, 5) + assert result.success is True + assert result.data["status"] == "confirmed" + assert result.data["quantity"] == 5 + + @pytest.mark.asyncio + async def test_reserve_idempotency(self, mock_connector: MockConnector, pharmacy_id: UUID, medication_id: UUID): + mock_connector.configure({"simulate_latency": False}) + key = "idem-key-123" + result1 = await mock_connector.reserve(pharmacy_id, medication_id, 3, idempotency_key=key) + result2 = await mock_connector.reserve(pharmacy_id, medication_id, 3, idempotency_key=key) + assert result1.success is True + assert result2.success is True + + +class TestMockConnectorCancelReservation: + @pytest.mark.asyncio + async def test_cancel_reservation(self, mock_connector: MockConnector, pharmacy_id: UUID): + mock_connector.configure({"simulate_latency": False}) + reserve_result = await mock_connector.reserve(pharmacy_id, uuid4(), 2) + reservation_id = reserve_result.data["reservation_id"] + result = await mock_connector.cancel_reservation(pharmacy_id, reservation_id) + assert result.success is True + assert result.data["status"] == "cancelled" + + @pytest.mark.asyncio + async def test_cancel_nonexistent_reservation(self, mock_connector: MockConnector, pharmacy_id: UUID): + mock_connector.configure({"simulate_latency": False}) + result = await mock_connector.cancel_reservation(pharmacy_id, "nonexistent-id") + assert result.success is False + + +class TestMockConnectorPharmacyInfo: + @pytest.mark.asyncio + async def test_pharmacy_information(self, mock_connector: MockConnector, pharmacy_id: UUID): + mock_connector.configure({"simulate_latency": False}) + result = await mock_connector.pharmacy_information(pharmacy_id) + assert result.success is True + assert "FARM-001" in result.data["code"] + + +class TestMockConnectorSynchronize: + @pytest.mark.asyncio + async def test_synchronize(self, mock_connector: MockConnector, pharmacy_id: UUID): + mock_connector.configure({"simulate_latency": False, "error_rate": 0.0}) + result = await mock_connector.synchronize(pharmacy_id, strategy="full") + assert result.success is True + assert result.data["items_total"] == 10 + assert result.data["items_failed"] == 0 + + +class TestMockConnectorHeartbeat: + @pytest.mark.asyncio + async def test_heartbeat(self, mock_connector: MockConnector, pharmacy_id: UUID): + result = await mock_connector.heartbeat(pharmacy_id) + assert result.success is True + assert result.data["status"] == "healthy" + assert result.data["connector_type"] == "mock" + + +class TestMockConnectorErrorSimulation: + def test_high_error_rate_triggers_exception(self, mock_connector: MockConnector): + mock_connector.configure({"error_rate": 1.0}) + with pytest.raises(ConnectorException): + mock_connector._maybe_error() + + def test_zero_error_rate_no_exception(self, mock_connector: MockConnector): + mock_connector.configure({"error_rate": 0.0}) + mock_connector._maybe_error()