API client
This commit is contained in:
parent
97433ff68b
commit
b8bd410eeb
19
fastcheck_api/.env copy.example
Normal file
19
fastcheck_api/.env copy.example
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
MONGODB_URI=mongodb://localhost:27017/duxiter
|
||||
|
||||
VITE_JWT_SECRET=change-me
|
||||
VITE_JWT_EXPIRES_IN=7d
|
||||
|
||||
FASTCHECK_API_HOST=127.0.0.1
|
||||
FASTCHECK_API_PORT=8080
|
||||
FASTCHECK_LOG_LEVEL=INFO
|
||||
|
||||
LEGACY_BASE_URL=http://localhost:4040/api
|
||||
LEGACY_HTTP_TIMEOUT_SECONDS=30
|
||||
|
||||
RABBITMQ_ENABLED=true
|
||||
RABBITMQ_HOST=localhost
|
||||
RABBITMQ_PORT=5672
|
||||
RABBITMQ_VHOST=/
|
||||
RABBITMQ_USERNAME=guest
|
||||
RABBITMQ_PASSWORD=guest
|
||||
RABBITMQ_QUEUE=evaluation_queue
|
||||
20
fastcheck_api/.env.example
Normal file
20
fastcheck_api/.env.example
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
MONGODB_URI=mongodb://localhost:27017/duxiter
|
||||
|
||||
VITE_JWT_SECRET=change-me
|
||||
VITE_JWT_EXPIRES_IN=7d
|
||||
|
||||
FASTCHECK_API_HOST=127.0.0.1
|
||||
FASTCHECK_API_PORT=8080
|
||||
FASTCHECK_API_PREFIX=/api/v1
|
||||
FASTCHECK_LOG_LEVEL=INFO
|
||||
|
||||
LEGACY_BASE_URL=http://localhost:4040/api
|
||||
LEGACY_HTTP_TIMEOUT_SECONDS=30
|
||||
|
||||
RABBITMQ_ENABLED=true
|
||||
RABBITMQ_HOST=localhost
|
||||
RABBITMQ_PORT=5672
|
||||
RABBITMQ_VHOST=/
|
||||
RABBITMQ_USERNAME=guest
|
||||
RABBITMQ_PASSWORD=guest
|
||||
RABBITMQ_QUEUE=evaluation_queue
|
||||
40
fastcheck_api/README.md
Normal file
40
fastcheck_api/README.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# FastCheck API (FastAPI)
|
||||
|
||||
This is a parallel, client-facing API that reuses the existing MongoDB database and collections used by the Node.js backend.
|
||||
|
||||
## Documentation
|
||||
|
||||
- OpenAPI schema: `GET /openapi.json`
|
||||
- Customer docs (UI): `/docs`
|
||||
- Swagger UI (internal): `/internal/docs`
|
||||
- ReDoc (internal): `/internal/redoc`
|
||||
- Customer docs (repo): [docs/index.md](file:///home/duxiter/duxiter_intermediate/fastcheck_api/docs/index.md)
|
||||
|
||||
## Local run (no Docker)
|
||||
|
||||
1. Create and activate a virtual environment.
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
pip install -r fastcheck_api/requirements.txt
|
||||
```
|
||||
|
||||
3. Create an env file:
|
||||
|
||||
```bash
|
||||
cp fastcheck_api/.env.example fastcheck_api/.env
|
||||
```
|
||||
|
||||
4. Start the API:
|
||||
|
||||
```bash
|
||||
export $(cat fastcheck_api/.env | xargs)
|
||||
uvicorn fastcheck_api.app.main:app --host ${FASTCHECK_API_HOST:-127.0.0.1} --port ${FASTCHECK_API_PORT:-8080} --reload
|
||||
```
|
||||
|
||||
5. Start the worker (in a separate shell):
|
||||
|
||||
```bash
|
||||
export $(cat fastcheck_api/.env | xargs)
|
||||
python -m fastcheck_api.app.workers.check_worker
|
||||
```
|
||||
1
fastcheck_api/__init__.py
Normal file
1
fastcheck_api/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
1
fastcheck_api/app/__init__.py
Normal file
1
fastcheck_api/app/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
1
fastcheck_api/app/api/__init__.py
Normal file
1
fastcheck_api/app/api/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
191
fastcheck_api/app/api/dependencies.py
Normal file
191
fastcheck_api/app/api/dependencies.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Any
|
||||
|
||||
from bson import ObjectId
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
|
||||
from fastcheck_api.app.core.mongodb import get_db
|
||||
from fastcheck_api.app.core.security import AuthError, decode_token
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CurrentUser:
|
||||
id: str
|
||||
email: str
|
||||
role: str
|
||||
tenant: str
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
db: AsyncIOMotorDatabase = Depends(get_db),
|
||||
) -> CurrentUser:
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No token provided")
|
||||
|
||||
token = authorization.split(" ", 1)[1].strip()
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
except AuthError as e:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e)) from e
|
||||
|
||||
user_id = str(payload.get("id") or "")
|
||||
email = str(payload.get("email") or "")
|
||||
role = str(payload.get("role") or "")
|
||||
tenant = str(payload.get("tenant") or "")
|
||||
if not user_id or not email or not role or not tenant:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
|
||||
|
||||
try:
|
||||
user_oid = ObjectId(user_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid user id")
|
||||
|
||||
user_doc = await db["users"].find_one({"_id": user_oid}, projection={"isActive": 1, "tenant": 1})
|
||||
if not user_doc or not user_doc.get("isActive", False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"message": "Account not activated. Please wait for superadmin approval.", "code": "ACCOUNT_NOT_ACTIVATED"},
|
||||
)
|
||||
|
||||
stored_tenant = user_doc.get("tenant")
|
||||
stored_tenant_id = str(stored_tenant) if stored_tenant is not None else ""
|
||||
if stored_tenant_id and stored_tenant_id != tenant:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
|
||||
|
||||
if role != "superuser":
|
||||
try:
|
||||
tenant_oid = ObjectId(tenant)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid tenant id")
|
||||
|
||||
tenant_doc = await db["tenants"].find_one({"_id": tenant_oid}, projection={"isActive": 1})
|
||||
if not tenant_doc or not tenant_doc.get("isActive", False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"message": "Tenant not activated. Please wait for superadmin approval.", "code": "TENANT_NOT_ACTIVATED"},
|
||||
)
|
||||
|
||||
current = CurrentUser(id=user_id, email=email, role=role, tenant=tenant)
|
||||
request.state.current_user = current
|
||||
request.state.tenant_id = tenant
|
||||
return current
|
||||
|
||||
|
||||
Permission = str
|
||||
|
||||
|
||||
ROLE_PERMISSIONS: dict[str, set[Permission]] = {
|
||||
"superuser": {
|
||||
"user:create",
|
||||
"user:read",
|
||||
"user:update",
|
||||
"user:delete",
|
||||
"evaluation:create",
|
||||
"evaluation:read",
|
||||
"evaluation:update",
|
||||
"evaluation:delete",
|
||||
"evaluation:bulk",
|
||||
"company:read",
|
||||
"company:update",
|
||||
"rut:lookup",
|
||||
"rut:read",
|
||||
"sheriff:logs:read",
|
||||
"monitoring:create",
|
||||
"monitoring:read",
|
||||
"monitoring:update",
|
||||
"monitoring:delete",
|
||||
"monitoring:execute",
|
||||
"tenant:read",
|
||||
"tenant:update",
|
||||
"tenant:users:manage",
|
||||
"admin:dashboard",
|
||||
"admin:settings",
|
||||
"admin:logs",
|
||||
"notification:read",
|
||||
"notification:create",
|
||||
},
|
||||
"tenant_admin": {
|
||||
"user:create",
|
||||
"user:read",
|
||||
"user:update",
|
||||
"user:delete",
|
||||
"evaluation:create",
|
||||
"evaluation:read",
|
||||
"evaluation:update",
|
||||
"evaluation:delete",
|
||||
"evaluation:bulk",
|
||||
"company:read",
|
||||
"company:update",
|
||||
"rut:lookup",
|
||||
"rut:read",
|
||||
"sheriff:logs:read",
|
||||
"monitoring:create",
|
||||
"monitoring:read",
|
||||
"monitoring:update",
|
||||
"monitoring:delete",
|
||||
"monitoring:execute",
|
||||
"tenant:read",
|
||||
"tenant:update",
|
||||
"tenant:users:manage",
|
||||
"admin:dashboard",
|
||||
"notification:read",
|
||||
"notification:create",
|
||||
},
|
||||
"evaluator": {
|
||||
"user:read",
|
||||
"evaluation:create",
|
||||
"evaluation:read",
|
||||
"evaluation:update",
|
||||
"evaluation:delete",
|
||||
"evaluation:bulk",
|
||||
"company:read",
|
||||
"rut:lookup",
|
||||
"rut:read",
|
||||
"sheriff:logs:read",
|
||||
"monitoring:create",
|
||||
"monitoring:read",
|
||||
"monitoring:update",
|
||||
"monitoring:execute",
|
||||
"tenant:read",
|
||||
"notification:read",
|
||||
},
|
||||
"read_only": {
|
||||
"user:read",
|
||||
"evaluation:read",
|
||||
"company:read",
|
||||
"rut:read",
|
||||
"sheriff:logs:read",
|
||||
"monitoring:read",
|
||||
"tenant:read",
|
||||
"notification:read",
|
||||
},
|
||||
"write_only": {
|
||||
"evaluation:create",
|
||||
"evaluation:read",
|
||||
"company:read",
|
||||
"rut:lookup",
|
||||
"monitoring:create",
|
||||
"monitoring:execute",
|
||||
"notification:create",
|
||||
"tenant:read",
|
||||
"tenant:update",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def require_permission(permission: Permission):
|
||||
async def _dep(user: Annotated[CurrentUser, Depends(get_current_user)]) -> CurrentUser:
|
||||
perms = ROLE_PERMISSIONS.get(user.role, set())
|
||||
if permission not in perms:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"message": "Forbidden: Insufficient permissions", "required": permission, "userRole": user.role},
|
||||
)
|
||||
return user
|
||||
|
||||
return _dep
|
||||
1
fastcheck_api/app/api/v1/__init__.py
Normal file
1
fastcheck_api/app/api/v1/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
1
fastcheck_api/app/api/v1/routers/__init__.py
Normal file
1
fastcheck_api/app/api/v1/routers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
101
fastcheck_api/app/api/v1/routers/auth.py
Normal file
101
fastcheck_api/app/api/v1/routers/auth.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from bson import ObjectId
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
|
||||
from fastcheck_api.app.api.dependencies import CurrentUser, get_current_user
|
||||
from fastcheck_api.app.core.config import settings
|
||||
from fastcheck_api.app.core.mongodb import get_db
|
||||
from fastcheck_api.app.core.security import create_access_token
|
||||
from fastcheck_api.app.schemas.auth import LoginRequest, LoginResponse, UserOut
|
||||
from fastcheck_api.app.services.auth_service import AuthService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
API_PREFIX = settings.normalized_api_prefix or "/api/v1"
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=LoginResponse,
|
||||
summary="Login",
|
||||
description="Authenticate with email and password and receive a bearer JWT token compatible with the existing Node.js backend.",
|
||||
openapi_extra={
|
||||
"x-code-samples": [
|
||||
{
|
||||
"lang": "curl",
|
||||
"label": "cURL",
|
||||
"source": f"curl -X POST 'http://127.0.0.1:8181{API_PREFIX}/auth/login' \\\n -H 'Content-Type: application/json' \\\n -d '{{\"email\":\"user@example.com\",\"password\":\"********\"}}'",
|
||||
},
|
||||
{
|
||||
"lang": "python",
|
||||
"label": "httpx",
|
||||
"source": f"import httpx\nresp = httpx.post('http://127.0.0.1:8181{API_PREFIX}/auth/login', json={{\n 'email': 'user@example.com',\n 'password': '********'\n}})\nprint(resp.json())",
|
||||
},
|
||||
{
|
||||
"lang": "js",
|
||||
"label": "fetch",
|
||||
"source": f"const resp = await fetch('http://127.0.0.1:8181{API_PREFIX}/auth/login', {{\n method: 'POST',\n headers: {{ 'Content-Type': 'application/json' }},\n body: JSON.stringify({{ email: 'user@example.com', password: '********' }})\n}});\nconsole.log(await resp.json());",
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
async def login(payload: LoginRequest, db: Annotated[AsyncIOMotorDatabase, Depends(get_db)]):
|
||||
try:
|
||||
res = await AuthService.login(db=db, email=payload.email, password=payload.password)
|
||||
return res
|
||||
except PermissionError as e:
|
||||
if str(e) == "ACCOUNT_NOT_ACTIVATED":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={
|
||||
"message": "Tu cuenta está pendiente de activación por parte del superadministrador. Contacta al administrador para completar la activación.",
|
||||
"activationStatus": "pending",
|
||||
},
|
||||
)
|
||||
raise
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/me",
|
||||
response_model=UserOut,
|
||||
summary="Get current user",
|
||||
description="Return the authenticated user's profile from the existing `users` collection.",
|
||||
)
|
||||
async def me(
|
||||
user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
db: Annotated[AsyncIOMotorDatabase, Depends(get_db)],
|
||||
):
|
||||
doc = await db["users"].find_one({"_id": ObjectId(user.id)}, projection={"name": 1, "email": 1, "role": 1})
|
||||
if not doc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
return {"id": user.id, "name": doc.get("name") or "", "email": doc.get("email") or user.email, "role": doc.get("role") or user.role}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/refresh",
|
||||
summary="Refresh token",
|
||||
description="Issue a new JWT token using the current token's identity and tenant context.",
|
||||
openapi_extra={
|
||||
"x-code-samples": [
|
||||
{
|
||||
"lang": "curl",
|
||||
"label": "cURL",
|
||||
"source": f"curl -X POST 'http://127.0.0.1:8181{API_PREFIX}/auth/refresh' \\\n -H 'Authorization: Bearer <jwt>'",
|
||||
},
|
||||
{
|
||||
"lang": "python",
|
||||
"label": "httpx",
|
||||
"source": f"import httpx\nresp = httpx.post('http://127.0.0.1:8181{API_PREFIX}/auth/refresh', headers={{\n 'Authorization': 'Bearer <jwt>'\n}})\nprint(resp.json())",
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
async def refresh(user: Annotated[CurrentUser, Depends(get_current_user)]):
|
||||
token = create_access_token(user_id=user.id, email=user.email, role=user.role, tenant_id=user.tenant)
|
||||
return {"token": token}
|
||||
169
fastcheck_api/app/api/v1/routers/checks.py
Normal file
169
fastcheck_api/app/api/v1/routers/checks.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
|
||||
from fastcheck_api.app.api.dependencies import CurrentUser, require_permission
|
||||
from fastcheck_api.app.core.config import settings
|
||||
from fastcheck_api.app.core.mongodb import get_db
|
||||
from fastcheck_api.app.schemas.checks import (
|
||||
CreateCheckRequest,
|
||||
EvaluationJobOut,
|
||||
ListJobsResponse,
|
||||
ListResultsResponse,
|
||||
)
|
||||
from fastcheck_api.app.services.audit_service import AuditService
|
||||
from fastcheck_api.app.services.check_service import CheckService, InsufficientCreditsError
|
||||
|
||||
|
||||
router = APIRouter(prefix="/checks", tags=["checks"])
|
||||
API_PREFIX = settings.normalized_api_prefix or "/api/v1"
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=ListJobsResponse,
|
||||
summary="List checks",
|
||||
description="List evaluation jobs (checks) for the current tenant.",
|
||||
)
|
||||
async def list_checks(
|
||||
user: Annotated[CurrentUser, Depends(require_permission("evaluation:read"))],
|
||||
db: Annotated[AsyncIOMotorDatabase, Depends(get_db)],
|
||||
page: int = Query(default=1, ge=1),
|
||||
limit: int = Query(default=10, ge=1, le=100),
|
||||
):
|
||||
return await CheckService.list_jobs(db=db, tenant_id=user.tenant, page=page, limit=limit)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=EvaluationJobOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a check",
|
||||
description="Create a single evaluation job and enqueue processing in RabbitMQ using the existing queue and message format.",
|
||||
openapi_extra={
|
||||
"x-code-samples": [
|
||||
{
|
||||
"lang": "curl",
|
||||
"label": "cURL",
|
||||
"source": f"curl -X POST 'http://127.0.0.1:8181{API_PREFIX}/checks' \\\n -H 'Authorization: Bearer <jwt>' \\\n -H 'Content-Type: application/json' \\\n -d '{{\"supplier\":{{\"rut\":\"12345678-9\",\"name\":\"ACME SpA\"}}}}'",
|
||||
},
|
||||
{
|
||||
"lang": "python",
|
||||
"label": "httpx",
|
||||
"source": f"import httpx\nresp = httpx.post('http://127.0.0.1:8181{API_PREFIX}/checks',\n headers={{'Authorization': 'Bearer <jwt>'}},\n json={{'supplier': {{'rut': '12345678-9', 'name': 'ACME SpA'}}}})\nprint(resp.json())",
|
||||
},
|
||||
{
|
||||
"lang": "js",
|
||||
"label": "fetch",
|
||||
"source": f"const resp = await fetch('http://127.0.0.1:8181{API_PREFIX}/checks', {{\n method: 'POST',\n headers: {{\n 'Authorization': 'Bearer <jwt>',\n 'Content-Type': 'application/json'\n }},\n body: JSON.stringify({{ supplier: {{ rut: '12345678-9', name: 'ACME SpA' }} }})\n}});\nconsole.log(await resp.json());",
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
async def create_check(
|
||||
request: Request,
|
||||
payload: CreateCheckRequest,
|
||||
user: Annotated[CurrentUser, Depends(require_permission("evaluation:create"))],
|
||||
db: Annotated[AsyncIOMotorDatabase, Depends(get_db)],
|
||||
):
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
job = await CheckService.create_single_check(
|
||||
db=db,
|
||||
tenant_id=user.tenant,
|
||||
user_id=user.id,
|
||||
rut=payload.supplier.rut,
|
||||
name=payload.supplier.name,
|
||||
)
|
||||
await AuditService.log_consulta(
|
||||
db=db,
|
||||
tenant_id=user.tenant,
|
||||
user_id=user.id,
|
||||
consulta_type="individual",
|
||||
rut=payload.supplier.rut,
|
||||
endpoint="/api/v1/checks",
|
||||
response_status=status.HTTP_201_CREATED,
|
||||
request_data={"supplier": {"rut": payload.supplier.rut, "name": payload.supplier.name}},
|
||||
processing_time_ms=int((time.perf_counter() - start) * 1000),
|
||||
credits_used=1,
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
metadata={"evaluationType": "single"},
|
||||
)
|
||||
return job
|
||||
except InsufficientCreditsError:
|
||||
await AuditService.log_consulta(
|
||||
db=db,
|
||||
tenant_id=user.tenant,
|
||||
user_id=user.id,
|
||||
consulta_type="individual",
|
||||
rut=payload.supplier.rut,
|
||||
endpoint="/api/v1/checks",
|
||||
response_status=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
request_data={"supplier": {"rut": payload.supplier.rut, "name": payload.supplier.name}},
|
||||
processing_time_ms=int((time.perf_counter() - start) * 1000),
|
||||
credits_used=0,
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
metadata={"reason": "insufficient_credits"},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail="Insufficient credits",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{job_id}",
|
||||
response_model=EvaluationJobOut,
|
||||
summary="Get a check",
|
||||
description="Fetch a single evaluation job by id (tenant-scoped).",
|
||||
)
|
||||
async def get_check(
|
||||
job_id: str,
|
||||
user: Annotated[CurrentUser, Depends(require_permission("evaluation:read"))],
|
||||
db: Annotated[AsyncIOMotorDatabase, Depends(get_db)],
|
||||
):
|
||||
job = await CheckService.get_job(db=db, tenant_id=user.tenant, job_id=job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
|
||||
return job
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{job_id}/results",
|
||||
response_model=ListResultsResponse,
|
||||
summary="List check results",
|
||||
description="List evaluation results for a given job id (tenant-scoped).",
|
||||
openapi_extra={
|
||||
"x-code-samples": [
|
||||
{
|
||||
"lang": "curl",
|
||||
"label": "cURL",
|
||||
"source": f"curl 'http://127.0.0.1:8181{API_PREFIX}/checks/<job_id>/results?page=1&limit=10' \\\n -H 'Authorization: Bearer <jwt>'",
|
||||
},
|
||||
{
|
||||
"lang": "python",
|
||||
"label": "httpx",
|
||||
"source": f"import httpx\nresp = httpx.get('http://127.0.0.1:8181{API_PREFIX}/checks/<job_id>/results',\n params={{'page': 1, 'limit': 10}},\n headers={{'Authorization': 'Bearer <jwt>'}})\nprint(resp.json())",
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
async def get_check_results(
|
||||
job_id: str,
|
||||
user: Annotated[CurrentUser, Depends(require_permission("evaluation:read"))],
|
||||
db: Annotated[AsyncIOMotorDatabase, Depends(get_db)],
|
||||
page: int = Query(default=1, ge=1),
|
||||
limit: int = Query(default=10, ge=1, le=100),
|
||||
):
|
||||
try:
|
||||
return await CheckService.list_results(db=db, tenant_id=user.tenant, job_id=job_id, page=page, limit=limit)
|
||||
except LookupError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
37
fastcheck_api/app/api/v1/routers/reports.py
Normal file
37
fastcheck_api/app/api/v1/routers/reports.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
|
||||
from fastcheck_api.app.api.dependencies import CurrentUser, require_permission
|
||||
from fastcheck_api.app.core.mongodb import get_db
|
||||
from fastcheck_api.app.schemas.reports import FastCheckReportOut
|
||||
from fastcheck_api.app.utils.mongo import jsonable
|
||||
|
||||
|
||||
router = APIRouter(prefix="/reports", tags=["reports"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/fast-check/{rut}",
|
||||
response_model=FastCheckReportOut,
|
||||
summary="Get FastCheck report",
|
||||
description="Fetch the latest saved FastCheck summary for a RUT from the existing `summaries` collection (tenant-scoped).",
|
||||
)
|
||||
async def get_fastcheck_report(
|
||||
rut: str,
|
||||
user: Annotated[CurrentUser, Depends(require_permission("evaluation:read"))],
|
||||
db: Annotated[AsyncIOMotorDatabase, Depends(get_db)],
|
||||
):
|
||||
doc = (
|
||||
await db["summaries"]
|
||||
.find({"rut": rut, "tenantId": user.tenant, "summaryType": "fast-check"})
|
||||
.sort([("createdAt", -1)])
|
||||
.limit(1)
|
||||
.to_list(length=1)
|
||||
)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Report not found")
|
||||
return jsonable(doc[0])
|
||||
59
fastcheck_api/app/api/v1/routers/usage.py
Normal file
59
fastcheck_api/app/api/v1/routers/usage.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from bson import ObjectId
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
|
||||
from fastcheck_api.app.api.dependencies import CurrentUser, require_permission
|
||||
from fastcheck_api.app.core.mongodb import get_db
|
||||
from fastcheck_api.app.schemas.usage import ListCreditOperationsResponse, TenantUsageOut
|
||||
from fastcheck_api.app.utils.mongo import jsonable
|
||||
|
||||
|
||||
router = APIRouter(prefix="/usage", tags=["usage"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=TenantUsageOut,
|
||||
summary="Get tenant usage",
|
||||
description="Return tenant credit balance from the existing `tenants.creditBalance` structure.",
|
||||
)
|
||||
async def get_usage(
|
||||
user: Annotated[CurrentUser, Depends(require_permission("tenant:read"))],
|
||||
db: Annotated[AsyncIOMotorDatabase, Depends(get_db)],
|
||||
):
|
||||
tenant = await db["tenants"].find_one({"_id": ObjectId(user.tenant)}, projection={"creditBalance": 1})
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
|
||||
credit_balance = tenant.get("creditBalance") or {}
|
||||
return {"tenantId": user.tenant, "creditBalance": jsonable(credit_balance)}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/operations",
|
||||
response_model=ListCreditOperationsResponse,
|
||||
summary="List credit operations",
|
||||
description="List credit operations for the current tenant from the existing `creditoperations` ledger.",
|
||||
)
|
||||
async def list_operations(
|
||||
user: Annotated[CurrentUser, Depends(require_permission("tenant:read"))],
|
||||
db: Annotated[AsyncIOMotorDatabase, Depends(get_db)],
|
||||
page: int = Query(default=1, ge=1),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
):
|
||||
skip = (page - 1) * limit
|
||||
tenant_oid = ObjectId(user.tenant)
|
||||
cursor = (
|
||||
db["creditoperations"]
|
||||
.find({"tenantId": tenant_oid})
|
||||
.sort([("createdAt", -1)])
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
operations = [jsonable(doc) async for doc in cursor]
|
||||
total = await db["creditoperations"].count_documents({"tenantId": tenant_oid})
|
||||
pages = (total + limit - 1) // limit
|
||||
return {"operations": operations, "total": total, "pages": pages}
|
||||
1
fastcheck_api/app/core/__init__.py
Normal file
1
fastcheck_api/app/core/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
75
fastcheck_api/app/core/config.py
Normal file
75
fastcheck_api/app/core/config.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
ENV_FILE = Path(__file__).resolve().parents[2] / ".env"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=ENV_FILE, env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
mongodb_uri: str = Field(default="mongodb://localhost:27017/duxiter", validation_alias="MONGODB_URI")
|
||||
|
||||
jwt_secret: str = Field(default="your-secret-key", validation_alias="VITE_JWT_SECRET")
|
||||
jwt_expires_in: str = Field(default="7d", validation_alias="VITE_JWT_EXPIRES_IN")
|
||||
|
||||
api_host: str = Field(default="127.0.0.1", validation_alias="FASTCHECK_API_HOST")
|
||||
api_port: int = Field(default=8080, validation_alias="FASTCHECK_API_PORT")
|
||||
api_prefix: str = Field(default="/api/v1", validation_alias="FASTCHECK_API_PREFIX")
|
||||
log_level: str = Field(default="INFO", validation_alias="FASTCHECK_LOG_LEVEL")
|
||||
|
||||
legacy_base_url: str = Field(default="http://localhost:4040/api", validation_alias="LEGACY_BASE_URL")
|
||||
legacy_http_timeout_seconds: int = Field(default=30, validation_alias="LEGACY_HTTP_TIMEOUT_SECONDS")
|
||||
|
||||
rabbitmq_enabled: bool = Field(default=True, validation_alias="RABBITMQ_ENABLED")
|
||||
rabbitmq_queue: str = Field(default="evaluation_queue", validation_alias="RABBITMQ_QUEUE")
|
||||
rabbitmq_url: str | None = Field(default=None, validation_alias="RABBITMQ_URL")
|
||||
rabbitmq_protocol: str = Field(default="amqp", validation_alias="RABBITMQ_PROTOCOL")
|
||||
rabbitmq_username: str = Field(default="guest", validation_alias="RABBITMQ_USERNAME")
|
||||
rabbitmq_password: str = Field(default="guest", validation_alias="RABBITMQ_PASSWORD")
|
||||
rabbitmq_host: str = Field(default="localhost", validation_alias="RABBITMQ_HOST")
|
||||
rabbitmq_port: int = Field(default=5672, validation_alias="RABBITMQ_PORT")
|
||||
rabbitmq_vhost: str = Field(default="/", validation_alias="RABBITMQ_VHOST")
|
||||
|
||||
@property
|
||||
def mongo_db_name(self) -> str:
|
||||
uri = self.mongodb_uri
|
||||
after_slash = uri.rsplit("/", 1)[-1]
|
||||
if "?" in after_slash:
|
||||
return after_slash.split("?", 1)[0]
|
||||
if after_slash:
|
||||
return after_slash
|
||||
return "duxiter"
|
||||
|
||||
@property
|
||||
def rabbitmq_connection_url(self) -> str:
|
||||
if self.rabbitmq_url:
|
||||
return self.rabbitmq_url
|
||||
vhost = self.rabbitmq_vhost
|
||||
if vhost.startswith("/"):
|
||||
vhost = vhost[1:]
|
||||
return (
|
||||
f"{self.rabbitmq_protocol}://{self.rabbitmq_username}:{self.rabbitmq_password}"
|
||||
f"@{self.rabbitmq_host}:{self.rabbitmq_port}/{vhost}"
|
||||
)
|
||||
|
||||
@property
|
||||
def normalized_api_prefix(self) -> str:
|
||||
prefix = (self.api_prefix or "").strip()
|
||||
if not prefix:
|
||||
return ""
|
||||
if not prefix.startswith("/"):
|
||||
prefix = f"/{prefix}"
|
||||
if len(prefix) > 1 and prefix.endswith("/"):
|
||||
prefix = prefix[:-1]
|
||||
return prefix
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
25
fastcheck_api/app/core/logging.py
Normal file
25
fastcheck_api/app/core/logging.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from fastcheck_api.app.core.config import settings
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
root = logging.getLogger()
|
||||
if root.handlers:
|
||||
return
|
||||
|
||||
level = getattr(logging, settings.log_level.upper(), logging.INFO)
|
||||
root.setLevel(level)
|
||||
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setLevel(level)
|
||||
handler.setFormatter(
|
||||
logging.Formatter(
|
||||
fmt="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
datefmt="%Y-%m-%dT%H:%M:%S%z",
|
||||
)
|
||||
)
|
||||
root.addHandler(handler)
|
||||
28
fastcheck_api/app/core/mongodb.py
Normal file
28
fastcheck_api/app/core/mongodb.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
|
||||
|
||||
from fastcheck_api.app.core.config import settings
|
||||
|
||||
_client: AsyncIOMotorClient | None = None
|
||||
|
||||
|
||||
def get_client() -> AsyncIOMotorClient:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = AsyncIOMotorClient(settings.mongodb_uri)
|
||||
return _client
|
||||
|
||||
|
||||
def get_db() -> AsyncIOMotorDatabase:
|
||||
return get_client()[settings.mongo_db_name]
|
||||
|
||||
|
||||
async def lifespan_db() -> AsyncIterator[None]:
|
||||
client = get_client()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
client.close()
|
||||
65
fastcheck_api/app/core/security.py
Normal file
65
fastcheck_api/app/core/security.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
|
||||
from fastcheck_api.app.core.config import settings
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
_DURATION_RE = re.compile(r"^(?P<count>\d+)(?P<unit>[smhd])$")
|
||||
|
||||
|
||||
def _parse_expires_in(value: str) -> dt.timedelta:
|
||||
match = _DURATION_RE.match(value.strip())
|
||||
if not match:
|
||||
return dt.timedelta(days=7)
|
||||
count = int(match.group("count"))
|
||||
unit = match.group("unit")
|
||||
if unit == "s":
|
||||
return dt.timedelta(seconds=count)
|
||||
if unit == "m":
|
||||
return dt.timedelta(minutes=count)
|
||||
if unit == "h":
|
||||
return dt.timedelta(hours=count)
|
||||
return dt.timedelta(days=count)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def create_access_token(*, user_id: str, email: str, role: str, tenant_id: str) -> str:
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
exp = now + _parse_expires_in(settings.jwt_expires_in)
|
||||
payload = {
|
||||
"id": user_id,
|
||||
"email": email,
|
||||
"role": role,
|
||||
"tenant": tenant_id,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int(exp.timestamp()),
|
||||
}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any]:
|
||||
try:
|
||||
decoded = jwt.decode(token, settings.jwt_secret, algorithms=["HS256"])
|
||||
if not isinstance(decoded, dict):
|
||||
raise AuthError("Invalid token payload")
|
||||
return decoded
|
||||
except jwt.ExpiredSignatureError as e:
|
||||
raise AuthError("Token expired") from e
|
||||
except jwt.InvalidTokenError as e:
|
||||
raise AuthError("Invalid token") from e
|
||||
134
fastcheck_api/app/main.py
Normal file
134
fastcheck_api/app/main.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from fastcheck_api.app.api.v1.routers import auth, checks, reports, usage
|
||||
from fastcheck_api.app.core.logging import configure_logging
|
||||
from fastcheck_api.app.core.mongodb import lifespan_db
|
||||
from fastcheck_api.app.services.legacy_service import legacy_service
|
||||
from fastcheck_api.app.services.rabbitmq_service import rabbitmq_service
|
||||
from fastcheck_api.app.middleware.request_logging import RequestLoggingMiddleware
|
||||
from fastcheck_api.app.middleware.tenant_context import TenantContextMiddleware
|
||||
from fastcheck_api.app.core.config import settings
|
||||
|
||||
|
||||
logger = logging.getLogger("fastcheck")
|
||||
|
||||
tags_metadata = [
|
||||
{"name": "auth", "description": "Authentication and session management."},
|
||||
{"name": "checks", "description": "Create checks (evaluations) and retrieve job/results."},
|
||||
{"name": "reports", "description": "Fetch saved reports from existing persistence."},
|
||||
{"name": "usage", "description": "Usage and credit balance information."},
|
||||
]
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
configure_logging()
|
||||
async for _ in lifespan_db():
|
||||
if settings.rabbitmq_enabled:
|
||||
try:
|
||||
await rabbitmq_service.connect()
|
||||
except Exception:
|
||||
logger.exception("RabbitMQ connection failed; continuing without async processing")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
await rabbitmq_service.close()
|
||||
except Exception:
|
||||
logger.exception("RabbitMQ shutdown failed")
|
||||
await legacy_service.close()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="FastCheck API",
|
||||
version="0.1.0",
|
||||
description="FastCheck API for check evaluation and report retrieval.",
|
||||
openapi_url="/openapi.json",
|
||||
docs_url="/internal/docs",
|
||||
redoc_url="/internal/redoc",
|
||||
openapi_tags=tags_metadata,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.add_middleware(RequestLoggingMiddleware)
|
||||
app.add_middleware(TenantContextMiddleware)
|
||||
|
||||
|
||||
@app.get(
|
||||
"/",
|
||||
summary="API entrypoint",
|
||||
description="Convenience endpoint with links to the OpenAPI schema and internal Swagger/ReDoc.",
|
||||
)
|
||||
async def root():
|
||||
return {
|
||||
"openapi": "/openapi.json",
|
||||
"docs": "/docs",
|
||||
"swagger_ui": "/internal/docs",
|
||||
"redoc": "/internal/redoc",
|
||||
"health": "/health",
|
||||
}
|
||||
|
||||
|
||||
@app.get(
|
||||
"/docs",
|
||||
summary="Customer API docs",
|
||||
description="Customer-facing API reference with generated code examples. The OpenAPI schema remains the source of truth.",
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def customer_docs():
|
||||
return HTMLResponse(
|
||||
"""
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FastCheck API Docs</title>
|
||||
<style>
|
||||
html, body { height: 100%; margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<rapi-doc
|
||||
spec-url="/openapi.json"
|
||||
render-style="read"
|
||||
show-header="false"
|
||||
allow-try="true"
|
||||
allow-authentication="true"
|
||||
allow-server-selection="true"
|
||||
show-components="true"
|
||||
show-info="true"
|
||||
show-side-nav="true"
|
||||
theme="dark"
|
||||
primary-color="#4f46e5"
|
||||
></rapi-doc>
|
||||
<script src="https://unpkg.com/rapidoc/dist/rapidoc-min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
""".strip()
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception):
|
||||
logger.exception("Unhandled error", extra={"path": request.url.path})
|
||||
return JSONResponse(status_code=500, content={"status": "error", "message": "Something went wrong!"})
|
||||
|
||||
|
||||
api_v1 = APIRouter(prefix=settings.normalized_api_prefix)
|
||||
api_v1.include_router(auth.router)
|
||||
api_v1.include_router(checks.router)
|
||||
api_v1.include_router(reports.router)
|
||||
api_v1.include_router(usage.router)
|
||||
app.include_router(api_v1)
|
||||
1
fastcheck_api/app/middleware/__init__.py
Normal file
1
fastcheck_api/app/middleware/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
37
fastcheck_api/app/middleware/request_logging.py
Normal file
37
fastcheck_api/app/middleware/request_logging.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import Response
|
||||
|
||||
|
||||
logger = logging.getLogger("fastcheck.request")
|
||||
|
||||
|
||||
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
request_id = request.headers.get("x-request-id") or str(uuid.uuid4())
|
||||
request.state.request_id = request_id
|
||||
start = time.perf_counter()
|
||||
status_code = None
|
||||
try:
|
||||
response: Response = await call_next(request)
|
||||
status_code = response.status_code
|
||||
finally:
|
||||
duration_ms = int((time.perf_counter() - start) * 1000)
|
||||
logger.info(
|
||||
"request",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"status_code": status_code,
|
||||
"duration_ms": duration_ms,
|
||||
},
|
||||
)
|
||||
response.headers["x-request-id"] = request_id
|
||||
return response
|
||||
11
fastcheck_api/app/middleware/tenant_context.py
Normal file
11
fastcheck_api/app/middleware/tenant_context.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
|
||||
class TenantContextMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if not hasattr(request.state, "tenant_id"):
|
||||
request.state.tenant_id = None
|
||||
return await call_next(request)
|
||||
1
fastcheck_api/app/schemas/__init__.py
Normal file
1
fastcheck_api/app/schemas/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
23
fastcheck_api/app/schemas/auth.py
Normal file
23
fastcheck_api/app/schemas/auth.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr = Field(description="User email address.", examples=["user@example.com"])
|
||||
password: str = Field(description="User password.", examples=["********"])
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: str = Field(description="User id (MongoDB ObjectId as string).", examples=["507f1f77bcf86cd799439011"])
|
||||
name: str = Field(description="User display name.", examples=["Jane Doe"])
|
||||
email: EmailStr = Field(description="User email address.", examples=["user@example.com"])
|
||||
role: str = Field(
|
||||
description="User role (source of truth for access control).",
|
||||
examples=["tenant_admin"],
|
||||
)
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
token: str = Field(description="Bearer JWT token.", examples=["<jwt>"])
|
||||
user: UserOut
|
||||
57
fastcheck_api/app/schemas/checks.py
Normal file
57
fastcheck_api/app/schemas/checks.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SupplierInput(BaseModel):
|
||||
rut: str = Field(
|
||||
description="Chilean RUT in canonical format (recommended: no dots, with dash).",
|
||||
examples=["12345678-9"],
|
||||
)
|
||||
name: str | None = Field(default=None, description="Optional supplier/company name.", examples=["ACME SpA"])
|
||||
|
||||
|
||||
class CreateCheckRequest(BaseModel):
|
||||
supplier: SupplierInput = Field(description="Supplier to evaluate.")
|
||||
|
||||
|
||||
class EvaluationJobOut(BaseModel):
|
||||
id: str = Field(alias="_id", description="Evaluation job id (MongoDB ObjectId as string).", examples=["507f1f77bcf86cd799439011"])
|
||||
tenantId: str = Field(description="Tenant id (string stored in jobs/results collections).")
|
||||
status: str = Field(description="Job status.", examples=["processing"])
|
||||
type: str = Field(description="Job type.", examples=["single"])
|
||||
createdBy: str = Field(description="User id that created the job (string).")
|
||||
totalEvaluations: int = Field(description="Total evaluations tracked by this job.", examples=[1])
|
||||
completedEvaluations: int = Field(description="Number of successful results.", examples=[0])
|
||||
failedEvaluations: int = Field(description="Number of failed results.", examples=[0])
|
||||
createdAt: datetime | None = None
|
||||
updatedAt: datetime | None = None
|
||||
supplierData: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ListJobsResponse(BaseModel):
|
||||
jobs: list[EvaluationJobOut]
|
||||
total: int = Field(description="Total jobs in this tenant.", examples=[42])
|
||||
pages: int = Field(description="Total pages for the current limit.", examples=[5])
|
||||
|
||||
|
||||
class EvaluationResultOut(BaseModel):
|
||||
id: str = Field(alias="_id", description="Evaluation result id (MongoDB ObjectId as string).")
|
||||
jobId: str = Field(description="Evaluation job id (as string or ObjectId string).")
|
||||
tenantId: str = Field(description="Tenant id (string stored in jobs/results collections).")
|
||||
rut: str = Field(description="Supplier RUT.", examples=["12345678-9"])
|
||||
name: str | None = Field(default=None, description="Optional supplier name.", examples=["ACME SpA"])
|
||||
status: str = Field(description="Result status.", examples=["success"])
|
||||
error: str | None = Field(default=None, description="Error message if failed.")
|
||||
data: dict[str, Any] | None = None
|
||||
createdAt: datetime | None = None
|
||||
updatedAt: datetime | None = None
|
||||
|
||||
|
||||
class ListResultsResponse(BaseModel):
|
||||
results: list[EvaluationResultOut]
|
||||
total: int = Field(description="Total results in this job.", examples=[1])
|
||||
pages: int = Field(description="Total pages for the current limit.", examples=[1])
|
||||
8
fastcheck_api/app/schemas/common.py
Normal file
8
fastcheck_api/app/schemas/common.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Page(BaseModel):
|
||||
page: int = Field(default=1, ge=1)
|
||||
limit: int = Field(default=10, ge=1, le=100)
|
||||
17
fastcheck_api/app/schemas/reports.py
Normal file
17
fastcheck_api/app/schemas/reports.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class FastCheckReportOut(BaseModel):
|
||||
id: str = Field(alias="_id", description="Summary id (MongoDB ObjectId as string).")
|
||||
rut: str = Field(description="Supplier RUT.", examples=["12345678-9"])
|
||||
tenantId: str = Field(description="Tenant id (string stored in summaries).")
|
||||
summaryType: str = Field(description="Summary type.", examples=["fast-check"])
|
||||
content: str = Field(description="Rendered fast-check summary content (Markdown or plain text).")
|
||||
metadata: dict[str, Any] | None = None
|
||||
createdAt: datetime | None = None
|
||||
updatedAt: datetime | None = None
|
||||
35
fastcheck_api/app/schemas/usage.py
Normal file
35
fastcheck_api/app/schemas/usage.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreditBalanceOut(BaseModel):
|
||||
availableCredits: int = Field(description="Remaining credits for the tenant.", examples=[99])
|
||||
totalCreditsUsed: int = Field(description="Total credits used by the tenant.", examples=[1])
|
||||
lastCreditOperation: datetime | None = None
|
||||
|
||||
|
||||
class TenantUsageOut(BaseModel):
|
||||
tenantId: str = Field(description="Tenant id (MongoDB ObjectId as string).", examples=["507f1f77bcf86cd799439012"])
|
||||
creditBalance: CreditBalanceOut
|
||||
|
||||
|
||||
class CreditOperationOut(BaseModel):
|
||||
id: str = Field(alias="_id", description="Credit operation id (MongoDB ObjectId as string).")
|
||||
tenantId: str = Field(description="Tenant id (MongoDB ObjectId as string).")
|
||||
userId: str | None = Field(default=None, description="User id (MongoDB ObjectId as string).")
|
||||
operationType: str = Field(description="Operation type.", examples=["evaluation"])
|
||||
creditsChanged: int = Field(description="Positive for additions, negative for deductions.", examples=[-1])
|
||||
balanceAfter: int = Field(description="Tenant balance after this operation.", examples=[99])
|
||||
description: str | None = Field(default=None, description="Human-readable description.")
|
||||
metadata: dict[str, Any] | None = None
|
||||
createdAt: datetime | None = None
|
||||
|
||||
|
||||
class ListCreditOperationsResponse(BaseModel):
|
||||
operations: list[CreditOperationOut]
|
||||
total: int = Field(description="Total operations for this tenant.", examples=[123])
|
||||
pages: int = Field(description="Total pages for the current limit.", examples=[7])
|
||||
1
fastcheck_api/app/services/__init__.py
Normal file
1
fastcheck_api/app/services/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
64
fastcheck_api/app/services/audit_service.py
Normal file
64
fastcheck_api/app/services/audit_service.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from typing import Any
|
||||
|
||||
from bson import ObjectId
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
|
||||
|
||||
class AuditService:
|
||||
@staticmethod
|
||||
async def log_consulta(
|
||||
*,
|
||||
db: AsyncIOMotorDatabase,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
consulta_type: str,
|
||||
endpoint: str,
|
||||
response_status: int,
|
||||
rut: str | None = None,
|
||||
ruts: list[str] | None = None,
|
||||
request_data: dict[str, Any] | None = None,
|
||||
response_data: dict[str, Any] | None = None,
|
||||
error_message: str | None = None,
|
||||
processing_time_ms: int | None = None,
|
||||
credits_used: int | None = None,
|
||||
ip_address: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
doc: dict[str, Any] = {
|
||||
"tenantId": tenant_id,
|
||||
"consultaType": consulta_type,
|
||||
"endpoint": endpoint,
|
||||
"responseStatus": response_status,
|
||||
"createdAt": dt.datetime.now(dt.timezone.utc),
|
||||
"updatedAt": dt.datetime.now(dt.timezone.utc),
|
||||
}
|
||||
if user_id:
|
||||
try:
|
||||
doc["userId"] = ObjectId(user_id)
|
||||
except Exception:
|
||||
pass
|
||||
if rut:
|
||||
doc["rut"] = rut
|
||||
if ruts:
|
||||
doc["ruts"] = ruts
|
||||
if request_data is not None:
|
||||
doc["requestData"] = request_data
|
||||
if response_data is not None:
|
||||
doc["responseData"] = response_data
|
||||
if error_message:
|
||||
doc["errorMessage"] = error_message
|
||||
if processing_time_ms is not None:
|
||||
doc["processingTime"] = processing_time_ms
|
||||
if credits_used is not None:
|
||||
doc["creditsUsed"] = credits_used
|
||||
if ip_address:
|
||||
doc["ipAddress"] = ip_address
|
||||
if user_agent:
|
||||
doc["userAgent"] = user_agent
|
||||
if metadata is not None:
|
||||
doc["metadata"] = metadata
|
||||
await db["consulta_history"].insert_one(doc)
|
||||
47
fastcheck_api/app/services/auth_service.py
Normal file
47
fastcheck_api/app/services/auth_service.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from bson import ObjectId
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
|
||||
from fastcheck_api.app.core.security import create_access_token, verify_password
|
||||
|
||||
|
||||
class AuthService:
|
||||
@staticmethod
|
||||
async def login(*, db: AsyncIOMotorDatabase, email: str, password: str) -> dict:
|
||||
user = await db["users"].find_one({"email": email.lower()})
|
||||
if not user:
|
||||
raise ValueError("Invalid credentials")
|
||||
|
||||
if not user.get("isActive", False):
|
||||
raise PermissionError("ACCOUNT_NOT_ACTIVATED")
|
||||
|
||||
stored = user.get("password")
|
||||
if not stored:
|
||||
raise ValueError("Invalid credentials")
|
||||
|
||||
if not verify_password(password, stored):
|
||||
raise ValueError("Invalid credentials")
|
||||
|
||||
tenant_id = user.get("tenant")
|
||||
if isinstance(tenant_id, ObjectId):
|
||||
tenant_id_str = str(tenant_id)
|
||||
else:
|
||||
tenant_id_str = str(tenant_id or "")
|
||||
|
||||
token = create_access_token(
|
||||
user_id=str(user["_id"]),
|
||||
email=str(user.get("email") or ""),
|
||||
role=str(user.get("role") or ""),
|
||||
tenant_id=tenant_id_str,
|
||||
)
|
||||
|
||||
return {
|
||||
"token": token,
|
||||
"user": {
|
||||
"id": str(user["_id"]),
|
||||
"name": str(user.get("name") or ""),
|
||||
"email": str(user.get("email") or ""),
|
||||
"role": str(user.get("role") or ""),
|
||||
},
|
||||
}
|
||||
135
fastcheck_api/app/services/check_service.py
Normal file
135
fastcheck_api/app/services/check_service.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from typing import Any
|
||||
|
||||
from bson import ObjectId
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
from pymongo import ReturnDocument
|
||||
|
||||
from fastcheck_api.app.services.rabbitmq_service import rabbitmq_service
|
||||
from fastcheck_api.app.utils.mongo import jsonable, to_object_id
|
||||
|
||||
|
||||
class InsufficientCreditsError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CheckService:
|
||||
@staticmethod
|
||||
async def create_single_check(
|
||||
*,
|
||||
db: AsyncIOMotorDatabase,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
rut: str,
|
||||
name: str | None,
|
||||
) -> dict[str, Any]:
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
tenant_oid = to_object_id(tenant_id)
|
||||
|
||||
tenant_after = await db["tenants"].find_one_and_update(
|
||||
{"_id": tenant_oid, "creditBalance.availableCredits": {"$gte": 1}},
|
||||
{
|
||||
"$inc": {
|
||||
"creditBalance.availableCredits": -1,
|
||||
"creditBalance.totalCreditsUsed": 1,
|
||||
},
|
||||
"$set": {
|
||||
"creditBalance.lastCreditOperation": now,
|
||||
"updatedAt": now,
|
||||
},
|
||||
},
|
||||
return_document=ReturnDocument.AFTER,
|
||||
)
|
||||
if not tenant_after:
|
||||
raise InsufficientCreditsError("Insufficient credits")
|
||||
|
||||
new_balance = int((tenant_after.get("creditBalance") or {}).get("availableCredits") or 0)
|
||||
|
||||
await db["creditoperations"].insert_one(
|
||||
{
|
||||
"tenantId": tenant_oid,
|
||||
"userId": to_object_id(user_id),
|
||||
"operationType": "evaluation",
|
||||
"creditsChanged": -1,
|
||||
"balanceAfter": new_balance,
|
||||
"description": f"Valutazione singola per {rut}",
|
||||
"metadata": {"evaluationType": "single", "supplierRut": rut, "supplierName": name},
|
||||
"createdAt": now,
|
||||
}
|
||||
)
|
||||
|
||||
job_doc: dict[str, Any] = {
|
||||
"tenantId": tenant_id,
|
||||
"status": "processing",
|
||||
"type": "single",
|
||||
"createdBy": user_id,
|
||||
"totalEvaluations": 1,
|
||||
"completedEvaluations": 0,
|
||||
"failedEvaluations": 0,
|
||||
"supplierData": {"rut": rut, "name": name or "N/A"},
|
||||
"createdAt": now,
|
||||
"updatedAt": now,
|
||||
}
|
||||
insert = await db["evaluationjobs"].insert_one(job_doc)
|
||||
job_id = str(insert.inserted_id)
|
||||
|
||||
await rabbitmq_service.publish_evaluation(
|
||||
{"rut": rut, "name": name, "jobId": job_id, "tenantId": tenant_id, "userId": user_id}
|
||||
)
|
||||
|
||||
stored = await db["evaluationjobs"].find_one({"_id": insert.inserted_id})
|
||||
if not stored:
|
||||
raise RuntimeError("Failed to create job")
|
||||
return jsonable(stored)
|
||||
|
||||
@staticmethod
|
||||
async def list_jobs(*, db: AsyncIOMotorDatabase, tenant_id: str, page: int, limit: int) -> dict[str, Any]:
|
||||
skip = (page - 1) * limit
|
||||
cursor = (
|
||||
db["evaluationjobs"]
|
||||
.find({"tenantId": tenant_id})
|
||||
.sort([("createdAt", -1)])
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
jobs = [jsonable(doc) async for doc in cursor]
|
||||
total = await db["evaluationjobs"].count_documents({"tenantId": tenant_id})
|
||||
pages = (total + limit - 1) // limit
|
||||
return {"jobs": jobs, "total": total, "pages": pages}
|
||||
|
||||
@staticmethod
|
||||
async def get_job(*, db: AsyncIOMotorDatabase, tenant_id: str, job_id: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
oid = ObjectId(job_id)
|
||||
except Exception:
|
||||
return None
|
||||
doc = await db["evaluationjobs"].find_one({"_id": oid, "tenantId": tenant_id})
|
||||
return jsonable(doc) if doc else None
|
||||
|
||||
@staticmethod
|
||||
async def list_results(
|
||||
*, db: AsyncIOMotorDatabase, tenant_id: str, job_id: str, page: int, limit: int
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
oid = ObjectId(job_id)
|
||||
except Exception:
|
||||
raise ValueError("Invalid job id")
|
||||
|
||||
job = await db["evaluationjobs"].find_one({"_id": oid, "tenantId": tenant_id})
|
||||
if not job:
|
||||
raise LookupError("Job not found")
|
||||
|
||||
skip = (page - 1) * limit
|
||||
cursor = (
|
||||
db["evaluationresults"]
|
||||
.find({"jobId": oid, "tenantId": tenant_id})
|
||||
.sort([("createdAt", -1)])
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
results = [jsonable(doc) async for doc in cursor]
|
||||
total = await db["evaluationresults"].count_documents({"jobId": oid, "tenantId": tenant_id})
|
||||
pages = (total + limit - 1) // limit
|
||||
return {"results": results, "total": total, "pages": pages}
|
||||
45
fastcheck_api/app/services/legacy_service.py
Normal file
45
fastcheck_api/app/services/legacy_service.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from fastcheck_api.app.core.config import settings
|
||||
|
||||
|
||||
class LegacyService:
|
||||
def __init__(self) -> None:
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=settings.legacy_base_url,
|
||||
timeout=settings.legacy_http_timeout_seconds,
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
async def lookup_rut(self, *, rut: str, token: str) -> dict[str, Any]:
|
||||
resp = await self._client.post(
|
||||
"rut/lookup",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"rut": rut, "isMonitoring": False, "type": "masiva"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError("Unexpected legacy response")
|
||||
return data
|
||||
|
||||
async def save_fastcheck_summary(self, *, rut: str, summary: str, token: str) -> dict[str, Any]:
|
||||
resp = await self._client.post(
|
||||
f"rut/fast-check-summary/{rut}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"summary": summary},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError("Unexpected legacy response")
|
||||
return data
|
||||
|
||||
|
||||
legacy_service = LegacyService()
|
||||
47
fastcheck_api/app/services/rabbitmq_service.py
Normal file
47
fastcheck_api/app/services/rabbitmq_service.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import aio_pika
|
||||
|
||||
from fastcheck_api.app.core.config import settings
|
||||
|
||||
|
||||
class RabbitMQService:
|
||||
def __init__(self) -> None:
|
||||
self._connection: aio_pika.RobustConnection | None = None
|
||||
self._channel: aio_pika.RobustChannel | None = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
if not settings.rabbitmq_enabled:
|
||||
return
|
||||
if self._connection and not self._connection.is_closed:
|
||||
return
|
||||
self._connection = await aio_pika.connect_robust(settings.rabbitmq_connection_url)
|
||||
self._channel = await self._connection.channel()
|
||||
await self._channel.set_qos(prefetch_count=1)
|
||||
await self._channel.declare_queue(settings.rabbitmq_queue, durable=True)
|
||||
|
||||
async def publish_evaluation(self, data: dict[str, Any]) -> None:
|
||||
if not settings.rabbitmq_enabled:
|
||||
return
|
||||
await self.connect()
|
||||
if not self._channel:
|
||||
raise RuntimeError("RabbitMQ channel not available")
|
||||
exchange = self._channel.default_exchange
|
||||
message = aio_pika.Message(
|
||||
body=json.dumps(data).encode("utf-8"),
|
||||
delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
|
||||
content_type="application/json",
|
||||
)
|
||||
await exchange.publish(message, routing_key=settings.rabbitmq_queue)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._channel and not self._channel.is_closed:
|
||||
await self._channel.close()
|
||||
if self._connection and not self._connection.is_closed:
|
||||
await self._connection.close()
|
||||
|
||||
|
||||
rabbitmq_service = RabbitMQService()
|
||||
1
fastcheck_api/app/utils/__init__.py
Normal file
1
fastcheck_api/app/utils/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
23
fastcheck_api/app/utils/mongo.py
Normal file
23
fastcheck_api/app/utils/mongo.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from bson import ObjectId
|
||||
|
||||
|
||||
def to_object_id(value: str) -> ObjectId:
|
||||
return ObjectId(value)
|
||||
|
||||
|
||||
def jsonable(value: Any) -> Any:
|
||||
if isinstance(value, ObjectId):
|
||||
return str(value)
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, Mapping):
|
||||
return {k: jsonable(v) for k, v in value.items()}
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return [jsonable(v) for v in value]
|
||||
return value
|
||||
1
fastcheck_api/app/workers/__init__.py
Normal file
1
fastcheck_api/app/workers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
148
fastcheck_api/app/workers/check_worker.py
Normal file
148
fastcheck_api/app/workers/check_worker.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime as dt
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aio_pika
|
||||
from bson import ObjectId
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
|
||||
from fastcheck_api.app.core.config import settings
|
||||
from fastcheck_api.app.core.logging import configure_logging
|
||||
from fastcheck_api.app.core.security import create_access_token
|
||||
from fastcheck_api.app.services.legacy_service import legacy_service
|
||||
|
||||
|
||||
logger = logging.getLogger("fastcheck.worker")
|
||||
|
||||
|
||||
def _db() -> Any:
|
||||
client = AsyncIOMotorClient(settings.mongodb_uri)
|
||||
return client, client[settings.mongo_db_name]
|
||||
|
||||
|
||||
async def _update_job_progress(db: Any, job_oid: ObjectId, tenant_id: str) -> None:
|
||||
job = await db["evaluationjobs"].find_one({"_id": job_oid, "tenantId": tenant_id})
|
||||
if not job:
|
||||
return
|
||||
total = int(job.get("totalEvaluations") or 0)
|
||||
completed = await db["evaluationresults"].count_documents({"jobId": job_oid, "tenantId": tenant_id, "status": "success"})
|
||||
failed = await db["evaluationresults"].count_documents({"jobId": job_oid, "tenantId": tenant_id, "status": "failed"})
|
||||
status = "processing"
|
||||
if total and (completed + failed) >= total:
|
||||
status = "completed"
|
||||
await db["evaluationjobs"].update_one(
|
||||
{"_id": job_oid, "tenantId": tenant_id},
|
||||
{
|
||||
"$set": {
|
||||
"completedEvaluations": completed,
|
||||
"failedEvaluations": failed,
|
||||
"status": status,
|
||||
"updatedAt": dt.datetime.now(dt.timezone.utc),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _process_payload(db: Any, payload: dict[str, Any]) -> None:
|
||||
rut = str(payload.get("rut") or "")
|
||||
name = payload.get("name")
|
||||
tenant_id = str(payload.get("tenantId") or "")
|
||||
user_id = str(payload.get("userId") or "")
|
||||
job_id = str(payload.get("jobId") or "")
|
||||
|
||||
if not rut or not tenant_id or not user_id or not job_id:
|
||||
raise ValueError("Invalid message payload")
|
||||
|
||||
job_oid = ObjectId(job_id)
|
||||
user_oid = ObjectId(user_id)
|
||||
user = await db["users"].find_one({"_id": user_oid}, projection={"email": 1, "role": 1, "tenant": 1})
|
||||
if not user:
|
||||
raise RuntimeError("User not found")
|
||||
|
||||
token = create_access_token(
|
||||
user_id=user_id,
|
||||
email=str(user.get("email") or ""),
|
||||
role=str(user.get("role") or ""),
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
|
||||
try:
|
||||
result_data = await legacy_service.lookup_rut(rut=rut, token=token)
|
||||
ai_summary = result_data.get("aiAnalysis") or None
|
||||
if ai_summary and isinstance(ai_summary, str) and ai_summary.strip():
|
||||
try:
|
||||
await legacy_service.save_fastcheck_summary(rut=rut, summary=ai_summary, token=token)
|
||||
except Exception:
|
||||
logger.exception("Failed to save fast-check summary via legacy endpoint")
|
||||
|
||||
await db["evaluationresults"].insert_one(
|
||||
{
|
||||
"jobId": job_oid,
|
||||
"tenantId": tenant_id,
|
||||
"rut": rut,
|
||||
"name": name,
|
||||
"status": "success",
|
||||
"data": result_data,
|
||||
"createdAt": now,
|
||||
"updatedAt": now,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
await db["evaluationresults"].insert_one(
|
||||
{
|
||||
"jobId": job_oid,
|
||||
"tenantId": tenant_id,
|
||||
"rut": rut,
|
||||
"name": name,
|
||||
"status": "failed",
|
||||
"error": str(e),
|
||||
"createdAt": now,
|
||||
"updatedAt": now,
|
||||
}
|
||||
)
|
||||
finally:
|
||||
await _update_job_progress(db, job_oid, tenant_id)
|
||||
|
||||
|
||||
async def run_worker() -> None:
|
||||
configure_logging()
|
||||
if not settings.rabbitmq_enabled:
|
||||
raise RuntimeError("RabbitMQ disabled")
|
||||
|
||||
client, db = _db()
|
||||
connection: aio_pika.RobustConnection | None = None
|
||||
try:
|
||||
connection = await aio_pika.connect_robust(settings.rabbitmq_connection_url)
|
||||
channel = await connection.channel()
|
||||
await channel.set_qos(prefetch_count=1)
|
||||
queue = await channel.declare_queue(settings.rabbitmq_queue, durable=True)
|
||||
|
||||
async with queue.iterator() as iterator:
|
||||
async for message in iterator:
|
||||
async with message.process(requeue=True):
|
||||
payload = json.loads(message.body.decode("utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Invalid message body")
|
||||
await _process_payload(db, payload)
|
||||
finally:
|
||||
try:
|
||||
await legacy_service.close()
|
||||
except Exception:
|
||||
pass
|
||||
if connection and not connection.is_closed:
|
||||
await connection.close()
|
||||
client.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
asyncio.run(run_worker())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
19
fastcheck_api/docs/api_reference.md
Normal file
19
fastcheck_api/docs/api_reference.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# API Reference
|
||||
|
||||
The OpenAPI schema is the source of truth:
|
||||
|
||||
- JSON schema: `GET /openapi.json`
|
||||
- Swagger UI (internal): `/internal/docs`
|
||||
- ReDoc (internal): `/internal/redoc`
|
||||
|
||||
## Groups
|
||||
|
||||
- `auth`: login, refresh, profile
|
||||
- `checks`: create and query evaluation jobs/results
|
||||
- `reports`: fetch saved summaries/reports
|
||||
- `usage`: tenant usage and credit operations
|
||||
|
||||
## Compatibility notes
|
||||
|
||||
- Identity, tenants, and permissions are derived from existing MongoDB collections and fields.
|
||||
- The system does not introduce new auth/user/tenant collections.
|
||||
62
fastcheck_api/docs/authentication.md
Normal file
62
fastcheck_api/docs/authentication.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Authentication
|
||||
|
||||
FastCheck uses bearer JWT authentication.
|
||||
|
||||
## Source of truth
|
||||
|
||||
Authentication is compatible with the existing Node.js backend:
|
||||
|
||||
- Users are stored in the existing `users` collection.
|
||||
- Passwords are verified against the existing bcrypt hash in `users.password`.
|
||||
- Tenant membership is read from `users.tenant`.
|
||||
- Access control is enforced using `users.role` (role → permissions mapping in code).
|
||||
|
||||
## Obtain a token
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/api/v1/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"user@example.com","password":"********"}'
|
||||
```
|
||||
|
||||
### Python (httpx)
|
||||
|
||||
```python
|
||||
import httpx
|
||||
|
||||
resp = httpx.post(
|
||||
"http://127.0.0.1:8080/api/v1/auth/login",
|
||||
json={"email": "user@example.com", "password": "********"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
token = resp.json()["token"]
|
||||
```
|
||||
|
||||
### JavaScript (fetch)
|
||||
|
||||
```js
|
||||
const resp = await fetch("http://127.0.0.1:8080/api/v1/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "user@example.com", password: "********" }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
const { token } = await resp.json();
|
||||
```
|
||||
|
||||
## Use the token
|
||||
|
||||
Send it as:
|
||||
|
||||
```
|
||||
Authorization: Bearer <jwt>
|
||||
```
|
||||
|
||||
## Token refresh
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/api/v1/auth/refresh" \
|
||||
-H "Authorization: Bearer <jwt>"
|
||||
```
|
||||
85
fastcheck_api/docs/examples.md
Normal file
85
fastcheck_api/docs/examples.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# End-to-end examples
|
||||
|
||||
## Create a check, poll results, fetch report (cURL)
|
||||
|
||||
```bash
|
||||
API="http://127.0.0.1:8080"
|
||||
|
||||
TOKEN="$(curl -sS -X POST "$API/api/v1/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"user@example.com","password":"********"}' | python -c 'import sys, json; print(json.load(sys.stdin)["token"])')"
|
||||
|
||||
JOB_ID="$(curl -sS -X POST "$API/api/v1/checks" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"supplier":{"rut":"12345678-9","name":"ACME SpA"}}' | python -c 'import sys, json; print(json.load(sys.stdin)["_id"])')"
|
||||
|
||||
curl -sS "$API/api/v1/checks/$JOB_ID/results" -H "Authorization: Bearer $TOKEN"
|
||||
|
||||
curl -sS "$API/api/v1/reports/fast-check/12345678-9" -H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
## Python example (httpx)
|
||||
|
||||
```python
|
||||
import time
|
||||
import httpx
|
||||
|
||||
api = "http://127.0.0.1:8080"
|
||||
|
||||
with httpx.Client() as client:
|
||||
token = client.post(
|
||||
f"{api}/api/v1/auth/login",
|
||||
json={"email": "user@example.com", "password": "********"},
|
||||
).json()["token"]
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
job = client.post(
|
||||
f"{api}/api/v1/checks",
|
||||
headers=headers,
|
||||
json={"supplier": {"rut": "12345678-9", "name": "ACME SpA"}},
|
||||
).json()
|
||||
|
||||
job_id = job["_id"]
|
||||
|
||||
for _ in range(30):
|
||||
results = client.get(f"{api}/api/v1/checks/{job_id}/results", headers=headers).json()
|
||||
if results["total"] > 0:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
report = client.get(f"{api}/api/v1/reports/fast-check/12345678-9", headers=headers)
|
||||
if report.status_code == 200:
|
||||
print(report.json()["content"])
|
||||
```
|
||||
|
||||
## JavaScript example (fetch)
|
||||
|
||||
```js
|
||||
const api = "http://127.0.0.1:8080";
|
||||
|
||||
const loginResp = await fetch(`${api}/api/v1/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "user@example.com", password: "********" }),
|
||||
});
|
||||
if (!loginResp.ok) throw new Error(await loginResp.text());
|
||||
const { token } = await loginResp.json();
|
||||
|
||||
const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
|
||||
|
||||
const createResp = await fetch(`${api}/api/v1/checks`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ supplier: { rut: "12345678-9", name: "ACME SpA" } }),
|
||||
});
|
||||
if (!createResp.ok) throw new Error(await createResp.text());
|
||||
const job = await createResp.json();
|
||||
|
||||
const resultsResp = await fetch(`${api}/api/v1/checks/${job._id}/results`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!resultsResp.ok) throw new Error(await resultsResp.text());
|
||||
console.log(await resultsResp.json());
|
||||
```
|
||||
12
fastcheck_api/docs/index.md
Normal file
12
fastcheck_api/docs/index.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# FastCheck API Documentation
|
||||
|
||||
- OpenAPI schema (source of truth): `/openapi.json`
|
||||
- Swagger UI (internal): `/internal/docs`
|
||||
- ReDoc (internal): `/internal/redoc`
|
||||
|
||||
## Start here
|
||||
|
||||
- [Quickstart](quickstart.md)
|
||||
- [Authentication](authentication.md)
|
||||
- [API Reference](api_reference.md)
|
||||
- [End-to-end examples](examples.md)
|
||||
45
fastcheck_api/docs/quickstart.md
Normal file
45
fastcheck_api/docs/quickstart.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Quickstart
|
||||
|
||||
## 1) Run the API
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r fastcheck_api/requirements.txt
|
||||
|
||||
cp fastcheck_api/.env.example fastcheck_api/.env
|
||||
export $(cat fastcheck_api/.env | xargs)
|
||||
|
||||
uvicorn fastcheck_api.app.main:app --host ${FASTCHECK_API_HOST:-127.0.0.1} --port ${FASTCHECK_API_PORT:-8080} --reload
|
||||
```
|
||||
|
||||
## 2) Login
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/api/v1/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"user@example.com","password":"********"}'
|
||||
```
|
||||
|
||||
## 3) Create a check
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/api/v1/checks" \
|
||||
-H "Authorization: Bearer <jwt>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"supplier":{"rut":"12345678-9","name":"ACME SpA"}}'
|
||||
```
|
||||
|
||||
## 4) Fetch results
|
||||
|
||||
```bash
|
||||
curl -sS "http://127.0.0.1:8080/api/v1/checks/<job_id>/results" \
|
||||
-H "Authorization: Bearer <jwt>"
|
||||
```
|
||||
|
||||
## 5) Run the worker (async processing)
|
||||
|
||||
```bash
|
||||
export $(cat fastcheck_api/.env | xargs)
|
||||
python -m fastcheck_api.app.workers.check_worker
|
||||
```
|
||||
11
fastcheck_api/requirements.txt
Normal file
11
fastcheck_api/requirements.txt
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fastapi>=0.115,<1.0
|
||||
pydantic>=2.6,<3.0
|
||||
pydantic-settings>=2.2,<3.0
|
||||
email-validator>=2.2,<3.0
|
||||
uvicorn[standard]>=0.30,<1.0
|
||||
motor>=3.6,<4.0
|
||||
pymongo>=4.6,<5.0
|
||||
PyJWT>=2.8,<3.0
|
||||
bcrypt>=4.1,<5.0
|
||||
aio-pika>=9.4,<10.0
|
||||
httpx>=0.27,<1.0
|
||||
33
fastcheck_api/run.sh
Executable file
33
fastcheck_api/run.sh
Executable file
|
|
@ -0,0 +1,33 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
API_DIR="${ROOT_DIR}/fastcheck_api"
|
||||
VENV_DIR="${ROOT_DIR}/.venv"
|
||||
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
if [[ ! -d "${VENV_DIR}" ]]; then
|
||||
python3 -m venv "${VENV_DIR}"
|
||||
fi
|
||||
|
||||
source "${VENV_DIR}/bin/activate"
|
||||
|
||||
python -m pip install --upgrade pip >/dev/null
|
||||
python -m pip install -r "${API_DIR}/requirements.txt"
|
||||
|
||||
if [[ -f "${API_DIR}/.env" ]]; then
|
||||
set -a
|
||||
source "${API_DIR}/.env"
|
||||
set +a
|
||||
elif [[ -f "${API_DIR}/.env.example" ]]; then
|
||||
cp -n "${API_DIR}/.env.example" "${API_DIR}/.env" || true
|
||||
set -a
|
||||
source "${API_DIR}/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
HOST="${FASTCHECK_API_HOST:-127.0.0.1}"
|
||||
PORT="${FASTCHECK_API_PORT:-8080}"
|
||||
|
||||
exec uvicorn fastcheck_api.app.main:app --host "${HOST}" --port "${PORT}" --reload
|
||||
Loading…
Reference in New Issue
Block a user