Phase 2 - Raw SQL migration, pytest-cov coverage gate, CI branch
triggers + coverage, Dead tooling & config drift
This commit is contained in:
parent
06e1256ea6
commit
2fe54ffd86
67 changed files with 4509 additions and 2669 deletions
66
.github/workflows/ci.yml
vendored
66
.github/workflows/ci.yml
vendored
|
|
@ -2,9 +2,9 @@ name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main, master]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main, master]
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
|
@ -56,39 +56,37 @@ jobs:
|
||||||
- name: Sync dev dependencies
|
- name: Sync dev dependencies
|
||||||
run: uv sync --group dev --frozen
|
run: uv sync --group dev --frozen
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests with coverage
|
||||||
run: uv run pytest -m "not integration"
|
run: uv run pytest --cov=src/contract_check --cov-branch --cov-report=term-missing --cov-fail-under=59 -m "not integration" tests/unit
|
||||||
|
|
||||||
# Integration tests are temporarily disabled in CI while the Docker Compose
|
# Integration tests.
|
||||||
# infrastructure startup issue is investigated. They can still be run locally
|
test-integration:
|
||||||
# with `docker compose up -d` followed by `uv run pytest -m integration`.
|
name: Integration tests
|
||||||
# test-integration:
|
runs-on: ubuntu-latest
|
||||||
# name: Integration tests
|
steps:
|
||||||
# runs-on: ubuntu-latest
|
- uses: actions/checkout@v4
|
||||||
# steps:
|
|
||||||
# - uses: actions/checkout@v4
|
- name: Setup uv
|
||||||
#
|
uses: astral-sh/setup-uv@v8.3.2
|
||||||
# - name: Setup uv
|
with:
|
||||||
# uses: astral-sh/setup-uv@v8.3.2
|
enable-cache: true
|
||||||
# with:
|
cache-dependency-glob: uv.lock
|
||||||
# enable-cache: true
|
|
||||||
# cache-dependency-glob: uv.lock
|
- name: Install Python
|
||||||
#
|
run: uv python install
|
||||||
# - name: Install Python
|
|
||||||
# run: uv python install
|
- name: Sync dev dependencies
|
||||||
#
|
run: uv sync --group dev --frozen
|
||||||
# - name: Sync dev dependencies
|
|
||||||
# run: uv sync --group dev --frozen
|
- name: Start infrastructure (postgres/redis/rabbitmq/minio)
|
||||||
#
|
run: docker compose up -d --wait
|
||||||
# - name: Start infrastructure (postgres/redis/rabbitmq/minio)
|
|
||||||
# run: docker compose up -d --wait
|
- name: Run integration tests
|
||||||
#
|
run: uv run pytest -m integration
|
||||||
# - name: Run integration tests
|
|
||||||
# run: uv run pytest -m integration
|
- name: Teardown infrastructure
|
||||||
#
|
if: always()
|
||||||
# - name: Teardown infrastructure
|
run: docker compose down -v
|
||||||
# if: always()
|
|
||||||
# run: docker compose down -v
|
|
||||||
|
|
||||||
# Build and push service images on pushes to main. Set these repository secrets:
|
# Build and push service images on pushes to main. Set these repository secrets:
|
||||||
# REGISTRY e.g. ghcr.io (or docker.io, your-private-registry.io)
|
# REGISTRY e.g. ghcr.io (or docker.io, your-private-registry.io)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# Local hooks run through `uv run` so they use the pinned tool versions
|
# Local hooks run through `uv run` so they use the pinned tool versions
|
||||||
# from uv.lock (ruff/mypy in the `dev` group). Install with: uv run pre-commit install
|
# from uv.lock (ruff/isort/ty in the `dev` group). Install with: uv run pre-commit install
|
||||||
|
|
||||||
minimum_pre_commit_version: "4.0.0"
|
minimum_pre_commit_version: "4.0.0"
|
||||||
|
|
||||||
|
|
|
||||||
5
Makefile
5
Makefile
|
|
@ -44,6 +44,9 @@ test: ## Run all tests (unit + integration)
|
||||||
test-unit: ## Run unit tests only (fast)
|
test-unit: ## Run unit tests only (fast)
|
||||||
uv run pytest -m "not integration"
|
uv run pytest -m "not integration"
|
||||||
|
|
||||||
|
test-cov: ## Run unit tests with coverage report (enforces threshold)
|
||||||
|
uv run pytest --cov=src/contract_check --cov-branch --cov-report=term-missing --cov-report=html --cov-fail-under=59 -m "not integration" tests/unit
|
||||||
|
|
||||||
test-integration: ## Run integration tests (needs docker infra)
|
test-integration: ## Run integration tests (needs docker infra)
|
||||||
uv run pytest -m integration
|
uv run pytest -m integration
|
||||||
|
|
||||||
|
|
@ -206,7 +209,7 @@ shell-bot: ## Open shell inside bot container
|
||||||
clean: ## Remove containers, volumes, caches
|
clean: ## Remove containers, volumes, caches
|
||||||
docker compose --profile services down -v
|
docker compose --profile services down -v
|
||||||
docker compose down -v
|
docker compose down -v
|
||||||
rm -rf .mypy_cache .pytest_cache .ruff_cache
|
rm -rf .pytest_cache .ruff_cache
|
||||||
uv cache clean
|
uv cache clean
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
# Alembic config. The DB URL is resolved at runtime from DATABASE_URL env
|
# Alembic config. The DB URL is resolved at runtime from DATABASE_URL env
|
||||||
# (see migrations/env.py), so the [alembic] sqlalchemy.url here is a fallback.
|
# (see migrations/env.py), so the [alembic] sqlalchemy.url here is a dev-only
|
||||||
|
# fallback for local `alembic` invocations without DATABASE_URL set.
|
||||||
[alembic]
|
[alembic]
|
||||||
script_location = migrations
|
script_location = migrations
|
||||||
prepend_sys_path = src
|
prepend_sys_path = src
|
||||||
|
|
|
||||||
|
|
@ -191,9 +191,9 @@ DealDocumentScreening/
|
||||||
│ │ ├── redis_client.py (async Redis client from redis_url)
|
│ │ ├── redis_client.py (async Redis client from redis_url)
|
||||||
│ │ ├── db/
|
│ │ ├── db/
|
||||||
│ │ │ ├── __init__.py
|
│ │ │ ├── __init__.py
|
||||||
│ │ │ ├── models.py (SQLAlchemy 2 decl: User, Document, Report, Job, ServiceToken, Invoice, ApiKey, ApiKeyRequest)
|
│ │ │ ├── models.py (SQLAlchemy 2 decl: User, PasskeyCredential, Document, Report, PrescreenResult, Job, ServiceToken, ApiKey, ApiKeyRequest, Invoice)
|
||||||
│ │ │ ├── session.py (async_sessionmaker, engine)
|
│ │ │ ├── session.py (async_sessionmaker, engine)
|
||||||
│ │ │ └── enums.py (DocStatus, JobStatus, FailureClass — as plain str constants)
|
│ │ │ └── enums.py (DocStatus, JobStatus, QueueName, RoutingDecision, FailureClass — as plain str constants)
|
||||||
│ │ ├── mq/
|
│ │ ├── mq/
|
||||||
│ │ │ ├── __init__.py
|
│ │ │ ├── __init__.py
|
||||||
│ │ │ ├── topology.py (exchange/queue/rk constants + declare_all())
|
│ │ │ ├── topology.py (exchange/queue/rk constants + declare_all())
|
||||||
|
|
@ -522,12 +522,40 @@ before api/worker start. Alternatively `core/s3/minio_storage.py` does
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Postgres schema (Alembic migrations)
|
## 7. Postgres schema & data access (Alembic migrations + repositories)
|
||||||
|
|
||||||
Six core tables plus additive migrations. `status`/`queue`/`adapter`/`role`
|
Six core tables plus additive migrations. `status`/`queue`/`adapter`/`role`
|
||||||
columns are `TEXT + CHECK` (not Postgres enums) so migrations are additive —
|
columns are `TEXT + CHECK` (not Postgres enums) so migrations are additive —
|
||||||
matches the convention noted in `IMPLEMENTATION_PLAN.md` §1.2.
|
matches the convention noted in `IMPLEMENTATION_PLAN.md` §1.2.
|
||||||
|
|
||||||
|
### 7.1 Data-access layer
|
||||||
|
|
||||||
|
All production SQL lives behind the repository package:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/contract_check/core/db/repositories/
|
||||||
|
├── api_keys.py (ApiKeyRepository)
|
||||||
|
├── credits.py (CreditsRepository — thin facade)
|
||||||
|
├── documents.py (DocumentRepository)
|
||||||
|
├── jobs.py (JobRepository)
|
||||||
|
├── passkeys.py (PasskeyRepository)
|
||||||
|
├── prescreen_results.py (PrescreenResultRepository)
|
||||||
|
├── reports.py (ReportRepository)
|
||||||
|
├── service_tokens.py (ServiceTokenRepository)
|
||||||
|
└── users.py (UserRepository)
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- A repository receives an `AsyncSession`; it **never** commits.
|
||||||
|
- Callers own transactions and commits.
|
||||||
|
- Raw `text()` is allowed only inside repositories or migrations.
|
||||||
|
- `api/deps.py` keeps lightweight dependency helpers but no direct SQL; it calls
|
||||||
|
`UserRepository`, `ApiKeyRepository`, etc.
|
||||||
|
- `core/credits.py` remains the public facade for credit operations; it now
|
||||||
|
delegates to `CreditsRepository`.
|
||||||
|
|
||||||
|
### 7.2 Migrations
|
||||||
|
|
||||||
Migrations (hand-written, async `env.py`):
|
Migrations (hand-written, async `env.py`):
|
||||||
- `0001` initial schema: users/documents/reports/jobs/service_tokens/invoices
|
- `0001` initial schema: users/documents/reports/jobs/service_tokens/invoices
|
||||||
- `0002` `api_keys` + `api_key_requests` (B2B)
|
- `0002` `api_keys` + `api_key_requests` (B2B)
|
||||||
|
|
@ -616,7 +644,7 @@ CREATE TABLE prescreen_results (
|
||||||
error_message TEXT,
|
error_message TEXT,
|
||||||
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0)
|
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0)
|
||||||
);
|
);
|
||||||
CREATE INDEX prescreen_results_document_idx ON prescreen_results (document_id);
|
CREATE INDEX ix_prescreen_results_document_id ON prescreen_results (document_id);
|
||||||
CREATE INDEX prescreen_results_routing_idx ON prescreen_results (routing_decision);
|
CREATE INDEX prescreen_results_routing_idx ON prescreen_results (routing_decision);
|
||||||
CREATE INDEX prescreen_results_confidence_idx ON prescreen_results (confidence_score);
|
CREATE INDEX prescreen_results_confidence_idx ON prescreen_results (confidence_score);
|
||||||
|
|
||||||
|
|
@ -1443,7 +1471,7 @@ Each step is a verifiable unit. Do not start step N+1 until N is green
|
||||||
minio, minio-init. All with healthchecks + volumes. Default profile =
|
minio, minio-init. All with healthchecks + volumes. Default profile =
|
||||||
infra only.
|
infra only.
|
||||||
- `src/contract_check/core/`: config, logging, telemetry, sentry, metrics,
|
- `src/contract_check/core/`: config, logging, telemetry, sentry, metrics,
|
||||||
db/models (all 6 tables), db/session, mq/topology, mq/messages, s3/port,
|
db/models (all 10 tables), db/session, mq/topology, mq/messages, s3/port,
|
||||||
s3/minio_storage, llm/port, analysis/* (migrate extractor/chunker/checklist/
|
s3/minio_storage, llm/port, analysis/* (migrate extractor/chunker/checklist/
|
||||||
report_schema from prototype), credits, tokens.
|
report_schema from prototype), credits, tokens.
|
||||||
- Alembic: init + initial migration (6 tables).
|
- Alembic: init + initial migration (6 tables).
|
||||||
|
|
|
||||||
|
|
@ -130,14 +130,13 @@ dev = [
|
||||||
"pytest-asyncio>=0.23",
|
"pytest-asyncio>=0.23",
|
||||||
"respx>=0.21",
|
"respx>=0.21",
|
||||||
"ruff>=0.5",
|
"ruff>=0.5",
|
||||||
"mypy>=1.10",
|
|
||||||
"anyio>=4",
|
"anyio>=4",
|
||||||
"aiosqlite>=0.20",
|
"aiosqlite>=0.20",
|
||||||
"testcontainers[rabbitmq,postgres,minio]>=4",
|
|
||||||
"asgi-lifespan>=2.1.0",
|
"asgi-lifespan>=2.1.0",
|
||||||
"pre-commit>=4.6.2",
|
"pre-commit>=4.6.2",
|
||||||
"isort>=5.13",
|
"isort>=5.13",
|
||||||
"ty>=0.0.72",
|
"ty>=0.0.72",
|
||||||
|
"pytest-cov>=7.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
|
|
@ -169,3 +168,22 @@ pythonpath = ["."]
|
||||||
markers = [
|
markers = [
|
||||||
"integration: slow tests needing real Postgres/RabbitMQ/MinIO (deselect with '-m \"not integration\"')",
|
"integration: slow tests needing real Postgres/RabbitMQ/MinIO (deselect with '-m \"not integration\"')",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["src/contract_check"]
|
||||||
|
branch = true
|
||||||
|
omit = [
|
||||||
|
"*/migrations/*",
|
||||||
|
"*/prototype/*",
|
||||||
|
"*/__main__.py",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
"raise NotImplementedError",
|
||||||
|
]
|
||||||
|
fail_under = 59
|
||||||
|
show_missing = true
|
||||||
|
skip_covered = false
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import sys
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
from ..core.config import get_settings
|
from ..core.config import get_settings
|
||||||
|
from ..core.db.repositories import ServiceTokenRepository
|
||||||
from ..core.db.session import create_session_factory
|
from ..core.db.session import create_session_factory
|
||||||
from ..core.tokens import assert_adapter, generate_token, hash_token
|
from ..core.tokens import assert_adapter, generate_token, hash_token
|
||||||
from .app import create_app
|
from .app import create_app
|
||||||
|
|
@ -24,19 +25,8 @@ async def _seed_token(name: str, adapter: str, token: str | None = None) -> None
|
||||||
raw = token or generate_token()
|
raw = token or generate_token()
|
||||||
factory = create_session_factory()
|
factory = create_session_factory()
|
||||||
async with factory() as session:
|
async with factory() as session:
|
||||||
from sqlalchemy import text
|
repo = ServiceTokenRepository(session)
|
||||||
|
await repo.upsert(name=name, token_hash=hash_token(raw), adapter=adapter)
|
||||||
await session.execute(
|
|
||||||
text(
|
|
||||||
"INSERT INTO service_tokens (name, token_hash, adapter) "
|
|
||||||
"VALUES (:name, :hash, :adapter) "
|
|
||||||
"ON CONFLICT (name) DO UPDATE SET "
|
|
||||||
" token_hash = EXCLUDED.token_hash, "
|
|
||||||
" revoked = FALSE, "
|
|
||||||
" adapter = EXCLUDED.adapter"
|
|
||||||
),
|
|
||||||
{"name": name, "hash": hash_token(raw), "adapter": adapter},
|
|
||||||
)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
print(f"Token '{name}' ({adapter}) ready. Bearer:")
|
print(f"Token '{name}' ({adapter}) ready. Bearer:")
|
||||||
print(raw)
|
print(raw)
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,11 @@ from dataclasses import dataclass
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Request, status
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
from sqlalchemy import text
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ...core.auth import AuthError, verify_access_token
|
from ...core.auth import AuthError, verify_access_token
|
||||||
from ...core.config import get_settings
|
from ...core.config import get_settings
|
||||||
|
from ...core.db.repositories import UserRepository
|
||||||
from ..deps import AsyncSessionDep
|
from ..deps import AsyncSessionDep
|
||||||
|
|
||||||
# HttpOnly cookie carrying the admin access JWT.
|
# HttpOnly cookie carrying the admin access JWT.
|
||||||
|
|
@ -76,22 +76,17 @@ async def resolve_admin(request: Request, session: AsyncSession) -> AdminUser |
|
||||||
except AuthError:
|
except AuthError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
result = await session.execute(
|
user = await UserRepository(session).get_by_id(claims.sub)
|
||||||
text("SELECT id, telegram_id, email, role, is_active FROM users WHERE id = :u"),
|
if user is None:
|
||||||
{"u": claims.sub},
|
|
||||||
)
|
|
||||||
row = result.first()
|
|
||||||
if row is None:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
user_id, telegram_id, email, role, is_active = row
|
if not user.is_active or user.role != get_settings().admin_required_role:
|
||||||
if not is_active or role != get_settings().admin_required_role:
|
|
||||||
return None
|
return None
|
||||||
return AdminUser(
|
return AdminUser(
|
||||||
user_id=user_id,
|
user_id=user.id,
|
||||||
telegram_id=int(telegram_id or 0),
|
telegram_id=int(user.telegram_id or 0),
|
||||||
email=email,
|
email=user.email,
|
||||||
role=role,
|
role=user.role,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,10 @@ from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Form, Request, status
|
from fastapi import APIRouter, Form, Request, status
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
from ...core.auth import create_access_token
|
from ...core.auth import create_access_token
|
||||||
from ...core.config import get_settings
|
from ...core.config import get_settings
|
||||||
|
from ...core.db.repositories import UserRepository
|
||||||
from ...core.logging import get_logger
|
from ...core.logging import get_logger
|
||||||
from ...core.security.passwords import verify_password
|
from ...core.security.passwords import verify_password
|
||||||
from ..deps import AsyncSessionDep
|
from ..deps import AsyncSessionDep
|
||||||
|
|
@ -75,34 +75,26 @@ async def login(
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
"""Verify email/password + admin role, set the cookie, redirect to users."""
|
"""Verify email/password + admin role, set the cookie, redirect to users."""
|
||||||
email_norm = email.lower().strip()
|
email_norm = email.lower().strip()
|
||||||
result = await session.execute(
|
user = await UserRepository(session).get_by_email(email_norm)
|
||||||
text(
|
|
||||||
"SELECT id, telegram_id, email, password_hash, role, is_active "
|
|
||||||
"FROM users WHERE email = :e"
|
|
||||||
),
|
|
||||||
{"e": email_norm},
|
|
||||||
)
|
|
||||||
row = result.first()
|
|
||||||
invalid = RedirectResponse(
|
invalid = RedirectResponse(
|
||||||
url="/admin/login?reason=invalid", status_code=status.HTTP_303_SEE_OTHER
|
url="/admin/login?reason=invalid", status_code=status.HTTP_303_SEE_OTHER
|
||||||
)
|
)
|
||||||
if row is None:
|
if user is None:
|
||||||
return invalid
|
return invalid
|
||||||
|
|
||||||
user_id, telegram_id, _email, password_hash, role, is_active = row
|
if not user.password_hash or not verify_password(password, user.password_hash):
|
||||||
if not password_hash or not verify_password(password, password_hash):
|
|
||||||
return invalid
|
return invalid
|
||||||
if not is_active:
|
if not user.is_active:
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
url="/admin/login?reason=disabled", status_code=status.HTTP_303_SEE_OTHER
|
url="/admin/login?reason=disabled", status_code=status.HTTP_303_SEE_OTHER
|
||||||
)
|
)
|
||||||
if role != get_settings().admin_required_role:
|
if user.role != get_settings().admin_required_role:
|
||||||
log.warning("admin_login_forbidden", user_id=str(user_id), role=role)
|
log.warning("admin_login_forbidden", user_id=str(user.id), role=user.role)
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
url="/admin/login?reason=forbidden", status_code=status.HTTP_303_SEE_OTHER
|
url="/admin/login?reason=forbidden", status_code=status.HTTP_303_SEE_OTHER
|
||||||
)
|
)
|
||||||
|
|
||||||
token = create_access_token(user_id, int(telegram_id or 0))
|
token = create_access_token(user.id, int(user.telegram_id or 0))
|
||||||
response = RedirectResponse(url="/admin/users", status_code=status.HTTP_303_SEE_OTHER)
|
response = RedirectResponse(url="/admin/users", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key=ADMIN_COOKIE,
|
key=ADMIN_COOKIE,
|
||||||
|
|
@ -112,7 +104,7 @@ async def login(
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
secure=get_settings().env == "prod",
|
secure=get_settings().env == "prod",
|
||||||
)
|
)
|
||||||
log.info("admin_logged_in", user_id=str(user_id))
|
log.info("admin_logged_in", user_id=str(user.id))
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,11 @@ from typing import Annotated, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from sqlalchemy import text
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ...core.config import get_settings
|
from ...core.config import get_settings
|
||||||
from ...core.db.enums import USER_ROLE_USER, USER_ROLES
|
from ...core.db.enums import USER_ROLE_USER, USER_ROLES
|
||||||
|
from ...core.db.repositories import DocumentRepository, UserRepository
|
||||||
from ...core.logging import get_logger
|
from ...core.logging import get_logger
|
||||||
from ...core.security.passwords import hash_password
|
from ...core.security.passwords import hash_password
|
||||||
from ..deps import AsyncSessionDep
|
from ..deps import AsyncSessionDep
|
||||||
|
|
@ -36,54 +36,30 @@ router = APIRouter(
|
||||||
|
|
||||||
PAGE_SIZE = 25
|
PAGE_SIZE = 25
|
||||||
|
|
||||||
_USER_COLUMNS = (
|
|
||||||
"id, telegram_id, email, role, is_active, credits_left, created_at, telegram_verified"
|
def _user_to_dict(user: Any) -> dict[str, Any]:
|
||||||
)
|
return {
|
||||||
|
"id": user.id,
|
||||||
|
"telegram_id": user.telegram_id,
|
||||||
|
"email": user.email,
|
||||||
|
"role": user.role,
|
||||||
|
"is_active": bool(user.is_active),
|
||||||
|
"credits_left": int(user.credits_left),
|
||||||
|
"created_at": user.created_at,
|
||||||
|
"telegram_verified": bool(user.telegram_verified),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_user(session: AsyncSession, user_id: uuid.UUID) -> dict[str, Any] | None:
|
async def _fetch_user(session: AsyncSession, user_id: uuid.UUID) -> dict[str, Any] | None:
|
||||||
result = await session.execute(
|
user = await UserRepository(session).get_by_id(user_id)
|
||||||
text(f"SELECT {_USER_COLUMNS} FROM users WHERE id = :u"),
|
if user is None:
|
||||||
{"u": user_id},
|
|
||||||
)
|
|
||||||
row = result.first()
|
|
||||||
if row is None:
|
|
||||||
return None
|
return None
|
||||||
return _user_row_to_dict(row)
|
return _user_to_dict(user)
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_user_stats(session: AsyncSession, user_id: uuid.UUID) -> dict[str, int]:
|
async def _fetch_user_stats(session: AsyncSession, user_id: uuid.UUID) -> dict[str, int]:
|
||||||
"""Aggregate counts shown on the detail page."""
|
"""Aggregate counts shown on the detail page."""
|
||||||
result = await session.execute(
|
return await DocumentRepository(session).stats_for_user(user_id)
|
||||||
text(
|
|
||||||
"SELECT count(*), "
|
|
||||||
" count(*) FILTER (WHERE status = 'done'), "
|
|
||||||
" count(*) FILTER (WHERE status = 'failed') "
|
|
||||||
"FROM documents WHERE user_id = :u"
|
|
||||||
),
|
|
||||||
{"u": user_id},
|
|
||||||
)
|
|
||||||
row = result.first()
|
|
||||||
if row is None:
|
|
||||||
return {"docs_total": 0, "docs_done": 0, "docs_failed": 0}
|
|
||||||
return {
|
|
||||||
"docs_total": int(row[0] or 0),
|
|
||||||
"docs_done": int(row[1] or 0),
|
|
||||||
"docs_failed": int(row[2] or 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _user_row_to_dict(row: Any) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"id": row[0],
|
|
||||||
"telegram_id": row[1],
|
|
||||||
"email": row[2],
|
|
||||||
"role": row[3],
|
|
||||||
"is_active": bool(row[4]),
|
|
||||||
"credits_left": int(row[5]),
|
|
||||||
"created_at": row[6],
|
|
||||||
"telegram_verified": bool(row[7]),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_class=HTMLResponse, include_in_schema=False)
|
@router.get("", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
|
@ -97,44 +73,27 @@ async def list_users(
|
||||||
page = max(page, 1)
|
page = max(page, 1)
|
||||||
offset = (page - 1) * PAGE_SIZE
|
offset = (page - 1) * PAGE_SIZE
|
||||||
term = q.strip()
|
term = q.strip()
|
||||||
filter_sql = ""
|
|
||||||
filter_params: dict[str, Any] = {}
|
|
||||||
|
|
||||||
# Optional admin filters via query string (no URL params in form; keep simple).
|
# Optional admin filters via query string (no URL params in form; keep simple).
|
||||||
only_telegram = request.query_params.get("only_telegram") == "1"
|
only_telegram = request.query_params.get("only_telegram") == "1"
|
||||||
unverified = request.query_params.get("unverified") == "1"
|
unverified = request.query_params.get("unverified") == "1"
|
||||||
|
|
||||||
if only_telegram:
|
repo = UserRepository(session)
|
||||||
filter_sql += " AND telegram_id IS NOT NULL"
|
users_orm = await repo.list_users(
|
||||||
if unverified:
|
search=term or None,
|
||||||
filter_sql += " AND telegram_verified = FALSE"
|
has_telegram=only_telegram or None,
|
||||||
|
telegram_verified=False if unverified else None,
|
||||||
base_where = "WHERE (email ILIKE :q OR telegram_id::text ILIKE :q)" if term else "WHERE TRUE"
|
limit=PAGE_SIZE,
|
||||||
base_where += filter_sql
|
offset=offset,
|
||||||
like = f"%{term}%"
|
|
||||||
filter_params = {"q": like} if term else {}
|
|
||||||
|
|
||||||
rows = (
|
|
||||||
await session.execute(
|
|
||||||
text(
|
|
||||||
f"SELECT {_USER_COLUMNS} FROM users "
|
|
||||||
f"{base_where} "
|
|
||||||
"ORDER BY created_at DESC LIMIT :lim OFFSET :off"
|
|
||||||
),
|
|
||||||
{**filter_params, "lim": PAGE_SIZE, "off": offset},
|
|
||||||
)
|
)
|
||||||
).all()
|
total = await repo.count_users(
|
||||||
total = int(
|
search=term or None,
|
||||||
(
|
has_telegram=only_telegram or None,
|
||||||
await session.execute(
|
telegram_verified=False if unverified else None,
|
||||||
text(f"SELECT count(*) FROM users {base_where}"),
|
|
||||||
filter_params,
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
|
pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||||
users = [_user_row_to_dict(r) for r in rows]
|
users = [_user_to_dict(u) for u in users_orm]
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"users_list.html",
|
"users_list.html",
|
||||||
|
|
@ -236,10 +195,8 @@ async def create_user(
|
||||||
tg,
|
tg,
|
||||||
)
|
)
|
||||||
|
|
||||||
dup = (
|
repo = UserRepository(session)
|
||||||
await session.execute(text("SELECT 1 FROM users WHERE email = :e"), {"e": email_norm})
|
if await repo.email_exists(email_norm):
|
||||||
).first()
|
|
||||||
if dup is not None:
|
|
||||||
return _render_new_form(
|
return _render_new_form(
|
||||||
request,
|
request,
|
||||||
"Пользователь с таким email уже существует.",
|
"Пользователь с таким email уже существует.",
|
||||||
|
|
@ -248,11 +205,7 @@ async def create_user(
|
||||||
credits_left,
|
credits_left,
|
||||||
tg,
|
tg,
|
||||||
)
|
)
|
||||||
if tg_id is not None:
|
if tg_id is not None and await repo.telegram_id_exists(tg_id):
|
||||||
dup_tg = (
|
|
||||||
await session.execute(text("SELECT 1 FROM users WHERE telegram_id = :t"), {"t": tg_id})
|
|
||||||
).first()
|
|
||||||
if dup_tg is not None:
|
|
||||||
return _render_new_form(
|
return _render_new_form(
|
||||||
request,
|
request,
|
||||||
"Этот Telegram ID уже привязан к другому пользователю.",
|
"Этот Telegram ID уже привязан к другому пользователю.",
|
||||||
|
|
@ -264,28 +217,20 @@ async def create_user(
|
||||||
|
|
||||||
# ── insert ──
|
# ── insert ──
|
||||||
hashed = hash_password(password)
|
hashed = hash_password(password)
|
||||||
result = await session.execute(
|
new_user = await repo.create_email_user(
|
||||||
text(
|
email=email_norm,
|
||||||
"INSERT INTO users "
|
name=None,
|
||||||
"(email, password_hash, role, credits_left, telegram_id, is_active) "
|
password_hash=hashed,
|
||||||
"VALUES (:e, :p, :r, :c, :t, :a) "
|
credits_left=credits,
|
||||||
f"RETURNING {_USER_COLUMNS}"
|
role=role,
|
||||||
),
|
telegram_id=tg_id,
|
||||||
{
|
is_active=is_active == "on",
|
||||||
"e": email_norm,
|
|
||||||
"p": hashed,
|
|
||||||
"r": role,
|
|
||||||
"c": credits,
|
|
||||||
"t": tg_id,
|
|
||||||
"a": is_active == "on",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
row = result.first()
|
|
||||||
assert row is not None # RETURNING always yields the inserted row
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
new_id = row[0]
|
log.info("admin_user_created", user_id=str(new_user.id), email=email_norm, role=role)
|
||||||
log.info("admin_user_created", user_id=str(new_id), email=email_norm, role=role)
|
return RedirectResponse(
|
||||||
return RedirectResponse(url=f"/admin/users/{new_id}", status_code=status.HTTP_303_SEE_OTHER)
|
url=f"/admin/users/{new_user.id}", status_code=status.HTTP_303_SEE_OTHER
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{user_id}", response_class=HTMLResponse, include_in_schema=False)
|
@router.get("/{user_id}", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
|
@ -323,11 +268,10 @@ async def update_user(
|
||||||
"""Update role / active flag / credits. Returns the refreshed user card."""
|
"""Update role / active flag / credits. Returns the refreshed user card."""
|
||||||
if role not in USER_ROLES:
|
if role not in USER_ROLES:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid role")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid role")
|
||||||
credits = max(0, int(credits_left))
|
repo = UserRepository(session)
|
||||||
await session.execute(
|
await repo.set_role(user_id, role)
|
||||||
text("UPDATE users SET role = :r, is_active = :a, credits_left = :c WHERE id = :u"),
|
await repo.set_active(user_id, is_active == "on")
|
||||||
{"r": role, "a": is_active == "on", "c": credits, "u": user_id},
|
await repo.adjust_credits(user_id, max(0, int(credits_left)))
|
||||||
)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
user = await _fetch_user(session, user_id)
|
user = await _fetch_user(session, user_id)
|
||||||
|
|
@ -349,10 +293,8 @@ async def toggle_active(
|
||||||
session: AsyncSessionDep,
|
session: AsyncSessionDep,
|
||||||
_: Annotated[None, HtmxGuard],
|
_: Annotated[None, HtmxGuard],
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
await session.execute(
|
repo = UserRepository(session)
|
||||||
text("UPDATE users SET is_active = NOT is_active WHERE id = :u"),
|
await repo.toggle_active(user_id)
|
||||||
{"u": user_id},
|
|
||||||
)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
user = await _fetch_user(session, user_id)
|
user = await _fetch_user(session, user_id)
|
||||||
assert user is not None
|
assert user is not None
|
||||||
|
|
@ -375,10 +317,7 @@ async def verify_telegram(
|
||||||
_: Annotated[None, HtmxGuard],
|
_: Annotated[None, HtmxGuard],
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
"""Mark the user's Telegram identity as verified (admin review)."""
|
"""Mark the user's Telegram identity as verified (admin review)."""
|
||||||
await session.execute(
|
await UserRepository(session).verify_telegram(user_id)
|
||||||
text("UPDATE users SET telegram_verified = TRUE WHERE id = :u"),
|
|
||||||
{"u": user_id},
|
|
||||||
)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
user = await _fetch_user(session, user_id)
|
user = await _fetch_user(session, user_id)
|
||||||
assert user is not None
|
assert user is not None
|
||||||
|
|
@ -401,10 +340,7 @@ async def adjust_credits(
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
"""Bump credits by ``delta`` (clamped at 0). HTMX button target."""
|
"""Bump credits by ``delta`` (clamped at 0). HTMX button target."""
|
||||||
if delta != 0:
|
if delta != 0:
|
||||||
await session.execute(
|
await UserRepository(session).adjust_credits(user_id, int(delta))
|
||||||
text("UPDATE users SET credits_left = greatest(0, credits_left + :d) WHERE id = :u"),
|
|
||||||
{"d": int(delta), "u": user_id},
|
|
||||||
)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
user = await _fetch_user(session, user_id)
|
user = await _fetch_user(session, user_id)
|
||||||
assert user is not None
|
assert user is not None
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from fastapi import FastAPI
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
from ..core.config import get_settings
|
from ..core.config import get_settings
|
||||||
|
from ..core.db.repositories import UserRepository
|
||||||
from ..core.db.session import dispose_engine
|
from ..core.db.session import dispose_engine
|
||||||
from ..core.llm import port as llm_port # noqa: F401 — package loaded
|
from ..core.llm import port as llm_port # noqa: F401 — package loaded
|
||||||
from ..core.logging import bind_context, configure_logging, get_logger
|
from ..core.logging import bind_context, configure_logging, get_logger
|
||||||
|
|
@ -140,8 +141,6 @@ async def _ensure_default_admin(settings: Any, redis_client: Any) -> None:
|
||||||
Uses a one-off DB session because the FastAPI dependency chain is not
|
Uses a one-off DB session because the FastAPI dependency chain is not
|
||||||
available during lifespan setup. Requires web auth (Redis) to be available.
|
available during lifespan setup. Requires web auth (Redis) to be available.
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
from ..core.db.session import create_session_factory
|
from ..core.db.session import create_session_factory
|
||||||
from ..core.security.passwords import hash_password
|
from ..core.security.passwords import hash_password
|
||||||
|
|
||||||
|
|
@ -152,21 +151,16 @@ async def _ensure_default_admin(settings: Any, redis_client: Any) -> None:
|
||||||
|
|
||||||
factory = create_session_factory()
|
factory = create_session_factory()
|
||||||
async with factory() as session:
|
async with factory() as session:
|
||||||
existing = await session.execute(
|
repo = UserRepository(session)
|
||||||
text("SELECT id FROM users WHERE email = :e"),
|
existing = await repo.get_by_email(settings.admin_default_email.lower().strip())
|
||||||
{"e": settings.admin_default_email.lower().strip()},
|
if existing is None:
|
||||||
)
|
|
||||||
if existing.first() is None:
|
|
||||||
user = await create_email_user(
|
user = await create_email_user(
|
||||||
session,
|
session,
|
||||||
email=settings.admin_default_email.lower().strip(),
|
email=settings.admin_default_email.lower().strip(),
|
||||||
name="Administrator",
|
name="Administrator",
|
||||||
password_hash=hash_password(settings.admin_default_password),
|
password_hash=hash_password(settings.admin_default_password),
|
||||||
)
|
)
|
||||||
await session.execute(
|
await repo.set_role(user.id, settings.admin_required_role)
|
||||||
text("UPDATE users SET role = :r WHERE id = :u"),
|
|
||||||
{"r": settings.admin_required_role, "u": user.id},
|
|
||||||
)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info(
|
logger.info(
|
||||||
"default_admin_created",
|
"default_admin_created",
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,12 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime as dt
|
|
||||||
import json
|
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import Depends, Header, HTTPException, Request
|
from fastapi import Depends, Header, HTTPException, Request
|
||||||
from sqlalchemy import select, text
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..core.api_keys import hash_api_key
|
from ..core.api_keys import hash_api_key
|
||||||
|
|
@ -17,6 +15,13 @@ from ..core.auth import AuthError, TokenExpiredError, TokenInvalidError, verify_
|
||||||
from ..core.auth_refresh import RefreshTokenStore
|
from ..core.auth_refresh import RefreshTokenStore
|
||||||
from ..core.config import get_settings
|
from ..core.config import get_settings
|
||||||
from ..core.db.models import PasskeyCredential, User
|
from ..core.db.models import PasskeyCredential, User
|
||||||
|
from ..core.db.repositories import (
|
||||||
|
ApiKeyRepository,
|
||||||
|
DocumentRepository,
|
||||||
|
PasskeyRepository,
|
||||||
|
UserRepository,
|
||||||
|
)
|
||||||
|
from ..core.db.repositories.service_tokens import ServiceTokenRepository
|
||||||
from ..core.db.session import get_session
|
from ..core.db.session import get_session
|
||||||
from ..core.logging import get_logger
|
from ..core.logging import get_logger
|
||||||
from ..core.mq.publisher import Publisher
|
from ..core.mq.publisher import Publisher
|
||||||
|
|
@ -118,18 +123,12 @@ async def require_service_token(
|
||||||
|
|
||||||
raw = authorization[7:].strip()
|
raw = authorization[7:].strip()
|
||||||
token_hash = hash_token(raw)
|
token_hash = hash_token(raw)
|
||||||
result = await session.execute(
|
repo = ServiceTokenRepository(session)
|
||||||
text("SELECT id FROM service_tokens WHERE token_hash = :h AND revoked = FALSE"),
|
token_id = await repo.get_id_by_hash(token_hash)
|
||||||
{"h": token_hash},
|
if token_id is None:
|
||||||
)
|
|
||||||
row = result.first()
|
|
||||||
if row is None:
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid or revoked token")
|
raise HTTPException(status_code=401, detail="Invalid or revoked token")
|
||||||
|
|
||||||
await session.execute(
|
await repo.bump_last_used(token_id)
|
||||||
text("UPDATE service_tokens SET last_used_at = now() WHERE id = :id"),
|
|
||||||
{"id": row[0]},
|
|
||||||
)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -148,72 +147,29 @@ async def get_or_create_user_for_telegram(
|
||||||
On creation stores the profile snapshot, verification flag, and binding time.
|
On creation stores the profile snapshot, verification flag, and binding time.
|
||||||
On existing row optionally refreshes the profile snapshot.
|
On existing row optionally refreshes the profile snapshot.
|
||||||
"""
|
"""
|
||||||
result = await session.execute(
|
repo = UserRepository(session)
|
||||||
text(
|
user = await repo.get_by_telegram_id(telegram_id)
|
||||||
"SELECT id, telegram_id, email, password_hash, is_active, "
|
if user is not None:
|
||||||
"created_at, credits_left, telegram_verified "
|
|
||||||
"FROM users WHERE telegram_id = :t"
|
|
||||||
),
|
|
||||||
{"t": telegram_id},
|
|
||||||
)
|
|
||||||
row = result.first()
|
|
||||||
if row:
|
|
||||||
# Refresh profile snapshot on every /start so admin sees current data.
|
|
||||||
if profile:
|
if profile:
|
||||||
await session.execute(
|
await repo.update_telegram_profile(user.id, profile=profile, verified=verified)
|
||||||
text(
|
|
||||||
"UPDATE users SET telegram_profile_json = :p, telegram_verified = :v "
|
|
||||||
"WHERE id = :u"
|
|
||||||
),
|
|
||||||
{"p": json.dumps(profile, ensure_ascii=False), "v": verified, "u": row[0]},
|
|
||||||
)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return User(
|
user.telegram_verified = verified
|
||||||
id=row[0],
|
return user
|
||||||
telegram_id=row[1],
|
|
||||||
email=row[2],
|
|
||||||
password_hash=row[3],
|
|
||||||
is_active=row[4],
|
|
||||||
created_at=row[5],
|
|
||||||
credits_left=row[6],
|
|
||||||
telegram_verified=verified if profile else row[7],
|
|
||||||
)
|
|
||||||
|
|
||||||
bound_at = dt.datetime.now(tz=dt.UTC)
|
return await repo.create_telegram_user(telegram_id, profile=profile, verified=verified)
|
||||||
profile_json = json.dumps(profile, ensure_ascii=False) if profile else None
|
|
||||||
insert = await session.execute(
|
|
||||||
text(
|
|
||||||
"INSERT INTO users (telegram_id, credits_left, telegram_profile_json, "
|
|
||||||
"telegram_verified, telegram_bound_at) "
|
|
||||||
"VALUES (:t, 0, :p, :v, :b) "
|
|
||||||
"RETURNING id, telegram_id, created_at, credits_left, is_active, "
|
|
||||||
"telegram_verified"
|
|
||||||
),
|
|
||||||
{"t": telegram_id, "p": profile_json, "v": verified, "b": bound_at},
|
|
||||||
)
|
|
||||||
new = insert.first()
|
|
||||||
assert new is not None
|
|
||||||
await session.commit()
|
|
||||||
return User(
|
|
||||||
id=new[0],
|
|
||||||
telegram_id=new[1],
|
|
||||||
created_at=new[2],
|
|
||||||
credits_left=new[3],
|
|
||||||
is_active=new[4],
|
|
||||||
telegram_verified=new[5],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_or_create_user_by_id(session: AsyncSession, user_id: UUID) -> User | None:
|
async def get_or_create_user_by_id(session: AsyncSession, user_id: UUID) -> User | None:
|
||||||
"""Fetch an existing user by UUID. Returns None if not found."""
|
"""Fetch an existing user by UUID. Returns None if not found."""
|
||||||
result = await session.execute(
|
user = await UserRepository(session).get_by_id(user_id)
|
||||||
text("SELECT id, telegram_id, created_at, credits_left FROM users WHERE id = :u"),
|
if user is None:
|
||||||
{"u": user_id},
|
|
||||||
)
|
|
||||||
row = result.first()
|
|
||||||
if row is None:
|
|
||||||
return None
|
return None
|
||||||
return User(id=row[0], telegram_id=row[1], created_at=row[2], credits_left=row[3])
|
return User(
|
||||||
|
id=user.id,
|
||||||
|
telegram_id=user.telegram_id,
|
||||||
|
created_at=user.created_at,
|
||||||
|
credits_left=user.credits_left,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def bind_telegram_to_user(
|
async def bind_telegram_to_user(
|
||||||
|
|
@ -225,64 +181,31 @@ async def bind_telegram_to_user(
|
||||||
|
|
||||||
Raises HTTPException 409 if the telegram_id is already bound to another user.
|
Raises HTTPException 409 if the telegram_id is already bound to another user.
|
||||||
"""
|
"""
|
||||||
dup = await session.execute(
|
repo = UserRepository(session)
|
||||||
text("SELECT id FROM users WHERE telegram_id = :t AND id != :u"),
|
if await repo.telegram_id_exists_for_other_user(telegram_id, user_id):
|
||||||
{"t": telegram_id, "u": user_id},
|
|
||||||
)
|
|
||||||
if dup.first() is not None:
|
|
||||||
raise HTTPException(status_code=409, detail="Telegram id already bound to another account")
|
raise HTTPException(status_code=409, detail="Telegram id already bound to another account")
|
||||||
|
|
||||||
await session.execute(
|
await repo.bind_telegram(user_id, telegram_id)
|
||||||
text("UPDATE users SET telegram_id = :t, telegram_bound_at = now() WHERE id = :u"),
|
|
||||||
{"t": telegram_id, "u": user_id},
|
|
||||||
)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
async def set_user_password(session: AsyncSession, user_id: UUID, password: str) -> None:
|
async def set_user_password(session: AsyncSession, user_id: UUID, password: str) -> None:
|
||||||
"""Set/rotate a web password for a user (Telegram -> web UI access)."""
|
"""Set/rotate a web password for a user (Telegram -> web UI access)."""
|
||||||
hashed = hash_password(password)
|
hashed = hash_password(password)
|
||||||
await session.execute(
|
await UserRepository(session).set_password(user_id, hashed)
|
||||||
text("UPDATE users SET password_hash = :p WHERE id = :u"),
|
|
||||||
{"p": hashed, "u": user_id},
|
|
||||||
)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
async def fetch_user_by_email(session: AsyncSession, email: str) -> User | None:
|
async def fetch_user_by_email(session: AsyncSession, email: str) -> User | None:
|
||||||
"""Fetch a user by email (case-sensitive — normalize upstream). Returns None if not found."""
|
"""Fetch a user by email (case-sensitive — normalize upstream). Returns None if not found."""
|
||||||
result = await session.execute(
|
return await UserRepository(session).get_by_email(email)
|
||||||
text(
|
|
||||||
"SELECT id, telegram_id, email, name, password_hash, is_active, created_at, credits_left "
|
|
||||||
"FROM users WHERE email = :e"
|
|
||||||
),
|
|
||||||
{"e": email},
|
|
||||||
)
|
|
||||||
row = result.first()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return User(
|
|
||||||
id=row[0],
|
|
||||||
telegram_id=row[1],
|
|
||||||
email=row[2],
|
|
||||||
name=row[3],
|
|
||||||
password_hash=row[4],
|
|
||||||
is_active=row[5],
|
|
||||||
created_at=row[6],
|
|
||||||
credits_left=row[7],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_passkey_credentials_for_user(
|
async def fetch_passkey_credentials_for_user(
|
||||||
session: AsyncSession, user_id: UUID
|
session: AsyncSession, user_id: UUID
|
||||||
) -> list[PasskeyCredential]:
|
) -> list[PasskeyCredential]:
|
||||||
"""All passkey credentials of a user, newest first."""
|
"""All passkey credentials of a user, newest first."""
|
||||||
result = await session.execute(
|
return await PasskeyRepository(session).list_for_user(user_id)
|
||||||
select(PasskeyCredential)
|
|
||||||
.where(PasskeyCredential.user_id == user_id)
|
|
||||||
.order_by(PasskeyCredential.created_at.desc())
|
|
||||||
)
|
|
||||||
return list(result.scalars().all())
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_passkey_by_credential_id(
|
async def fetch_passkey_by_credential_id(
|
||||||
|
|
@ -297,52 +220,15 @@ async def fetch_passkey_by_credential_id(
|
||||||
|
|
||||||
async def fetch_user_by_id_full(session: AsyncSession, user_id: UUID) -> User | None:
|
async def fetch_user_by_id_full(session: AsyncSession, user_id: UUID) -> User | None:
|
||||||
"""Fetch a user by UUID including web-auth columns."""
|
"""Fetch a user by UUID including web-auth columns."""
|
||||||
result = await session.execute(
|
return await UserRepository(session).get_by_id(user_id)
|
||||||
text(
|
|
||||||
"SELECT id, telegram_id, email, name, password_hash, is_active, created_at, credits_left "
|
|
||||||
"FROM users WHERE id = :u"
|
|
||||||
),
|
|
||||||
{"u": user_id},
|
|
||||||
)
|
|
||||||
row = result.first()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return User(
|
|
||||||
id=row[0],
|
|
||||||
telegram_id=row[1],
|
|
||||||
email=row[2],
|
|
||||||
name=row[3],
|
|
||||||
password_hash=row[4],
|
|
||||||
is_active=row[5],
|
|
||||||
created_at=row[6],
|
|
||||||
credits_left=row[7],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def create_email_user(
|
async def create_email_user(
|
||||||
session: AsyncSession, *, email: str, name: str | None, password_hash: str
|
session: AsyncSession, *, email: str, name: str | None, password_hash: str
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Insert a new email/password user with 0 credits and return it."""
|
"""Insert a new email/password user with 0 credits and return it."""
|
||||||
result = await session.execute(
|
return await UserRepository(session).create_email_user(
|
||||||
text(
|
email=email, name=name, password_hash=password_hash
|
||||||
"INSERT INTO users (email, name, password_hash, credits_left) "
|
|
||||||
"VALUES (:e, :n, :p, 0) "
|
|
||||||
"RETURNING id, telegram_id, email, name, password_hash, is_active, created_at, credits_left"
|
|
||||||
),
|
|
||||||
{"e": email, "n": name, "p": password_hash},
|
|
||||||
)
|
|
||||||
row = result.first()
|
|
||||||
assert row is not None
|
|
||||||
await session.commit()
|
|
||||||
return User(
|
|
||||||
id=row[0],
|
|
||||||
telegram_id=row[1],
|
|
||||||
email=row[2],
|
|
||||||
name=row[3],
|
|
||||||
password_hash=row[4],
|
|
||||||
is_active=row[5],
|
|
||||||
created_at=row[6],
|
|
||||||
credits_left=row[7],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -364,18 +250,6 @@ class ApiKeyAuth:
|
||||||
self.rate_limit_rps = rate_limit_rps
|
self.rate_limit_rps = rate_limit_rps
|
||||||
|
|
||||||
|
|
||||||
async def _maybe_reset_monthly_quota(session: AsyncSession, api_key_id: UUID) -> None:
|
|
||||||
"""Reset monthly_used/resets_at if the quota window has expired."""
|
|
||||||
await session.execute(
|
|
||||||
text(
|
|
||||||
"UPDATE api_keys "
|
|
||||||
"SET monthly_used = 0, resets_at = now() + interval '1 month' "
|
|
||||||
"WHERE id = :k AND resets_at < now()"
|
|
||||||
),
|
|
||||||
{"k": api_key_id},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def require_api_key(
|
async def require_api_key(
|
||||||
session: AsyncSessionDep,
|
session: AsyncSessionDep,
|
||||||
rate_limiter: RateLimiterDep,
|
rate_limiter: RateLimiterDep,
|
||||||
|
|
@ -390,31 +264,24 @@ async def require_api_key(
|
||||||
raise HTTPException(status_code=401, detail="Missing X-API-Key header")
|
raise HTTPException(status_code=401, detail="Missing X-API-Key header")
|
||||||
|
|
||||||
key_hash = hash_api_key(x_api_key)
|
key_hash = hash_api_key(x_api_key)
|
||||||
result = await session.execute(
|
repo = ApiKeyRepository(session)
|
||||||
text(
|
key = await repo.get_by_hash(key_hash)
|
||||||
"SELECT id, user_id, rate_limit_rps, monthly_quota, monthly_used, revoked "
|
if key is None:
|
||||||
"FROM api_keys "
|
|
||||||
"WHERE key_hash = :h"
|
|
||||||
),
|
|
||||||
{"h": key_hash},
|
|
||||||
)
|
|
||||||
row = result.first()
|
|
||||||
if row is None:
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||||
|
|
||||||
api_key_id, user_id, rate_limit_rps, monthly_quota, monthly_used, revoked = row
|
if key.revoked:
|
||||||
if revoked:
|
|
||||||
raise HTTPException(status_code=401, detail="Revoked API key")
|
raise HTTPException(status_code=401, detail="Revoked API key")
|
||||||
|
|
||||||
await _maybe_reset_monthly_quota(session, api_key_id)
|
await repo.maybe_reset_monthly_quota(key.id)
|
||||||
|
|
||||||
# Check monthly quota (if configured) after potential reset.
|
# Check monthly quota (if configured) after potential reset.
|
||||||
|
monthly_quota, monthly_used = await repo.get_monthly_quota_state(key.id)
|
||||||
if monthly_quota is not None and int(monthly_used) >= int(monthly_quota):
|
if monthly_quota is not None and int(monthly_used) >= int(monthly_quota):
|
||||||
raise HTTPException(status_code=429, detail="Monthly quota exceeded")
|
raise HTTPException(status_code=429, detail="Monthly quota exceeded")
|
||||||
|
|
||||||
# Apply token-bucket rate limit per key id.
|
# Apply token-bucket rate limit per key id.
|
||||||
limit = int(rate_limit_rps or get_settings().b2b_default_rate_limit_rps)
|
limit = int(key.rate_limit_rps or get_settings().b2b_default_rate_limit_rps)
|
||||||
rl_result = await rate_limiter.allow(f"rate_limit:{api_key_id}", limit)
|
rl_result = await rate_limiter.allow(f"rate_limit:{key.id}", limit)
|
||||||
if not rl_result.allowed:
|
if not rl_result.allowed:
|
||||||
retry_after = max(1, int(rl_result.retry_after_sec or 1))
|
retry_after = max(1, int(rl_result.retry_after_sec or 1))
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -423,13 +290,10 @@ async def require_api_key(
|
||||||
headers={"Retry-After": str(retry_after)},
|
headers={"Retry-After": str(retry_after)},
|
||||||
)
|
)
|
||||||
|
|
||||||
await session.execute(
|
await repo.bump_last_used(key.id)
|
||||||
text("UPDATE api_keys SET last_used_at = now() WHERE id = :id"),
|
|
||||||
{"id": api_key_id},
|
|
||||||
)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
return ApiKeyAuth(api_key_id=api_key_id, user_id=user_id, rate_limit_rps=limit)
|
return ApiKeyAuth(api_key_id=key.id, user_id=key.user_id, rate_limit_rps=limit)
|
||||||
|
|
||||||
|
|
||||||
ApiKeyAuthDep = Annotated[ApiKeyAuth, Depends(require_api_key)]
|
ApiKeyAuthDep = Annotated[ApiKeyAuth, Depends(require_api_key)]
|
||||||
|
|
@ -437,34 +301,23 @@ ApiKeyAuthDep = Annotated[ApiKeyAuth, Depends(require_api_key)]
|
||||||
|
|
||||||
async def fetch_document_status_for_user(
|
async def fetch_document_status_for_user(
|
||||||
session: AsyncSession, document_id: UUID, user_id: UUID
|
session: AsyncSession, document_id: UUID, user_id: UUID
|
||||||
) -> dict[str, object] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Fetch document + report scoped to a specific user (B2B API)."""
|
"""Fetch document + report scoped to a specific user (B2B API)."""
|
||||||
result = await session.execute(
|
status = await DocumentRepository(session).get_with_report_by_id_for_user(document_id, user_id)
|
||||||
text(
|
if status is None:
|
||||||
"SELECT d.id, d.status, d.stage, d.filename, d.created_at, "
|
|
||||||
" r.markdown, r.content_json, r.model_used, "
|
|
||||||
" r.prompt_tokens, r.eval_tokens, r.latency_ms "
|
|
||||||
"FROM documents d "
|
|
||||||
"LEFT JOIN reports r ON r.document_id = d.id "
|
|
||||||
"WHERE d.id = :d AND d.user_id = :u"
|
|
||||||
),
|
|
||||||
{"d": document_id, "u": user_id},
|
|
||||||
)
|
|
||||||
row = result.first()
|
|
||||||
if row is None:
|
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
"id": row[0],
|
"id": status.id,
|
||||||
"status": row[1],
|
"status": status.status,
|
||||||
"stage": row[2],
|
"stage": status.stage,
|
||||||
"filename": row[3],
|
"filename": status.filename,
|
||||||
"created_at": row[4],
|
"created_at": status.created_at,
|
||||||
"markdown": row[5],
|
"markdown": status.markdown,
|
||||||
"content_json": row[6],
|
"content_json": status.content_json,
|
||||||
"model_used": row[7],
|
"model_used": status.model_used,
|
||||||
"prompt_tokens": row[8],
|
"prompt_tokens": status.prompt_tokens,
|
||||||
"eval_tokens": row[9],
|
"eval_tokens": status.eval_tokens,
|
||||||
"latency_ms": row[10],
|
"latency_ms": status.latency_ms,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -499,11 +352,7 @@ async def require_current_user(
|
||||||
|
|
||||||
# Ensure the user still exists (defense in depth: tokens are stateless,
|
# Ensure the user still exists (defense in depth: tokens are stateless,
|
||||||
# but a deleted user should not be able to use them).
|
# but a deleted user should not be able to use them).
|
||||||
result = await session.execute(
|
if not await UserRepository(session).exists(claims.sub):
|
||||||
text("SELECT id FROM users WHERE id = :u"),
|
|
||||||
{"u": claims.sub},
|
|
||||||
)
|
|
||||||
if result.first() is None:
|
|
||||||
raise HTTPException(status_code=401, detail="User not found")
|
raise HTTPException(status_code=401, detail="User not found")
|
||||||
|
|
||||||
return CurrentUser(user_id=claims.sub, telegram_id=claims.telegram_id)
|
return CurrentUser(user_id=claims.sub, telegram_id=claims.telegram_id)
|
||||||
|
|
@ -513,11 +362,5 @@ CurrentUserDep = Annotated[CurrentUser, Depends(require_current_user)]
|
||||||
|
|
||||||
|
|
||||||
async def get_credits(session: AsyncSession, user_id: UUID) -> int:
|
async def get_credits(session: AsyncSession, user_id: UUID) -> int:
|
||||||
result = await session.execute(
|
credits = await UserRepository(session).get_credits(user_id)
|
||||||
text("SELECT credits_left FROM users WHERE id = :u"),
|
return credits if credits is not None else 0
|
||||||
{"u": user_id},
|
|
||||||
)
|
|
||||||
row = result.first()
|
|
||||||
if row is None:
|
|
||||||
return 0
|
|
||||||
return int(row[0])
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
13
src/contract_check/api/routes/auth/__init__.py
Normal file
13
src/contract_check/api/routes/auth/__init__.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
"""Authentication routes package: composes all auth sub-routers under /api/v1/auth."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from . import magic_links, passkeys, password, telegram
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||||
|
router.include_router(telegram.router)
|
||||||
|
router.include_router(password.router)
|
||||||
|
router.include_router(passkeys.router)
|
||||||
|
router.include_router(magic_links.router)
|
||||||
153
src/contract_check/api/routes/auth/magic_links.py
Normal file
153
src/contract_check/api/routes/auth/magic_links.py
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
|
||||||
|
from ....core.config import get_settings
|
||||||
|
from ....core.db.repositories import UserRepository
|
||||||
|
from ....core.logging import get_logger
|
||||||
|
from ....core.mq.messages import NotificationMessage
|
||||||
|
from ...deps import (
|
||||||
|
AsyncSessionDep,
|
||||||
|
NotificationPublisherDep,
|
||||||
|
fetch_user_by_email,
|
||||||
|
fetch_user_by_id_full,
|
||||||
|
)
|
||||||
|
from ...schemas import (
|
||||||
|
MagicLinkRequest,
|
||||||
|
MagicLinkResponse,
|
||||||
|
MagicLinkVerifyRequest,
|
||||||
|
SingleTokenAuthResponse,
|
||||||
|
)
|
||||||
|
from .support import (
|
||||||
|
_build_magic_link,
|
||||||
|
_hash_reset_token,
|
||||||
|
_issue_single_token,
|
||||||
|
_require_magic_link_enabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
router = APIRouter(tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/magic-link/request",
|
||||||
|
response_model=MagicLinkResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def magic_link_request(
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
publisher: NotificationPublisherDep,
|
||||||
|
body: MagicLinkRequest,
|
||||||
|
) -> MagicLinkResponse:
|
||||||
|
"""Generate a one-time login token, store its hash, and enqueue a notification.
|
||||||
|
|
||||||
|
Always returns the same 200 payload regardless of whether the email
|
||||||
|
exists, to avoid leaking which addresses are registered (mirrors
|
||||||
|
forgot-password).
|
||||||
|
"""
|
||||||
|
_require_magic_link_enabled()
|
||||||
|
email_normalized = body.email.lower().strip()
|
||||||
|
settings = get_settings()
|
||||||
|
generic = MagicLinkResponse(
|
||||||
|
success=True,
|
||||||
|
message="if the email exists, a magic link was sent",
|
||||||
|
expires_in=settings.magic_link_ttl_minutes * 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
user = await fetch_user_by_email(session, email_normalized)
|
||||||
|
if user is None:
|
||||||
|
log.info("magic_link_unknown_email", email=email_normalized)
|
||||||
|
return generic
|
||||||
|
|
||||||
|
raw_token = secrets.token_urlsafe(32)
|
||||||
|
expires_at = dt.datetime.now(tz=dt.UTC) + dt.timedelta(minutes=settings.magic_link_ttl_minutes)
|
||||||
|
|
||||||
|
await UserRepository(session).set_magic_link_token(
|
||||||
|
user.id,
|
||||||
|
token_hash=_hash_reset_token(raw_token),
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
link = _build_magic_link(raw_token)
|
||||||
|
notification = NotificationMessage(
|
||||||
|
correlation_id=uuid.uuid4(),
|
||||||
|
kind="magic_link",
|
||||||
|
to=email_normalized,
|
||||||
|
subject="Вход по ссылке — Контракт-чек",
|
||||||
|
body_text=(
|
||||||
|
"Вы запросили вход по ссылке.\n\n"
|
||||||
|
f"Перейдите по ссылке, чтобы войти (действует "
|
||||||
|
f"{settings.magic_link_ttl_minutes} мин.):\n{link}\n\n"
|
||||||
|
"Ссылка одноразовая. Если вы не запрашивали вход — просто "
|
||||||
|
"проигнорируйте это письмо."
|
||||||
|
),
|
||||||
|
body_html=(
|
||||||
|
"<p>Вы запросили вход по ссылке.</p>"
|
||||||
|
f'<p><a href="{link}">Войти</a> '
|
||||||
|
f"(действует {settings.magic_link_ttl_minutes} мин., ссылка одноразовая)</p>"
|
||||||
|
"<p>Если вы не запрашивали вход — проигнорируйте это письмо.</p>"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await publisher.publish(notification, routing_key="notify")
|
||||||
|
except Exception as exc: # noqa: BLE001 — best-effort; token is still storable
|
||||||
|
log.error(
|
||||||
|
"magic_link_publish_failed",
|
||||||
|
user_id=str(user.id),
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info("magic_link_enqueued", user_id=str(user.id))
|
||||||
|
return generic
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/magic-link/verify",
|
||||||
|
response_model=SingleTokenAuthResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def magic_link_verify(
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
body: MagicLinkVerifyRequest,
|
||||||
|
) -> SingleTokenAuthResponse:
|
||||||
|
"""Exchange a magic-link token for an access JWT.
|
||||||
|
|
||||||
|
On success the stored token hash is cleared, so every link works exactly
|
||||||
|
once. Expired tokens are also cleared (they can never be replayed).
|
||||||
|
"""
|
||||||
|
_require_magic_link_enabled()
|
||||||
|
token_hash = _hash_reset_token(body.token)
|
||||||
|
|
||||||
|
users = UserRepository(session)
|
||||||
|
token_user = await users.get_by_magic_link_token_hash(token_hash)
|
||||||
|
if token_user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid magic-link token"
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id = token_user.id
|
||||||
|
expires_at = token_user.magic_link_expires_at
|
||||||
|
now = dt.datetime.now(tz=dt.UTC)
|
||||||
|
if expires_at is None or expires_at < now:
|
||||||
|
await users.consume_magic_link_token(user_id)
|
||||||
|
await session.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="magic-link token expired"
|
||||||
|
)
|
||||||
|
|
||||||
|
await users.consume_magic_link_token(user_id)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
user = await fetch_user_by_id_full(session, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account disabled")
|
||||||
|
|
||||||
|
log.info("user_logged_in_magic_link", user_id=str(user.id))
|
||||||
|
return _issue_single_token(user)
|
||||||
260
src/contract_check/api/routes/auth/passkeys.py
Normal file
260
src/contract_check/api/routes/auth/passkeys.py
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from webauthn import verify_authentication_response, verify_registration_response
|
||||||
|
from webauthn.helpers import base64url_to_bytes, bytes_to_base64url
|
||||||
|
from webauthn.helpers.exceptions import InvalidAuthenticationResponse, InvalidRegistrationResponse
|
||||||
|
|
||||||
|
from ....core.config import get_settings
|
||||||
|
from ....core.db.models import PasskeyCredential
|
||||||
|
from ....core.db.repositories import PasskeyRepository
|
||||||
|
from ....core.logging import get_logger
|
||||||
|
from ....core.passkeys import build_authentication_options, build_registration_options
|
||||||
|
from ...deps import (
|
||||||
|
AsyncSessionDep,
|
||||||
|
CurrentUserDep,
|
||||||
|
PasskeyChallengeStoreDep,
|
||||||
|
fetch_passkey_by_credential_id,
|
||||||
|
fetch_passkey_credentials_for_user,
|
||||||
|
fetch_user_by_id_full,
|
||||||
|
)
|
||||||
|
from ...schemas import (
|
||||||
|
OkResponse,
|
||||||
|
PasskeyAuthenticateFinishRequest,
|
||||||
|
PasskeyAuthenticationOptions,
|
||||||
|
PasskeyCredentialPublic,
|
||||||
|
PasskeyRegisterFinishRequest,
|
||||||
|
PasskeyRegisterStartRequest,
|
||||||
|
PasskeyRegistrationFinishResponse,
|
||||||
|
PasskeyRegistrationOptions,
|
||||||
|
SingleTokenAuthResponse,
|
||||||
|
)
|
||||||
|
from .support import _issue_single_token, _require_passkey_enabled
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
router = APIRouter(tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/passkeys/register/start",
|
||||||
|
response_model=PasskeyRegistrationOptions,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def passkeys_register_start(
|
||||||
|
user: CurrentUserDep,
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
challenges: PasskeyChallengeStoreDep,
|
||||||
|
body: PasskeyRegisterStartRequest | None = None,
|
||||||
|
) -> PasskeyRegistrationOptions:
|
||||||
|
"""Begin passkey registration for the authenticated user.
|
||||||
|
|
||||||
|
Returns PublicKeyCredentialCreationOptions (resident key + user
|
||||||
|
verification required, existing credentials excluded) and stores the
|
||||||
|
challenge in Redis keyed by the user id.
|
||||||
|
"""
|
||||||
|
_require_passkey_enabled()
|
||||||
|
full_user = await fetch_user_by_id_full(session, user.user_id)
|
||||||
|
if full_user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
|
|
||||||
|
existing = await fetch_passkey_credentials_for_user(session, user.user_id)
|
||||||
|
options, challenge = build_registration_options(
|
||||||
|
settings=get_settings(),
|
||||||
|
user_id=full_user.id,
|
||||||
|
user_name=full_user.email or str(full_user.id),
|
||||||
|
existing_credential_ids=[cred.credential_id for cred in existing],
|
||||||
|
)
|
||||||
|
await challenges.store_registration(
|
||||||
|
user.user_id,
|
||||||
|
challenge=challenge,
|
||||||
|
device_name=body.device_name if body else None,
|
||||||
|
)
|
||||||
|
return PasskeyRegistrationOptions(**options)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/passkeys/register/finish",
|
||||||
|
response_model=PasskeyRegistrationFinishResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def passkeys_register_finish(
|
||||||
|
user: CurrentUserDep,
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
challenges: PasskeyChallengeStoreDep,
|
||||||
|
body: PasskeyRegisterFinishRequest,
|
||||||
|
) -> PasskeyRegistrationFinishResponse:
|
||||||
|
"""Verify the attestation and persist the credential.
|
||||||
|
|
||||||
|
The challenge is consumed from Redis (single use). A credential id
|
||||||
|
already registered on any account is rejected with 409.
|
||||||
|
"""
|
||||||
|
_require_passkey_enabled()
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
stored = await challenges.consume_registration(user.user_id)
|
||||||
|
if stored is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="no pending registration challenge (expired or already used)",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
verified = verify_registration_response(
|
||||||
|
credential=body.credential.model_dump(exclude_none=True),
|
||||||
|
expected_challenge=base64url_to_bytes(stored["challenge"]),
|
||||||
|
expected_origin=settings.passkey_rp_origins,
|
||||||
|
expected_rp_id=settings.passkey_rp_id,
|
||||||
|
require_user_verification=True,
|
||||||
|
)
|
||||||
|
except InvalidRegistrationResponse as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail=f"invalid registration: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
credential_id = bytes_to_base64url(verified.credential_id)
|
||||||
|
duplicate = await fetch_passkey_by_credential_id(session, credential_id)
|
||||||
|
if duplicate is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT, detail="credential already registered"
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
PasskeyCredential(
|
||||||
|
user_id=user.user_id,
|
||||||
|
credential_id=credential_id,
|
||||||
|
public_key=bytes_to_base64url(verified.credential_public_key),
|
||||||
|
sign_count=verified.sign_count,
|
||||||
|
device_name=body.device_name or stored.get("device_name"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
log.info("passkey_registered", user_id=str(user.user_id), credential_id=credential_id)
|
||||||
|
return PasskeyRegistrationFinishResponse(verified=True, credentialId=credential_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/passkeys/authenticate/start",
|
||||||
|
response_model=PasskeyAuthenticationOptions,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def passkeys_authenticate_start(
|
||||||
|
challenges: PasskeyChallengeStoreDep,
|
||||||
|
) -> PasskeyAuthenticationOptions:
|
||||||
|
"""Begin discoverable-credential (passkey) login.
|
||||||
|
|
||||||
|
`allowCredentials` is empty — the authenticator offers its resident keys
|
||||||
|
and the user is resolved on finish from the returned credential id.
|
||||||
|
The challenge is stored in Redis under its own base64url value.
|
||||||
|
"""
|
||||||
|
_require_passkey_enabled()
|
||||||
|
options, challenge = build_authentication_options(settings=get_settings())
|
||||||
|
await challenges.store_authentication(challenge)
|
||||||
|
return PasskeyAuthenticationOptions(**options)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/passkeys/authenticate/finish",
|
||||||
|
response_model=SingleTokenAuthResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def passkeys_authenticate_finish(
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
challenges: PasskeyChallengeStoreDep,
|
||||||
|
body: PasskeyAuthenticateFinishRequest,
|
||||||
|
) -> SingleTokenAuthResponse:
|
||||||
|
"""Verify the assertion and issue an access JWT.
|
||||||
|
|
||||||
|
The echoed challenge must still be pending in Redis (single use); the
|
||||||
|
credential is resolved to its owner, the signature is checked against
|
||||||
|
the stored public key, and the sign counter is rotated.
|
||||||
|
"""
|
||||||
|
_require_passkey_enabled()
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
if not await challenges.consume_authentication(body.challenge):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="unknown or expired challenge",
|
||||||
|
)
|
||||||
|
|
||||||
|
record = await fetch_passkey_by_credential_id(session, body.credential.id)
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unknown credential")
|
||||||
|
|
||||||
|
owner = await fetch_user_by_id_full(session, record.user_id)
|
||||||
|
if owner is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
|
if not owner.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account disabled")
|
||||||
|
|
||||||
|
try:
|
||||||
|
verified = verify_authentication_response(
|
||||||
|
credential=body.credential.model_dump(exclude_none=True),
|
||||||
|
expected_challenge=base64url_to_bytes(body.challenge),
|
||||||
|
expected_origin=settings.passkey_rp_origins,
|
||||||
|
expected_rp_id=settings.passkey_rp_id,
|
||||||
|
credential_public_key=base64url_to_bytes(record.public_key),
|
||||||
|
credential_current_sign_count=record.sign_count,
|
||||||
|
require_user_verification=True,
|
||||||
|
)
|
||||||
|
except InvalidAuthenticationResponse as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail=f"invalid authentication: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
record.sign_count = verified.new_sign_count
|
||||||
|
record.last_used_at = dt.datetime.now(tz=dt.UTC)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
log.info("user_logged_in_passkey", user_id=str(owner.id))
|
||||||
|
return _issue_single_token(owner)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/passkeys",
|
||||||
|
response_model=list[PasskeyCredentialPublic],
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def passkeys_list(
|
||||||
|
user: CurrentUserDep,
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
) -> list[PasskeyCredentialPublic]:
|
||||||
|
"""List the authenticated user's registered passkeys (newest first)."""
|
||||||
|
_require_passkey_enabled()
|
||||||
|
creds = await fetch_passkey_credentials_for_user(session, user.user_id)
|
||||||
|
return [
|
||||||
|
PasskeyCredentialPublic(
|
||||||
|
id=str(cred.id),
|
||||||
|
credentialID=cred.credential_id,
|
||||||
|
credentialPublicKey=cred.public_key,
|
||||||
|
counter=cred.sign_count,
|
||||||
|
userId=str(cred.user_id),
|
||||||
|
deviceName=cred.device_name or "",
|
||||||
|
createdAt=cred.created_at,
|
||||||
|
)
|
||||||
|
for cred in creds
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/passkeys/{passkey_id}",
|
||||||
|
response_model=OkResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def passkeys_delete(
|
||||||
|
user: CurrentUserDep,
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
passkey_id: uuid.UUID,
|
||||||
|
) -> OkResponse:
|
||||||
|
"""Remove one of the user's passkeys. Scoped to the owning user."""
|
||||||
|
_require_passkey_enabled()
|
||||||
|
|
||||||
|
deleted = await PasskeyRepository(session).delete_by_id_for_user(passkey_id, user.user_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="passkey not found")
|
||||||
|
await session.commit()
|
||||||
|
log.info("passkey_deleted", user_id=str(user.user_id), passkey_id=str(passkey_id))
|
||||||
|
return OkResponse(ok=True, detail="passkey deleted")
|
||||||
227
src/contract_check/api/routes/auth/password.py
Normal file
227
src/contract_check/api/routes/auth/password.py
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
|
||||||
|
from ....core.auth import AuthError, verify_refresh_token
|
||||||
|
from ....core.config import get_settings
|
||||||
|
from ....core.db.repositories import UserRepository
|
||||||
|
from ....core.logging import get_logger
|
||||||
|
from ....core.mq.messages import NotificationMessage
|
||||||
|
from ....core.security.passwords import hash_password, verify_password
|
||||||
|
from ...deps import (
|
||||||
|
AsyncSessionDep,
|
||||||
|
NotificationPublisherDep,
|
||||||
|
RefreshStoreDep,
|
||||||
|
create_email_user,
|
||||||
|
fetch_user_by_email,
|
||||||
|
)
|
||||||
|
from ...schemas import (
|
||||||
|
ForgotPasswordRequest,
|
||||||
|
LoginRequest,
|
||||||
|
LogoutRequest,
|
||||||
|
OkResponse,
|
||||||
|
RegisterRequest,
|
||||||
|
ResetPasswordRequest,
|
||||||
|
TokenPairResponse,
|
||||||
|
)
|
||||||
|
from .support import (
|
||||||
|
_build_reset_link,
|
||||||
|
_hash_reset_token,
|
||||||
|
_issue_pair,
|
||||||
|
_require_web_auth_enabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
router = APIRouter(tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/register",
|
||||||
|
response_model=TokenPairResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def register(
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
refresh_store: RefreshStoreDep,
|
||||||
|
body: RegisterRequest,
|
||||||
|
) -> TokenPairResponse:
|
||||||
|
"""Register a new email/password user and issue a JWT pair."""
|
||||||
|
_require_web_auth_enabled()
|
||||||
|
email_normalized = body.email.lower().strip()
|
||||||
|
|
||||||
|
existing = await fetch_user_by_email(session, email_normalized)
|
||||||
|
if existing is not None:
|
||||||
|
# Do not leak which emails are registered.
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="email already registered")
|
||||||
|
|
||||||
|
hashed = hash_password(body.password)
|
||||||
|
user = await create_email_user(
|
||||||
|
session, email=email_normalized, name=body.name.strip(), password_hash=hashed
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
log.info("user_registered", user_id=str(user.id), email=email_normalized)
|
||||||
|
return await _issue_pair(user, refresh_store)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/login",
|
||||||
|
response_model=TokenPairResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def login(
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
refresh_store: RefreshStoreDep,
|
||||||
|
body: LoginRequest,
|
||||||
|
) -> TokenPairResponse:
|
||||||
|
"""Email + password -> JWT pair."""
|
||||||
|
_require_web_auth_enabled()
|
||||||
|
email_normalized = body.email.lower().strip()
|
||||||
|
|
||||||
|
user = await fetch_user_by_email(session, email_normalized)
|
||||||
|
if user is None or not user.password_hash:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials")
|
||||||
|
if not verify_password(body.password, user.password_hash):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials")
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account disabled")
|
||||||
|
|
||||||
|
log.info("user_logged_in", user_id=str(user.id))
|
||||||
|
return await _issue_pair(user, refresh_store)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/logout",
|
||||||
|
response_model=OkResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def logout(
|
||||||
|
refresh_store: RefreshStoreDep,
|
||||||
|
body: LogoutRequest,
|
||||||
|
) -> OkResponse:
|
||||||
|
"""Revoke the supplied refresh token. Access token stays valid until it expires."""
|
||||||
|
_require_web_auth_enabled()
|
||||||
|
try:
|
||||||
|
claims = verify_refresh_token(body.refresh_token)
|
||||||
|
except AuthError as exc:
|
||||||
|
# Already expired or invalid — nothing to revoke. Return ok for idempotency.
|
||||||
|
log.info("logout_invalid_refresh", error=str(exc))
|
||||||
|
return OkResponse(ok=True, detail="already revoked")
|
||||||
|
removed = await refresh_store.revoke(claims.sub, claims.jti)
|
||||||
|
log.info("user_logged_out", user_id=str(claims.sub), removed=removed)
|
||||||
|
return OkResponse(ok=True, detail="refresh revoked")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/forgot-password",
|
||||||
|
response_model=OkResponse,
|
||||||
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
|
)
|
||||||
|
async def forgot_password(
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
publisher: NotificationPublisherDep,
|
||||||
|
body: ForgotPasswordRequest,
|
||||||
|
) -> OkResponse:
|
||||||
|
"""Generate a reset token, store its hash + expiry, and enqueue a notification.
|
||||||
|
|
||||||
|
Always returns 202 with `ok=true` regardless of whether the email exists,
|
||||||
|
to avoid leaking which addresses are registered.
|
||||||
|
"""
|
||||||
|
_require_web_auth_enabled()
|
||||||
|
email_normalized = body.email.lower().strip()
|
||||||
|
|
||||||
|
user = await fetch_user_by_email(session, email_normalized)
|
||||||
|
if user is None:
|
||||||
|
log.info("forgot_password_unknown_email", email=email_normalized)
|
||||||
|
return OkResponse(ok=True, detail="if the email exists, a reset link was sent")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
raw_token = secrets.token_urlsafe(32)
|
||||||
|
token_hash = _hash_reset_token(raw_token)
|
||||||
|
expires_at = dt.datetime.now(tz=dt.UTC) + dt.timedelta(
|
||||||
|
minutes=settings.password_reset_ttl_minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
await UserRepository(session).set_password_reset_token(
|
||||||
|
user.id,
|
||||||
|
token_hash=token_hash,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
reset_link = _build_reset_link(raw_token)
|
||||||
|
notification = NotificationMessage(
|
||||||
|
correlation_id=uuid.uuid4(),
|
||||||
|
kind="password_reset",
|
||||||
|
to=email_normalized,
|
||||||
|
subject="Восстановление пароля — Контракт-чек",
|
||||||
|
body_text=(
|
||||||
|
"Вы запросили сброс пароля.\n\n"
|
||||||
|
f"Перейдите по ссылке, чтобы задать новый пароль (действует "
|
||||||
|
f"{settings.password_reset_ttl_minutes} мин.):\n{reset_link}\n\n"
|
||||||
|
"Если вы не запрашивали сброс — просто проигнорируйте это письмо."
|
||||||
|
),
|
||||||
|
body_html=(
|
||||||
|
"<p>Вы запросили сброс пароля.</p>"
|
||||||
|
f'<p><a href="{reset_link}">Задать новый пароль</a> '
|
||||||
|
f"(действует {settings.password_reset_ttl_minutes} мин.)</p>"
|
||||||
|
"<p>Если вы не запрашивали сброс — проигнорируйте это письмо.</p>"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await publisher.publish(notification, routing_key="notify")
|
||||||
|
except Exception as exc: # noqa: BLE001 — best-effort; reset is still storable
|
||||||
|
log.error(
|
||||||
|
"forgot_password_publish_failed",
|
||||||
|
user_id=str(user.id),
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info("forgot_password_enqueued", user_id=str(user.id))
|
||||||
|
return OkResponse(ok=True, detail="if the email exists, a reset link was sent")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/reset-password",
|
||||||
|
response_model=OkResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def reset_password(
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
refresh_store: RefreshStoreDep,
|
||||||
|
body: ResetPasswordRequest,
|
||||||
|
) -> OkResponse:
|
||||||
|
"""Verify a reset token and set the new password.
|
||||||
|
|
||||||
|
On success: clears the stored token hash, rotates the password, and revokes
|
||||||
|
all active refresh tokens for the user (forcing re-login everywhere).
|
||||||
|
"""
|
||||||
|
_require_web_auth_enabled()
|
||||||
|
token_hash = _hash_reset_token(body.token)
|
||||||
|
|
||||||
|
users = UserRepository(session)
|
||||||
|
reset_window = await users.get_password_reset_window(token_hash)
|
||||||
|
if reset_window is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid reset token")
|
||||||
|
|
||||||
|
user_id, expires_at = reset_window
|
||||||
|
now = dt.datetime.now(tz=dt.UTC)
|
||||||
|
if expires_at is None or expires_at < now:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="reset token expired")
|
||||||
|
|
||||||
|
new_hash = hash_password(body.password)
|
||||||
|
await users.reset_password(user_id, password_hash=new_hash)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Best-effort revoke of existing sessions; ignore Redis hiccups so the
|
||||||
|
# password itself is still rotated.
|
||||||
|
try:
|
||||||
|
await refresh_store.revoke_all(user_id)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("reset_password_revoke_failed", user_id=str(user_id), error=str(exc))
|
||||||
|
|
||||||
|
log.info("user_password_reset", user_id=str(user_id))
|
||||||
|
return OkResponse(ok=True, detail="password updated")
|
||||||
121
src/contract_check/api/routes/auth/support.py
Normal file
121
src/contract_check/api/routes/auth/support.py
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
"""Shared auth route helpers (token minting, feature gates, link building)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
from ....core.auth import AuthError, create_access_token, create_refresh_token, verify_access_token
|
||||||
|
from ....core.config import get_settings
|
||||||
|
from ....core.db.models import User
|
||||||
|
from ...deps import RefreshStoreDep
|
||||||
|
from ...schemas import (
|
||||||
|
AuthResponse,
|
||||||
|
SingleTokenAuthResponse,
|
||||||
|
TelegramProfile,
|
||||||
|
TokenPairResponse,
|
||||||
|
WebUserPublic,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_token(user: User) -> AuthResponse:
|
||||||
|
token = create_access_token(user.id, user.telegram_id or 0)
|
||||||
|
ttl = get_settings().jwt_access_ttl_minutes * 60
|
||||||
|
return AuthResponse(
|
||||||
|
access_token=token,
|
||||||
|
token_type="bearer",
|
||||||
|
expires_in=ttl,
|
||||||
|
user_id=str(user.id),
|
||||||
|
telegram_id=user.telegram_id or 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _telegram_profile_looks_verified(profile: TelegramProfile | None) -> bool:
|
||||||
|
if profile is None:
|
||||||
|
return False
|
||||||
|
return bool(profile.username or profile.first_name or profile.last_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_access_claims(authorization: str | None) -> Any:
|
||||||
|
"""Shared bearer-extraction used by `me` and any future stateless endpoint."""
|
||||||
|
if not authorization or not authorization.lower().startswith("bearer "):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing bearer token")
|
||||||
|
token = authorization[7:].strip()
|
||||||
|
try:
|
||||||
|
return verify_access_token(token)
|
||||||
|
except AuthError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _web_user_public(user: User) -> WebUserPublic:
|
||||||
|
return WebUserPublic(
|
||||||
|
id=str(user.id),
|
||||||
|
email=user.email,
|
||||||
|
name=user.name,
|
||||||
|
telegram_id=user.telegram_id,
|
||||||
|
credits_left=user.credits_left,
|
||||||
|
is_active=bool(user.is_active),
|
||||||
|
created_at=user.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _issue_pair(user: User, refresh_store: RefreshStoreDep) -> TokenPairResponse:
|
||||||
|
"""Mint an access + refresh pair for a verified user."""
|
||||||
|
settings = get_settings()
|
||||||
|
access = create_access_token(user.id, user.telegram_id or 0)
|
||||||
|
jti = await refresh_store.issue(user.id)
|
||||||
|
refresh = create_refresh_token(user.id, jti)
|
||||||
|
return TokenPairResponse(
|
||||||
|
access_token=access,
|
||||||
|
refresh_token=refresh,
|
||||||
|
token_type="bearer",
|
||||||
|
expires_in=settings.jwt_access_ttl_minutes * 60,
|
||||||
|
user=_web_user_public(user),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_reset_token(token: str) -> str:
|
||||||
|
"""SHA-256 of a reset token — store this, never the raw token."""
|
||||||
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_reset_link(token: str) -> str:
|
||||||
|
base = get_settings().web_app_base_url.rstrip("/")
|
||||||
|
return f"{base}/reset-password?token={token}"
|
||||||
|
|
||||||
|
|
||||||
|
def _require_web_auth_enabled() -> None:
|
||||||
|
"""Gate webUI endpoints behind a feature flag (WEB_AUTH_ENABLED)."""
|
||||||
|
if not get_settings().web_auth_enabled:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="web auth disabled")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_passkey_enabled() -> None:
|
||||||
|
"""Gate passkey endpoints behind a feature flag (PASSKEY_ENABLED)."""
|
||||||
|
if not get_settings().passkey_enabled:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="passkeys disabled")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_magic_link_enabled() -> None:
|
||||||
|
"""Gate magic-link endpoints behind a feature flag (MAGIC_LINK_ENABLED)."""
|
||||||
|
if not get_settings().magic_link_enabled:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="magic links disabled")
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_single_token(user: User) -> SingleTokenAuthResponse:
|
||||||
|
"""Mint one access JWT (no refresh) for passwordless login flows."""
|
||||||
|
settings = get_settings()
|
||||||
|
return SingleTokenAuthResponse(
|
||||||
|
access_token=create_access_token(user.id, user.telegram_id or 0),
|
||||||
|
token_type="bearer",
|
||||||
|
expires_in=settings.jwt_access_ttl_minutes * 60,
|
||||||
|
user_id=user.id,
|
||||||
|
email=user.email or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_magic_link(token: str) -> str:
|
||||||
|
base = get_settings().web_app_base_url.rstrip("/")
|
||||||
|
return f"{base}/magic-link?token={token}"
|
||||||
117
src/contract_check/api/routes/auth/telegram.py
Normal file
117
src/contract_check/api/routes/auth/telegram.py
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Header, HTTPException, status
|
||||||
|
|
||||||
|
from ....core.auth import (
|
||||||
|
AuthError,
|
||||||
|
verify_bot_identity,
|
||||||
|
verify_telegram_miniapp_init_data,
|
||||||
|
verify_telegram_web_payload,
|
||||||
|
)
|
||||||
|
from ....core.config import get_settings
|
||||||
|
from ....core.logging import get_logger
|
||||||
|
from ...deps import AsyncSessionDep, AuthDep, fetch_user_by_id_full, get_or_create_user_for_telegram
|
||||||
|
from ...schemas import (
|
||||||
|
AuthResponse,
|
||||||
|
MeResponse,
|
||||||
|
TelegramBotAuthRequest,
|
||||||
|
TelegramMiniAppAuthRequest,
|
||||||
|
TelegramWebAuthRequest,
|
||||||
|
)
|
||||||
|
from .support import _issue_token, _require_access_claims, _telegram_profile_looks_verified
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
router = APIRouter(tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/telegram/bot", status_code=status.HTTP_200_OK)
|
||||||
|
async def auth_telegram_bot(
|
||||||
|
auth: AuthDep,
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
body: TelegramBotAuthRequest,
|
||||||
|
) -> AuthResponse:
|
||||||
|
"""Exchange a verified telegram_id (from the bot) for a user JWT.
|
||||||
|
|
||||||
|
Stores/updates the Telegram profile snapshot and sets telegram_verified
|
||||||
|
based on whether the profile looks legitimate.
|
||||||
|
"""
|
||||||
|
identity = verify_bot_identity(body.telegram_id)
|
||||||
|
profile = body.profile
|
||||||
|
verified = _telegram_profile_looks_verified(profile)
|
||||||
|
user = await get_or_create_user_for_telegram(
|
||||||
|
session,
|
||||||
|
identity.telegram_id,
|
||||||
|
profile=profile.model_dump(exclude_none=True) if profile else None,
|
||||||
|
verified=verified,
|
||||||
|
)
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account disabled")
|
||||||
|
return _issue_token(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/telegram/web", status_code=status.HTTP_200_OK)
|
||||||
|
async def auth_telegram_web(
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
body: TelegramWebAuthRequest,
|
||||||
|
) -> AuthResponse:
|
||||||
|
"""Verify Telegram Login Widget payload and issue a user JWT."""
|
||||||
|
bot_token = get_settings().telegram_bot_token
|
||||||
|
try:
|
||||||
|
identity = verify_telegram_web_payload(body.model_dump(), bot_token)
|
||||||
|
except AuthError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
user = await get_or_create_user_for_telegram(session, identity.telegram_id)
|
||||||
|
return _issue_token(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/telegram/miniapp", status_code=status.HTTP_200_OK)
|
||||||
|
async def auth_telegram_miniapp(
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
body: TelegramMiniAppAuthRequest,
|
||||||
|
) -> AuthResponse:
|
||||||
|
"""Verify Telegram Mini App initData and issue a user JWT."""
|
||||||
|
bot_token = get_settings().telegram_bot_token
|
||||||
|
try:
|
||||||
|
identity = verify_telegram_miniapp_init_data(body.init_data, bot_token)
|
||||||
|
except AuthError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
user = await get_or_create_user_for_telegram(session, identity.telegram_id)
|
||||||
|
return _issue_token(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=MeResponse, status_code=status.HTTP_200_OK)
|
||||||
|
async def me(
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
authorization: Annotated[str | None, Header()] = None,
|
||||||
|
) -> MeResponse:
|
||||||
|
"""Current user — verifies the Bearer JWT and returns the user's profile.
|
||||||
|
|
||||||
|
Backward-compatible with the previous introspect shape (sub/telegram_id/
|
||||||
|
type/exp) and adds email/credits_left/is_active/created_at.
|
||||||
|
"""
|
||||||
|
claims = _require_access_claims(authorization)
|
||||||
|
user = await fetch_user_by_id_full(session, claims.sub)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
|
|
||||||
|
return MeResponse(
|
||||||
|
sub=str(user.id),
|
||||||
|
telegram_id=user.telegram_id or 0,
|
||||||
|
type=claims.type,
|
||||||
|
exp=claims.exp or 0,
|
||||||
|
email=user.email,
|
||||||
|
name=user.name,
|
||||||
|
credits_left=user.credits_left,
|
||||||
|
is_active=bool(user.is_active),
|
||||||
|
created_at=user.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/permissions")
|
||||||
|
async def token_permissions_dummy() -> dict[str, Any]:
|
||||||
|
"""Placeholder for future RBAC expansion."""
|
||||||
|
return {"permissions": ["upload", "read_reports", "read_me"]}
|
||||||
|
|
@ -11,10 +11,9 @@ import uuid
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, File, HTTPException, UploadFile, status
|
from fastapi import APIRouter, File, HTTPException, UploadFile, status
|
||||||
from pydantic import BaseModel
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
from ...core.api_keys import generate_api_key, hash_api_key
|
from ...core.api_keys import generate_api_key, hash_api_key
|
||||||
|
from ...core.db.repositories import ApiKeyRepository
|
||||||
from ...core.logging import get_logger
|
from ...core.logging import get_logger
|
||||||
from ..deps import (
|
from ..deps import (
|
||||||
ApiKeyAuthDep,
|
ApiKeyAuthDep,
|
||||||
|
|
@ -24,6 +23,17 @@ from ..deps import (
|
||||||
StorageDep,
|
StorageDep,
|
||||||
fetch_document_status_for_user,
|
fetch_document_status_for_user,
|
||||||
)
|
)
|
||||||
|
from ..schemas import (
|
||||||
|
ApiKeyCreatedResponse,
|
||||||
|
ApiKeyResponse,
|
||||||
|
ApiKeyUsageDetailResponse,
|
||||||
|
B2BUsageResponse,
|
||||||
|
CreateApiKeyRequest,
|
||||||
|
DocumentUploadResponse,
|
||||||
|
ReportInProgressResponse,
|
||||||
|
ReportResponse,
|
||||||
|
RevokeApiKeyResponse,
|
||||||
|
)
|
||||||
from ..services import upload_and_enqueue
|
from ..services import upload_and_enqueue
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
@ -31,14 +41,16 @@ log = get_logger(__name__)
|
||||||
router = APIRouter(tags=["b2b"])
|
router = APIRouter(tags=["b2b"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/analyze", status_code=status.HTTP_202_ACCEPTED)
|
@router.post(
|
||||||
|
"/api/v1/analyze", response_model=DocumentUploadResponse, status_code=status.HTTP_202_ACCEPTED
|
||||||
|
)
|
||||||
async def analyze_document(
|
async def analyze_document(
|
||||||
auth: ApiKeyAuthDep,
|
auth: ApiKeyAuthDep,
|
||||||
session: AsyncSessionDep,
|
session: AsyncSessionDep,
|
||||||
storage: StorageDep,
|
storage: StorageDep,
|
||||||
publisher: PublisherDep,
|
publisher: PublisherDep,
|
||||||
file: Annotated[UploadFile, File()],
|
file: Annotated[UploadFile, File()],
|
||||||
) -> dict[str, object]:
|
) -> DocumentUploadResponse:
|
||||||
"""Upload a document for analysis using a B2B API key.
|
"""Upload a document for analysis using a B2B API key.
|
||||||
|
|
||||||
Reserves one credit from the key owner's account, stores the file, enqueues
|
Reserves one credit from the key owner's account, stores the file, enqueues
|
||||||
|
|
@ -48,20 +60,15 @@ async def analyze_document(
|
||||||
|
|
||||||
# Record usage for this key.
|
# Record usage for this key.
|
||||||
try:
|
try:
|
||||||
await session.execute(
|
key_repo = ApiKeyRepository(session)
|
||||||
text("INSERT INTO api_key_requests (api_key_id, document_id) VALUES (:k, :d)"),
|
await key_repo.record_request(auth.api_key_id, uuid.UUID(str(result.document_id)))
|
||||||
{"k": auth.api_key_id, "d": uuid.UUID(str(result["document_id"]))},
|
await key_repo.bump_monthly_used(auth.api_key_id)
|
||||||
)
|
|
||||||
await session.execute(
|
|
||||||
text("UPDATE api_keys SET monthly_used = monthly_used + 1 WHERE id = :k"),
|
|
||||||
{"k": auth.api_key_id},
|
|
||||||
)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error(
|
log.error(
|
||||||
"api_key_request_log_failed",
|
"api_key_request_log_failed",
|
||||||
api_key_id=str(auth.api_key_id),
|
api_key_id=str(auth.api_key_id),
|
||||||
document_id=result["document_id"],
|
document_id=result.document_id,
|
||||||
error=str(exc),
|
error=str(exc),
|
||||||
)
|
)
|
||||||
# Usage logging is best-effort; do not fail the upload.
|
# Usage logging is best-effort; do not fail the upload.
|
||||||
|
|
@ -69,12 +76,14 @@ async def analyze_document(
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/b2b/reports/{document_id}")
|
@router.get(
|
||||||
|
"/api/v1/b2b/reports/{document_id}", response_model=ReportResponse | ReportInProgressResponse
|
||||||
|
)
|
||||||
async def get_b2b_report(
|
async def get_b2b_report(
|
||||||
auth: ApiKeyAuthDep,
|
auth: ApiKeyAuthDep,
|
||||||
session: AsyncSessionDep,
|
session: AsyncSessionDep,
|
||||||
document_id: uuid.UUID,
|
document_id: uuid.UUID,
|
||||||
) -> dict[str, object]:
|
) -> ReportResponse | ReportInProgressResponse:
|
||||||
"""Poll for an analysis report scoped to the API key owner."""
|
"""Poll for an analysis report scoped to the API key owner."""
|
||||||
row = await fetch_document_status_for_user(session, document_id, auth.user_id)
|
row = await fetch_document_status_for_user(session, document_id, auth.user_id)
|
||||||
if row is None:
|
if row is None:
|
||||||
|
|
@ -82,83 +91,58 @@ async def get_b2b_report(
|
||||||
|
|
||||||
doc_status = row["status"]
|
doc_status = row["status"]
|
||||||
if doc_status != "done":
|
if doc_status != "done":
|
||||||
return {
|
return ReportInProgressResponse(
|
||||||
"document_id": str(document_id),
|
document_id=str(document_id),
|
||||||
"status": doc_status,
|
status=doc_status,
|
||||||
"stage": row["stage"],
|
stage=row["stage"],
|
||||||
}
|
)
|
||||||
|
|
||||||
return {
|
return ReportResponse(
|
||||||
"document_id": str(document_id),
|
document_id=str(document_id),
|
||||||
"status": doc_status,
|
status=doc_status,
|
||||||
"filename": row["filename"],
|
filename=row["filename"],
|
||||||
"markdown": row["markdown"],
|
markdown=row["markdown"],
|
||||||
"findings": row["content_json"],
|
findings=row["content_json"],
|
||||||
"model_used": row["model_used"],
|
model_used=row["model_used"],
|
||||||
"prompt_tokens": row["prompt_tokens"],
|
prompt_tokens=row["prompt_tokens"],
|
||||||
"eval_tokens": row["eval_tokens"],
|
eval_tokens=row["eval_tokens"],
|
||||||
"latency_ms": row["latency_ms"],
|
latency_ms=row["latency_ms"],
|
||||||
}
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/b2b/usage")
|
@router.get("/api/v1/b2b/usage", response_model=B2BUsageResponse)
|
||||||
async def get_usage(
|
async def get_usage(
|
||||||
auth: ApiKeyAuthDep,
|
auth: ApiKeyAuthDep,
|
||||||
session: AsyncSessionDep,
|
session: AsyncSessionDep,
|
||||||
) -> dict[str, object]:
|
) -> B2BUsageResponse:
|
||||||
"""Return current-month usage for the authenticating API key."""
|
"""Return current-month usage for the authenticating API key."""
|
||||||
result = await session.execute(
|
repo = ApiKeyRepository(session)
|
||||||
text("SELECT monthly_quota, monthly_used, resets_at FROM api_keys WHERE id = :k"),
|
key = await repo.get_by_id(auth.api_key_id)
|
||||||
{"k": auth.api_key_id},
|
assert key is not None
|
||||||
)
|
|
||||||
row = result.first()
|
|
||||||
assert row is not None
|
|
||||||
|
|
||||||
quota, used, resets_at = row
|
_, monthly_used = await repo.get_monthly_quota_state(auth.api_key_id)
|
||||||
requests_result = await session.execute(
|
|
||||||
text(
|
|
||||||
"SELECT COUNT(*) FROM api_key_requests "
|
|
||||||
"WHERE api_key_id = :k AND created_at >= DATE_TRUNC('month', now())"
|
|
||||||
),
|
|
||||||
{"k": auth.api_key_id},
|
|
||||||
)
|
|
||||||
requests_this_month = int(requests_result.scalar_one())
|
|
||||||
|
|
||||||
return {
|
return B2BUsageResponse(
|
||||||
"api_key_id": str(auth.api_key_id),
|
api_key_id=str(auth.api_key_id),
|
||||||
"rate_limit_rps": auth.rate_limit_rps,
|
rate_limit_rps=auth.rate_limit_rps,
|
||||||
"monthly_quota": quota,
|
monthly_quota=key.monthly_quota,
|
||||||
"monthly_used": used,
|
monthly_used=monthly_used,
|
||||||
"requests_this_month": requests_this_month,
|
requests_this_month=await repo.count_requests_this_month(auth.api_key_id),
|
||||||
"resets_at": resets_at.isoformat() if resets_at else None,
|
resets_at=key.resets_at.isoformat() if key.resets_at else None,
|
||||||
}
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Key management (service-token auth + telegram_id) ────────────────────────
|
# ── Key management (service-token auth + telegram_id) ────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class CreateApiKeyRequest(BaseModel):
|
@router.post(
|
||||||
name: str
|
"/api/v1/b2b/keys", response_model=ApiKeyCreatedResponse, status_code=status.HTTP_201_CREATED
|
||||||
rate_limit_rps: int | None = None
|
)
|
||||||
monthly_quota: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ApiKeyResponse(BaseModel):
|
|
||||||
id: uuid.UUID
|
|
||||||
name: str
|
|
||||||
rate_limit_rps: int
|
|
||||||
monthly_quota: int | None
|
|
||||||
monthly_used: int
|
|
||||||
revoked: bool
|
|
||||||
created_at: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/b2b/keys", status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_api_key(
|
async def create_api_key(
|
||||||
session: AsyncSessionDep,
|
session: AsyncSessionDep,
|
||||||
user: CurrentUserDep,
|
user: CurrentUserDep,
|
||||||
body: CreateApiKeyRequest,
|
body: CreateApiKeyRequest,
|
||||||
) -> dict[str, object]:
|
) -> ApiKeyCreatedResponse:
|
||||||
"""Create a new B2B API key for the authenticated user.
|
"""Create a new B2B API key for the authenticated user.
|
||||||
|
|
||||||
The raw key is returned **only once**; afterwards only its hash is stored.
|
The raw key is returned **only once**; afterwards only its hash is stored.
|
||||||
|
|
@ -171,124 +155,89 @@ async def create_api_key(
|
||||||
key_hash = hash_api_key(raw_key)
|
key_hash = hash_api_key(raw_key)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await session.execute(
|
key = await ApiKeyRepository(session).create(
|
||||||
text(
|
user_id=user.user_id,
|
||||||
"INSERT INTO api_keys (user_id, name, key_hash, rate_limit_rps, monthly_quota) "
|
name=body.name,
|
||||||
"VALUES (:u, :n, :h, :r, :q) "
|
key_hash=key_hash,
|
||||||
"RETURNING id, name, rate_limit_rps, monthly_quota, monthly_used, revoked, created_at"
|
rate_limit_rps=rate_limit,
|
||||||
),
|
monthly_quota=body.monthly_quota,
|
||||||
{
|
|
||||||
"u": user.user_id,
|
|
||||||
"n": body.name,
|
|
||||||
"h": key_hash,
|
|
||||||
"r": rate_limit,
|
|
||||||
"q": body.monthly_quota,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("api_key_create_failed", user_id=str(user.user_id), error=str(exc))
|
log.error("api_key_create_failed", user_id=str(user.user_id), error=str(exc))
|
||||||
raise HTTPException(status_code=500, detail="failed to create API key") from exc
|
raise HTTPException(status_code=500, detail="failed to create API key") from exc
|
||||||
|
|
||||||
row = result.first()
|
return ApiKeyCreatedResponse(
|
||||||
assert row is not None
|
api_key=raw_key,
|
||||||
return {
|
id=key.id,
|
||||||
"api_key": raw_key,
|
name=key.name,
|
||||||
"id": str(row[0]),
|
rate_limit_rps=key.rate_limit_rps,
|
||||||
"name": row[1],
|
monthly_quota=key.monthly_quota,
|
||||||
"rate_limit_rps": row[2],
|
monthly_used=key.monthly_used,
|
||||||
"monthly_quota": row[3],
|
revoked=key.revoked,
|
||||||
"monthly_used": row[4],
|
created_at=key.created_at.isoformat() if key.created_at else None,
|
||||||
"revoked": row[5],
|
)
|
||||||
"created_at": row[6].isoformat() if row[6] else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/b2b/keys")
|
@router.get("/api/v1/b2b/keys", response_model=list[ApiKeyResponse])
|
||||||
async def list_api_keys(
|
async def list_api_keys(
|
||||||
session: AsyncSessionDep,
|
session: AsyncSessionDep,
|
||||||
user: CurrentUserDep,
|
user: CurrentUserDep,
|
||||||
) -> list[dict[str, object]]:
|
) -> list[ApiKeyResponse]:
|
||||||
"""List B2B API keys for the authenticated user."""
|
"""List B2B API keys for the authenticated user."""
|
||||||
result = await session.execute(
|
keys = await ApiKeyRepository(session).list_for_user(user.user_id)
|
||||||
text(
|
|
||||||
"SELECT id, name, rate_limit_rps, monthly_quota, monthly_used, revoked, created_at "
|
|
||||||
"FROM api_keys WHERE user_id = :u ORDER BY created_at DESC"
|
|
||||||
),
|
|
||||||
{"u": user.user_id},
|
|
||||||
)
|
|
||||||
return [
|
return [
|
||||||
{
|
ApiKeyResponse(
|
||||||
"id": str(row[0]),
|
id=key.id,
|
||||||
"name": row[1],
|
name=key.name,
|
||||||
"rate_limit_rps": row[2],
|
rate_limit_rps=key.rate_limit_rps,
|
||||||
"monthly_quota": row[3],
|
monthly_quota=key.monthly_quota,
|
||||||
"monthly_used": row[4],
|
monthly_used=key.monthly_used,
|
||||||
"revoked": row[5],
|
revoked=key.revoked,
|
||||||
"created_at": row[6].isoformat() if row[6] else None,
|
created_at=key.created_at.isoformat() if key.created_at else None,
|
||||||
}
|
)
|
||||||
for row in result.all()
|
for key in keys
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/b2b/keys/{key_id}/revoke")
|
@router.post("/api/v1/b2b/keys/{key_id}/revoke", response_model=RevokeApiKeyResponse)
|
||||||
async def revoke_api_key(
|
async def revoke_api_key(
|
||||||
session: AsyncSessionDep,
|
session: AsyncSessionDep,
|
||||||
user: CurrentUserDep,
|
user: CurrentUserDep,
|
||||||
key_id: uuid.UUID,
|
key_id: uuid.UUID,
|
||||||
) -> dict[str, object]:
|
) -> RevokeApiKeyResponse:
|
||||||
"""Revoke a B2B API key. Only keys owned by the user may be revoked."""
|
"""Revoke a B2B API key. Only keys owned by the user may be revoked."""
|
||||||
result = await session.execute(
|
key = await ApiKeyRepository(session).get_by_id(key_id)
|
||||||
text(
|
if key is None or key.user_id != user.user_id or key.revoked:
|
||||||
"UPDATE api_keys SET revoked = TRUE "
|
|
||||||
"WHERE id = :k AND user_id = :u AND revoked = FALSE "
|
|
||||||
"RETURNING id"
|
|
||||||
),
|
|
||||||
{"k": key_id, "u": user.user_id},
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
if result.first() is None:
|
|
||||||
raise HTTPException(status_code=404, detail="key not found or already revoked")
|
raise HTTPException(status_code=404, detail="key not found or already revoked")
|
||||||
return {"id": str(key_id), "revoked": True}
|
await ApiKeyRepository(session).revoke(key_id)
|
||||||
|
await session.commit()
|
||||||
|
return RevokeApiKeyResponse(id=str(key_id), revoked=True)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/b2b/keys/{key_id}/usage")
|
@router.get("/api/v1/b2b/keys/{key_id}/usage", response_model=ApiKeyUsageDetailResponse)
|
||||||
async def get_key_usage(
|
async def get_key_usage(
|
||||||
session: AsyncSessionDep,
|
session: AsyncSessionDep,
|
||||||
user: CurrentUserDep,
|
user: CurrentUserDep,
|
||||||
key_id: uuid.UUID,
|
key_id: uuid.UUID,
|
||||||
) -> dict[str, object]:
|
) -> ApiKeyUsageDetailResponse:
|
||||||
"""Return per-month usage for a specific API key owned by the user."""
|
"""Return per-month usage for a specific API key owned by the user."""
|
||||||
key_result = await session.execute(
|
repo = ApiKeyRepository(session)
|
||||||
text(
|
key = await repo.get_by_id(key_id)
|
||||||
"SELECT name, rate_limit_rps, monthly_quota, monthly_used, resets_at "
|
if key is None or key.user_id != user.user_id:
|
||||||
"FROM api_keys WHERE id = :k AND user_id = :u"
|
|
||||||
),
|
|
||||||
{"k": key_id, "u": user.user_id},
|
|
||||||
)
|
|
||||||
key_row = key_result.first()
|
|
||||||
if key_row is None:
|
|
||||||
raise HTTPException(status_code=404, detail="key not found")
|
raise HTTPException(status_code=404, detail="key not found")
|
||||||
|
|
||||||
monthly_result = await session.execute(
|
monthly_result = await repo.get_usage_by_key(key_id)
|
||||||
text(
|
|
||||||
"SELECT DATE_TRUNC('month', created_at) AS month, COUNT(*) "
|
|
||||||
"FROM api_key_requests "
|
|
||||||
"WHERE api_key_id = :k "
|
|
||||||
"GROUP BY month ORDER BY month DESC"
|
|
||||||
),
|
|
||||||
{"k": key_id},
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return ApiKeyUsageDetailResponse(
|
||||||
"key_id": str(key_id),
|
key_id=str(key_id),
|
||||||
"name": key_row[0],
|
name=key.name,
|
||||||
"rate_limit_rps": key_row[1],
|
rate_limit_rps=key.rate_limit_rps,
|
||||||
"monthly_quota": key_row[2],
|
monthly_quota=key.monthly_quota,
|
||||||
"monthly_used": key_row[3],
|
monthly_used=key.monthly_used,
|
||||||
"resets_at": key_row[4].isoformat() if key_row[4] else None,
|
resets_at=key.resets_at.isoformat() if key.resets_at else None,
|
||||||
"monthly_requests": [
|
monthly_requests=[
|
||||||
{"month": row[0].isoformat() if row[0] else None, "requests": int(row[1])}
|
{"month": req.created_at.isoformat() if req.created_at else None, "requests": 1}
|
||||||
for row in monthly_result.all()
|
for req in monthly_result
|
||||||
],
|
],
|
||||||
}
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,19 +8,20 @@ from fastapi import APIRouter, File, UploadFile, status
|
||||||
|
|
||||||
from ...core.logging import get_logger
|
from ...core.logging import get_logger
|
||||||
from ..deps import AsyncSessionDep, CurrentUserDep, PublisherDep, StorageDep
|
from ..deps import AsyncSessionDep, CurrentUserDep, PublisherDep, StorageDep
|
||||||
|
from ..schemas import DocumentUploadResponse
|
||||||
from ..services import upload_and_enqueue
|
from ..services import upload_and_enqueue
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
router = APIRouter(tags=["documents"])
|
router = APIRouter(prefix="/api/v1/documents", tags=["documents"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/documents", status_code=status.HTTP_202_ACCEPTED)
|
@router.post("", response_model=DocumentUploadResponse, status_code=status.HTTP_202_ACCEPTED)
|
||||||
async def upload_document(
|
async def upload_document(
|
||||||
session: AsyncSessionDep,
|
session: AsyncSessionDep,
|
||||||
storage: StorageDep,
|
storage: StorageDep,
|
||||||
publisher: PublisherDep,
|
publisher: PublisherDep,
|
||||||
user: CurrentUserDep,
|
user: CurrentUserDep,
|
||||||
file: Annotated[UploadFile, File()],
|
file: Annotated[UploadFile, File()],
|
||||||
) -> dict[str, object]:
|
) -> DocumentUploadResponse:
|
||||||
return await upload_and_enqueue(session, storage, publisher, user.user_id, file)
|
return await upload_and_enqueue(session, storage, publisher, user.user_id, file)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ async def healthz() -> dict[str, str]:
|
||||||
@router.get("/readyz")
|
@router.get("/readyz")
|
||||||
async def readyz(session: AsyncSessionDep) -> dict[str, str]:
|
async def readyz(session: AsyncSessionDep) -> dict[str, str]:
|
||||||
try:
|
try:
|
||||||
|
# Deliberate raw SQL: lightweight connection/statement round-trip ping.
|
||||||
await session.execute(text("SELECT 1"))
|
await session.execute(text("SELECT 1"))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {"status": "not_ready", "reason": f"db: {exc}"}
|
return {"status": "not_ready", "reason": f"db: {exc}"}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,8 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, status
|
from fastapi import APIRouter, status
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
|
from ...core.db.repositories import DocumentRepository
|
||||||
from ..deps import (
|
from ..deps import (
|
||||||
AsyncSessionDep,
|
AsyncSessionDep,
|
||||||
CurrentUserDep,
|
CurrentUserDep,
|
||||||
|
|
@ -13,30 +12,19 @@ from ..deps import (
|
||||||
get_credits,
|
get_credits,
|
||||||
set_user_password,
|
set_user_password,
|
||||||
)
|
)
|
||||||
|
from ..schemas import (
|
||||||
|
BindTelegramRequest,
|
||||||
|
BindTelegramResponse,
|
||||||
|
)
|
||||||
|
from ..schemas import MeCreditsResponse as MeResponse
|
||||||
|
from ..schemas import (
|
||||||
|
SetPasswordRequest,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(tags=["me"])
|
router = APIRouter(prefix="/api/v1/me", tags=["me"])
|
||||||
|
|
||||||
|
|
||||||
class MeResponse(BaseModel):
|
@router.get("", response_model=MeResponse)
|
||||||
telegram_id: int | None = None
|
|
||||||
credits_left: int
|
|
||||||
email: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class BindTelegramRequest(BaseModel):
|
|
||||||
telegram_id: int = Field(..., gt=0, description="Verified Telegram user id")
|
|
||||||
|
|
||||||
|
|
||||||
class BindTelegramResponse(BaseModel):
|
|
||||||
ok: bool = True
|
|
||||||
telegram_id: int
|
|
||||||
|
|
||||||
|
|
||||||
class SetPasswordRequest(BaseModel):
|
|
||||||
password: str = Field(..., min_length=8, max_length=128)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/me", response_model=MeResponse)
|
|
||||||
async def me(
|
async def me(
|
||||||
session: AsyncSessionDep,
|
session: AsyncSessionDep,
|
||||||
user: CurrentUserDep,
|
user: CurrentUserDep,
|
||||||
|
|
@ -48,7 +36,7 @@ async def me(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/me/documents")
|
@router.get("/documents")
|
||||||
async def list_my_documents(
|
async def list_my_documents(
|
||||||
session: AsyncSessionDep,
|
session: AsyncSessionDep,
|
||||||
user: CurrentUserDep,
|
user: CurrentUserDep,
|
||||||
|
|
@ -60,32 +48,22 @@ async def list_my_documents(
|
||||||
if limit > 100:
|
if limit > 100:
|
||||||
limit = 100
|
limit = 100
|
||||||
|
|
||||||
result = await session.execute(
|
documents = await DocumentRepository(session).list_for_user(user.user_id, limit=limit, offset=0)
|
||||||
text(
|
|
||||||
"SELECT d.id, d.filename, d.status, d.stage, d.created_at "
|
|
||||||
"FROM documents d "
|
|
||||||
"WHERE d.user_id = :u "
|
|
||||||
"ORDER BY d.created_at DESC "
|
|
||||||
"LIMIT :limit"
|
|
||||||
),
|
|
||||||
{"u": user.user_id, "limit": limit},
|
|
||||||
)
|
|
||||||
rows = result.all()
|
|
||||||
return {
|
return {
|
||||||
"documents": [
|
"documents": [
|
||||||
{
|
{
|
||||||
"document_id": str(row[0]),
|
"document_id": str(doc.id),
|
||||||
"filename": row[1],
|
"filename": doc.filename,
|
||||||
"status": row[2],
|
"status": doc.status,
|
||||||
"stage": row[3],
|
"stage": doc.stage,
|
||||||
"created_at": row[4].isoformat() if row[4] else None,
|
"created_at": doc.created_at.isoformat() if doc.created_at else None,
|
||||||
}
|
}
|
||||||
for row in rows
|
for doc in documents
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/me/telegram", response_model=BindTelegramResponse)
|
@router.post("/telegram", response_model=BindTelegramResponse)
|
||||||
async def bind_telegram(
|
async def bind_telegram(
|
||||||
session: AsyncSessionDep,
|
session: AsyncSessionDep,
|
||||||
user: CurrentUserDep,
|
user: CurrentUserDep,
|
||||||
|
|
@ -96,7 +74,7 @@ async def bind_telegram(
|
||||||
return BindTelegramResponse(ok=True, telegram_id=body.telegram_id)
|
return BindTelegramResponse(ok=True, telegram_id=body.telegram_id)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/me/password", status_code=status.HTTP_200_OK)
|
@router.post("/password", status_code=status.HTTP_200_OK)
|
||||||
async def set_password(
|
async def set_password(
|
||||||
session: AsyncSessionDep,
|
session: AsyncSessionDep,
|
||||||
user: CurrentUserDep,
|
user: CurrentUserDep,
|
||||||
|
|
|
||||||
|
|
@ -7,36 +7,37 @@ from uuid import UUID
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
from ..deps import AsyncSessionDep, CurrentUserDep, fetch_document_status_for_user
|
from ..deps import AsyncSessionDep, CurrentUserDep, fetch_document_status_for_user
|
||||||
|
from ..schemas import ReportInProgressResponse, ReportResponse
|
||||||
|
|
||||||
router = APIRouter(tags=["reports"])
|
router = APIRouter(prefix="/api/v1/reports", tags=["reports"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/reports/{document_id}")
|
@router.get("/{document_id}", response_model=ReportResponse | ReportInProgressResponse)
|
||||||
async def get_report(
|
async def get_report(
|
||||||
session: AsyncSessionDep,
|
session: AsyncSessionDep,
|
||||||
document_id: UUID,
|
document_id: UUID,
|
||||||
user: CurrentUserDep,
|
user: CurrentUserDep,
|
||||||
) -> dict[str, object]:
|
) -> ReportResponse | ReportInProgressResponse:
|
||||||
row = await fetch_document_status_for_user(session, document_id, user.user_id)
|
row = await fetch_document_status_for_user(session, document_id, user.user_id)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(status_code=404, detail="report not found")
|
raise HTTPException(status_code=404, detail="report not found")
|
||||||
|
|
||||||
status = row["status"]
|
doc_status = row["status"]
|
||||||
if status != "done":
|
if doc_status != "done":
|
||||||
return {
|
return ReportInProgressResponse(
|
||||||
"document_id": str(document_id),
|
document_id=str(document_id),
|
||||||
"status": status,
|
status=doc_status,
|
||||||
"stage": row["stage"],
|
stage=row["stage"],
|
||||||
}
|
)
|
||||||
|
|
||||||
return {
|
return ReportResponse(
|
||||||
"document_id": str(document_id),
|
document_id=str(document_id),
|
||||||
"status": status,
|
status=doc_status,
|
||||||
"filename": row["filename"],
|
filename=row["filename"],
|
||||||
"markdown": row["markdown"],
|
markdown=row["markdown"],
|
||||||
"findings": row["content_json"],
|
findings=row["content_json"],
|
||||||
"model_used": row["model_used"],
|
model_used=row["model_used"],
|
||||||
"prompt_tokens": row["prompt_tokens"],
|
prompt_tokens=row["prompt_tokens"],
|
||||||
"eval_tokens": row["eval_tokens"],
|
eval_tokens=row["eval_tokens"],
|
||||||
"latency_ms": row["latency_ms"],
|
latency_ms=row["latency_ms"],
|
||||||
}
|
)
|
||||||
|
|
|
||||||
108
src/contract_check/api/schemas/__init__.py
Normal file
108
src/contract_check/api/schemas/__init__.py
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
"""API Pydantic schemas (request/response DTOs) for route modules.
|
||||||
|
|
||||||
|
This package is the single source of truth for JSON shapes crossing the HTTP
|
||||||
|
boundary. Route modules import from here; no Pydantic model definitions should
|
||||||
|
live in `api/routes/*` except tiny inline query/path params.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .auth import (
|
||||||
|
AuthResponse,
|
||||||
|
ForgotPasswordRequest,
|
||||||
|
LoginRequest,
|
||||||
|
LogoutRequest,
|
||||||
|
MagicLinkRequest,
|
||||||
|
MagicLinkResponse,
|
||||||
|
MagicLinkVerifyRequest,
|
||||||
|
MeResponse,
|
||||||
|
OkResponse,
|
||||||
|
PasskeyAuthenticateFinishRequest,
|
||||||
|
PasskeyAuthenticationCredentialData,
|
||||||
|
PasskeyAuthenticationCredentialPayload,
|
||||||
|
PasskeyAuthenticationOptions,
|
||||||
|
PasskeyCredentialPublic,
|
||||||
|
PasskeyRegisterFinishRequest,
|
||||||
|
PasskeyRegisterStartRequest,
|
||||||
|
PasskeyRegistrationCredentialData,
|
||||||
|
PasskeyRegistrationCredentialPayload,
|
||||||
|
PasskeyRegistrationFinishResponse,
|
||||||
|
PasskeyRegistrationOptions,
|
||||||
|
RegisterRequest,
|
||||||
|
ResetPasswordRequest,
|
||||||
|
SingleTokenAuthResponse,
|
||||||
|
TelegramBotAuthRequest,
|
||||||
|
TelegramMiniAppAuthRequest,
|
||||||
|
TelegramProfile,
|
||||||
|
TelegramWebAuthRequest,
|
||||||
|
TokenIntrospectResponse,
|
||||||
|
TokenPairResponse,
|
||||||
|
WebUserPublic,
|
||||||
|
)
|
||||||
|
from .b2b import (
|
||||||
|
ApiKeyCreatedResponse,
|
||||||
|
ApiKeyResponse,
|
||||||
|
ApiKeyUsageDetailResponse,
|
||||||
|
B2BUsageResponse,
|
||||||
|
CreateApiKeyRequest,
|
||||||
|
RevokeApiKeyResponse,
|
||||||
|
)
|
||||||
|
from .common import DocumentUploadResponse, ReportInProgressResponse, ReportResponse
|
||||||
|
from .documents import DocumentListResponse # noqa: F401
|
||||||
|
from .me import (
|
||||||
|
BindTelegramRequest,
|
||||||
|
BindTelegramResponse,
|
||||||
|
MeCreditsResponse,
|
||||||
|
SetPasswordRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# auth
|
||||||
|
"TelegramProfile",
|
||||||
|
"TelegramBotAuthRequest",
|
||||||
|
"TelegramWebAuthRequest",
|
||||||
|
"TelegramMiniAppAuthRequest",
|
||||||
|
"AuthResponse",
|
||||||
|
"TokenIntrospectResponse",
|
||||||
|
"WebUserPublic",
|
||||||
|
"MeResponse",
|
||||||
|
"RegisterRequest",
|
||||||
|
"LoginRequest",
|
||||||
|
"TokenPairResponse",
|
||||||
|
"LogoutRequest",
|
||||||
|
"ForgotPasswordRequest",
|
||||||
|
"ResetPasswordRequest",
|
||||||
|
"OkResponse",
|
||||||
|
"PasskeyRegistrationOptions",
|
||||||
|
"PasskeyAuthenticationOptions",
|
||||||
|
"PasskeyCredentialPublic",
|
||||||
|
"PasskeyRegistrationFinishResponse",
|
||||||
|
"SingleTokenAuthResponse",
|
||||||
|
"MagicLinkResponse",
|
||||||
|
"PasskeyRegisterStartRequest",
|
||||||
|
"PasskeyRegistrationCredentialData",
|
||||||
|
"PasskeyRegistrationCredentialPayload",
|
||||||
|
"PasskeyRegisterFinishRequest",
|
||||||
|
"PasskeyAuthenticationCredentialData",
|
||||||
|
"PasskeyAuthenticationCredentialPayload",
|
||||||
|
"PasskeyAuthenticateFinishRequest",
|
||||||
|
"MagicLinkRequest",
|
||||||
|
"MagicLinkVerifyRequest",
|
||||||
|
# me
|
||||||
|
"MeCreditsResponse",
|
||||||
|
"BindTelegramRequest",
|
||||||
|
"BindTelegramResponse",
|
||||||
|
"SetPasswordRequest",
|
||||||
|
# documents / reports / common
|
||||||
|
"DocumentUploadResponse",
|
||||||
|
"DocumentListResponse",
|
||||||
|
"ReportInProgressResponse",
|
||||||
|
"ReportResponse",
|
||||||
|
# b2b
|
||||||
|
"CreateApiKeyRequest",
|
||||||
|
"ApiKeyResponse",
|
||||||
|
"ApiKeyCreatedResponse",
|
||||||
|
"B2BUsageResponse",
|
||||||
|
"ApiKeyUsageDetailResponse",
|
||||||
|
"RevokeApiKeyResponse",
|
||||||
|
]
|
||||||
250
src/contract_check/api/schemas/auth.py
Normal file
250
src/contract_check/api/schemas/auth.py
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
"""Authentication request/response schemas."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramProfile(BaseModel):
|
||||||
|
username: str | None = None
|
||||||
|
first_name: str | None = None
|
||||||
|
last_name: str | None = None
|
||||||
|
language_code: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBotAuthRequest(BaseModel):
|
||||||
|
telegram_id: int = Field(..., gt=0, description="Verified Telegram user id from aiogram")
|
||||||
|
profile: TelegramProfile | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramWebAuthRequest(BaseModel):
|
||||||
|
id: int = Field(..., gt=0)
|
||||||
|
first_name: str | None = None
|
||||||
|
last_name: str | None = None
|
||||||
|
username: str | None = None
|
||||||
|
photo_url: str | None = None
|
||||||
|
auth_date: int
|
||||||
|
hash: str
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramMiniAppAuthRequest(BaseModel):
|
||||||
|
init_data: str = Field(..., description="Raw initData query string from Telegram.WebApp")
|
||||||
|
|
||||||
|
|
||||||
|
class AuthResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
expires_in: int
|
||||||
|
user_id: str
|
||||||
|
telegram_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class TokenIntrospectResponse(BaseModel):
|
||||||
|
sub: str
|
||||||
|
telegram_id: int
|
||||||
|
type: str
|
||||||
|
exp: int
|
||||||
|
|
||||||
|
|
||||||
|
class WebUserPublic(BaseModel):
|
||||||
|
"""User profile subset safe to return to the webUI."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
email: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
telegram_id: int | None = None
|
||||||
|
credits_left: int
|
||||||
|
is_active: bool
|
||||||
|
created_at: dt.datetime
|
||||||
|
|
||||||
|
|
||||||
|
class MeResponse(TokenIntrospectResponse):
|
||||||
|
"""Extends the introspection shape with web-user fields (additive)."""
|
||||||
|
|
||||||
|
email: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
credits_left: int = 0
|
||||||
|
is_active: bool = True
|
||||||
|
created_at: dt.datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
name: str = Field(..., min_length=1, max_length=128)
|
||||||
|
password: str = Field(..., min_length=8, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(..., min_length=1, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenPairResponse(BaseModel):
|
||||||
|
"""JWT pair returned by register/login."""
|
||||||
|
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
expires_in: int # access TTL in seconds
|
||||||
|
user: WebUserPublic
|
||||||
|
|
||||||
|
|
||||||
|
class LogoutRequest(BaseModel):
|
||||||
|
refresh_token: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ForgotPasswordRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class ResetPasswordRequest(BaseModel):
|
||||||
|
token: str = Field(..., min_length=1, max_length=256)
|
||||||
|
password: str = Field(..., min_length=8, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class OkResponse(BaseModel):
|
||||||
|
"""Generic `{ok: true, ...}` payload for state-mutating auth endpoints."""
|
||||||
|
|
||||||
|
ok: bool = True
|
||||||
|
detail: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyRpInfo(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
origin: str
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyUserInfo(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
displayName: str
|
||||||
|
|
||||||
|
|
||||||
|
class PubKeyCredParam(BaseModel):
|
||||||
|
alg: int
|
||||||
|
type: str
|
||||||
|
|
||||||
|
|
||||||
|
class CredDescriptor(BaseModel):
|
||||||
|
id: str
|
||||||
|
type: str
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticatorSelectionInfo(BaseModel):
|
||||||
|
residentKey: str
|
||||||
|
userVerification: str
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyRegistrationOptions(BaseModel):
|
||||||
|
"""PublicKeyCredentialCreationOptions — matches the frontend zod schema."""
|
||||||
|
|
||||||
|
challenge: str
|
||||||
|
rp: PasskeyRpInfo
|
||||||
|
user: PasskeyUserInfo
|
||||||
|
pubKeyCredParams: list[PubKeyCredParam]
|
||||||
|
timeout: int
|
||||||
|
attestation: str
|
||||||
|
excludeCredentials: list[CredDescriptor]
|
||||||
|
authenticatorSelection: AuthenticatorSelectionInfo
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyAuthenticationOptions(BaseModel):
|
||||||
|
"""PublicKeyCredentialRequestOptions — matches the frontend zod schema."""
|
||||||
|
|
||||||
|
challenge: str
|
||||||
|
timeout: int
|
||||||
|
rpId: str
|
||||||
|
allowCredentials: list[CredDescriptor]
|
||||||
|
userVerification: str
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyCredentialPublic(BaseModel):
|
||||||
|
"""Stored credential — matches passkeyCredentialSchema on the frontend."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
credentialID: str
|
||||||
|
credentialPublicKey: str
|
||||||
|
counter: int
|
||||||
|
userId: str
|
||||||
|
deviceName: str
|
||||||
|
createdAt: dt.datetime
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyRegistrationFinishResponse(BaseModel):
|
||||||
|
verified: bool
|
||||||
|
credentialId: str
|
||||||
|
|
||||||
|
|
||||||
|
class SingleTokenAuthResponse(BaseModel):
|
||||||
|
"""Shared by passkey-auth finish and magic-link verify (frontend schemas)."""
|
||||||
|
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
expires_in: int
|
||||||
|
user_id: uuid.UUID
|
||||||
|
email: str
|
||||||
|
|
||||||
|
|
||||||
|
class MagicLinkResponse(BaseModel):
|
||||||
|
"""Matches magicLinkResponseSchema on the frontend."""
|
||||||
|
|
||||||
|
success: bool = True
|
||||||
|
message: str
|
||||||
|
expires_in: int # link TTL in seconds
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyRegisterStartRequest(BaseModel):
|
||||||
|
device_name: str | None = Field(default=None, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyRegistrationCredentialData(BaseModel):
|
||||||
|
"""`navigator.credentials.create()` result, base64url-encoded."""
|
||||||
|
|
||||||
|
clientDataJSON: str
|
||||||
|
attestationObject: str
|
||||||
|
transports: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyRegistrationCredentialPayload(BaseModel):
|
||||||
|
id: str
|
||||||
|
rawId: str
|
||||||
|
type: str
|
||||||
|
response: PasskeyRegistrationCredentialData
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyRegisterFinishRequest(BaseModel):
|
||||||
|
credential: PasskeyRegistrationCredentialPayload
|
||||||
|
device_name: str | None = Field(default=None, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyAuthenticationCredentialData(BaseModel):
|
||||||
|
"""`navigator.credentials.get()` result, base64url-encoded."""
|
||||||
|
|
||||||
|
clientDataJSON: str
|
||||||
|
authenticatorData: str
|
||||||
|
signature: str
|
||||||
|
userHandle: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyAuthenticationCredentialPayload(BaseModel):
|
||||||
|
id: str
|
||||||
|
rawId: str
|
||||||
|
type: str
|
||||||
|
response: PasskeyAuthenticationCredentialData
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyAuthenticateFinishRequest(BaseModel):
|
||||||
|
challenge: str = Field(..., min_length=16, max_length=512)
|
||||||
|
credential: PasskeyAuthenticationCredentialPayload
|
||||||
|
|
||||||
|
|
||||||
|
class MagicLinkRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class MagicLinkVerifyRequest(BaseModel):
|
||||||
|
token: str = Field(..., min_length=1, max_length=256)
|
||||||
59
src/contract_check/api/schemas/b2b.py
Normal file
59
src/contract_check/api/schemas/b2b.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
"""B2B API request/response schemas."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class CreateApiKeyRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
rate_limit_rps: int | None = None
|
||||||
|
monthly_quota: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyResponse(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
rate_limit_rps: int
|
||||||
|
monthly_quota: int | None
|
||||||
|
monthly_used: int
|
||||||
|
revoked: bool
|
||||||
|
created_at: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyCreatedResponse(ApiKeyResponse):
|
||||||
|
"""Response when creating a key: raw secret is returned exactly once."""
|
||||||
|
|
||||||
|
api_key: str
|
||||||
|
|
||||||
|
|
||||||
|
class B2BUsageResponse(BaseModel):
|
||||||
|
api_key_id: str
|
||||||
|
rate_limit_rps: int
|
||||||
|
monthly_quota: int | None
|
||||||
|
monthly_used: int
|
||||||
|
requests_this_month: int
|
||||||
|
resets_at: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyMonthlyRequest(BaseModel):
|
||||||
|
month: str | None
|
||||||
|
requests: int
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyUsageDetailResponse(BaseModel):
|
||||||
|
key_id: str
|
||||||
|
name: str
|
||||||
|
rate_limit_rps: int
|
||||||
|
monthly_quota: int | None
|
||||||
|
monthly_used: int
|
||||||
|
resets_at: str | None
|
||||||
|
monthly_requests: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class RevokeApiKeyResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
revoked: bool
|
||||||
51
src/contract_check/api/schemas/common.py
Normal file
51
src/contract_check/api/schemas/common.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""Shared response schemas used across multiple route modules."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentUploadResponse(BaseModel):
|
||||||
|
"""202 response issued by document upload and B2B analyze endpoints."""
|
||||||
|
|
||||||
|
document_id: str
|
||||||
|
correlation_id: str
|
||||||
|
credits_left: int
|
||||||
|
|
||||||
|
|
||||||
|
class ReportInProgressResponse(BaseModel):
|
||||||
|
"""Polling response while a document is still being processed."""
|
||||||
|
|
||||||
|
document_id: str
|
||||||
|
status: str
|
||||||
|
stage: Any | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReportResponse(BaseModel):
|
||||||
|
"""Completed analysis report payload."""
|
||||||
|
|
||||||
|
document_id: str
|
||||||
|
status: Any = "done"
|
||||||
|
filename: Any | None = None
|
||||||
|
markdown: Any | None = None
|
||||||
|
findings: Any | None = Field(default=None, alias="findings")
|
||||||
|
model_used: Any | None = None
|
||||||
|
prompt_tokens: Any | None = None
|
||||||
|
eval_tokens: Any | None = None
|
||||||
|
latency_ms: Any | None = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentListItem(BaseModel):
|
||||||
|
document_id: str
|
||||||
|
filename: str | None = None
|
||||||
|
status: str | None = None
|
||||||
|
stage: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentListResponse(BaseModel):
|
||||||
|
documents: list[DocumentListItem]
|
||||||
11
src/contract_check/api/schemas/documents.py
Normal file
11
src/contract_check/api/schemas/documents.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
"""`/documents` route response schemas."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .common import DocumentListItem
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentListResponse(BaseModel):
|
||||||
|
documents: list[DocumentListItem]
|
||||||
24
src/contract_check/api/schemas/me.py
Normal file
24
src/contract_check/api/schemas/me.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""`/me` route request/response schemas."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class MeCreditsResponse(BaseModel):
|
||||||
|
telegram_id: int | None = None
|
||||||
|
credits_left: int
|
||||||
|
email: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BindTelegramRequest(BaseModel):
|
||||||
|
telegram_id: int = Field(..., gt=0, description="Verified Telegram user id")
|
||||||
|
|
||||||
|
|
||||||
|
class BindTelegramResponse(BaseModel):
|
||||||
|
ok: bool = True
|
||||||
|
telegram_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class SetPasswordRequest(BaseModel):
|
||||||
|
password: str = Field(..., min_length=8, max_length=128)
|
||||||
12
src/contract_check/api/schemas/reports.py
Normal file
12
src/contract_check/api/schemas/reports.py
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
"""`/reports` route response schemas.
|
||||||
|
|
||||||
|
The actual response models live in `common.py` because they are also used by
|
||||||
|
B2B reporting endpoints; this module exists only as a dedicated import target
|
||||||
|
for `api/routes/reports.py`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .common import ReportInProgressResponse, ReportResponse
|
||||||
|
|
||||||
|
__all__ = ["ReportInProgressResponse", "ReportResponse"]
|
||||||
|
|
@ -6,15 +6,17 @@ import uuid
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
from ..core.analysis.extractor import SUPPORTED_SUFFIXES
|
|
||||||
from ..core.credits import reserve_credit
|
from ..core.credits import reserve_credit
|
||||||
|
from ..core.db.repositories import DocumentRepository, JobRepository
|
||||||
|
from ..core.db.repositories.credits import CreditsRepository
|
||||||
|
from ..core.extraction.formats import SUPPORTED_SUFFIXES
|
||||||
from ..core.logging import get_logger, new_correlation_id
|
from ..core.logging import get_logger, new_correlation_id
|
||||||
from ..core.metrics import credits_reserved, documents_uploaded
|
from ..core.metrics import credits_reserved, documents_uploaded
|
||||||
from ..core.mq.messages import DocumentUploaded
|
from ..core.mq.messages import DocumentUploaded
|
||||||
from ..core.mq.topology import RK_EXTRACT
|
from ..core.mq.topology import RK_EXTRACT
|
||||||
from ..core.s3 import original_key
|
from ..core.s3 import original_key
|
||||||
|
from .schemas import DocumentUploadResponse
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from fastapi import UploadFile
|
from fastapi import UploadFile
|
||||||
|
|
@ -39,7 +41,7 @@ async def upload_and_enqueue(
|
||||||
publisher: Publisher,
|
publisher: Publisher,
|
||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
file: UploadFile,
|
file: UploadFile,
|
||||||
) -> dict[str, object]:
|
) -> DocumentUploadResponse:
|
||||||
"""Reserve credit, store file in MinIO, create document/job rows, publish to queue.
|
"""Reserve credit, store file in MinIO, create document/job rows, publish to queue.
|
||||||
|
|
||||||
Returns 202 payload: {document_id, correlation_id, credits_left}.
|
Returns 202 payload: {document_id, correlation_id, credits_left}.
|
||||||
|
|
@ -56,6 +58,10 @@ async def upload_and_enqueue(
|
||||||
detail=f"unsupported format {suffix!r}; supported: {sorted(SUPPORTED_SUFFIXES)}",
|
detail=f"unsupported format {suffix!r}; supported: {sorted(SUPPORTED_SUFFIXES)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
credits_repo = CreditsRepository(session)
|
||||||
|
doc_repo = DocumentRepository(session)
|
||||||
|
job_repo = JobRepository(session)
|
||||||
|
|
||||||
if not await reserve_credit(session, user_id):
|
if not await reserve_credit(session, user_id):
|
||||||
raise HTTPException(status_code=402, detail="no credits available")
|
raise HTTPException(status_code=402, detail="no credits available")
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
@ -75,42 +81,30 @@ async def upload_and_enqueue(
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("s3_upload_failed", document_id=str(document_id), error=str(exc))
|
log.error("s3_upload_failed", document_id=str(document_id), error=str(exc))
|
||||||
await session.execute(
|
await credits_repo.adjust(user_id, 1)
|
||||||
text("UPDATE users SET credits_left = credits_left + 1 WHERE id = :u"),
|
|
||||||
{"u": user_id},
|
|
||||||
)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
raise HTTPException(status_code=500, detail="failed to store document") from exc
|
raise HTTPException(status_code=500, detail="failed to store document") from exc
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await session.execute(
|
await doc_repo.create(
|
||||||
text(
|
document_id=document_id,
|
||||||
"INSERT INTO documents (id, user_id, s3_key, filename, mime, bytes, status) "
|
user_id=user_id,
|
||||||
"VALUES (:id, :uid, :s3, :fn, :mime, :bytes, 'queued')"
|
s3_key=s3_key,
|
||||||
),
|
filename=file.filename or "document",
|
||||||
{
|
mime=content_type,
|
||||||
"id": document_id,
|
bytes_=len(data),
|
||||||
"uid": user_id,
|
status="queued",
|
||||||
"s3": s3_key,
|
|
||||||
"fn": file.filename,
|
|
||||||
"mime": content_type,
|
|
||||||
"bytes": len(data),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
await session.execute(
|
await job_repo.create(
|
||||||
text(
|
document_id=document_id,
|
||||||
"INSERT INTO jobs (document_id, correlation_id, queue, status) "
|
correlation_id=uuid.UUID(correlation_id),
|
||||||
"VALUES (:did, :cid, 'extract', 'pending')"
|
queue="extract",
|
||||||
),
|
status="pending",
|
||||||
{"did": document_id, "cid": correlation_id},
|
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("db_enqueue_failed", document_id=str(document_id), error=str(exc))
|
log.error("db_enqueue_failed", document_id=str(document_id), error=str(exc))
|
||||||
await session.execute(
|
await credits_repo.adjust(user_id, 1)
|
||||||
text("UPDATE users SET credits_left = credits_left + 1 WHERE id = :u"),
|
|
||||||
{"u": user_id},
|
|
||||||
)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
raise HTTPException(status_code=500, detail="failed to enqueue document") from exc
|
raise HTTPException(status_code=500, detail="failed to enqueue document") from exc
|
||||||
|
|
||||||
|
|
@ -127,23 +121,14 @@ async def upload_and_enqueue(
|
||||||
documents_uploaded.inc()
|
documents_uploaded.inc()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("mq_publish_failed", document_id=str(document_id), error=str(exc))
|
log.error("mq_publish_failed", document_id=str(document_id), error=str(exc))
|
||||||
await session.execute(
|
await credits_repo.adjust(user_id, 1)
|
||||||
text("UPDATE users SET credits_left = credits_left + 1 WHERE id = :u"),
|
await doc_repo.mark_failed(document_id, stage="publish_failed")
|
||||||
{"u": user_id},
|
|
||||||
)
|
|
||||||
await session.execute(
|
|
||||||
text("UPDATE documents SET status = 'failed', stage = 'publish_failed' WHERE id = :id"),
|
|
||||||
{"id": document_id},
|
|
||||||
)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
raise HTTPException(status_code=500, detail="failed to publish job") from exc
|
raise HTTPException(status_code=500, detail="failed to publish job") from exc
|
||||||
|
|
||||||
credits = await session.execute(
|
credits_left = await credits_repo.get_balance(user_id)
|
||||||
text("SELECT credits_left FROM users WHERE id = :u"),
|
return DocumentUploadResponse(
|
||||||
{"u": user_id},
|
document_id=str(document_id),
|
||||||
|
correlation_id=str(correlation_id),
|
||||||
|
credits_left=credits_left,
|
||||||
)
|
)
|
||||||
return {
|
|
||||||
"document_id": str(document_id),
|
|
||||||
"correlation_id": str(correlation_id),
|
|
||||||
"credits_left": int(credits.scalar_one()),
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ from aiogram import Bot, F, Router
|
||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
from aiogram.types import BufferedInputFile, Document, Message, User
|
from aiogram.types import BufferedInputFile, Document, Message, User
|
||||||
|
|
||||||
|
from ..core.extraction.formats import SUPPORTED_SUFFIXES as _SUPPORTED_SUFFIXES
|
||||||
from ..core.logging import get_logger, new_correlation_id
|
from ..core.logging import get_logger, new_correlation_id
|
||||||
from ..core.rate_limit import RateLimiter
|
from ..core.rate_limit import RateLimiter
|
||||||
from .client import (
|
from .client import (
|
||||||
|
|
@ -36,8 +37,6 @@ from .config import BotSettings
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
router = Router(name="contract-check-bot")
|
router = Router(name="contract-check-bot")
|
||||||
|
|
||||||
_SUPPORTED_SUFFIXES = (".pdf", ".docx")
|
|
||||||
_CONTENT_TYPES = {
|
_CONTENT_TYPES = {
|
||||||
".pdf": "application/pdf",
|
".pdf": "application/pdf",
|
||||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,11 @@ from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ..extraction.formats import SUPPORTED_SUFFIXES
|
||||||
from ..logging import get_logger
|
from ..logging import get_logger
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
SUPPORTED_SUFFIXES = {".pdf", ".docx"}
|
|
||||||
|
|
||||||
|
|
||||||
class ExtractionError(Exception):
|
class ExtractionError(Exception):
|
||||||
"""Файл пуст, бит или формат не поддерживается."""
|
"""Файл пуст, бит или формат не поддерживается."""
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ Just like service tokens, the raw key is shown only once; we store SHA-256.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -16,11 +15,5 @@ def generate_api_key() -> str:
|
||||||
|
|
||||||
|
|
||||||
def hash_api_key(key: str) -> str:
|
def hash_api_key(key: str) -> str:
|
||||||
"""SHA-256 hex digest of an API key (store this, never the raw key)."""
|
"""SHA-256 hex digest of an API key (store this, never the raw token)."""
|
||||||
return hashlib.sha256(key.encode("utf-8")).hexdigest()
|
return hashlib.sha256(key.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def verify_api_key(key: str, key_hash: str) -> bool:
|
|
||||||
"""Constant-time check that `key` matches the stored `key_hash`."""
|
|
||||||
digest = hash_api_key(key)
|
|
||||||
return hmac.compare_digest(digest, key_hash)
|
|
||||||
|
|
|
||||||
|
|
@ -199,12 +199,13 @@ class Settings(BaseSettings):
|
||||||
chunk_size_chars: int = 10000
|
chunk_size_chars: int = 10000
|
||||||
|
|
||||||
# --- prescreen ---
|
# --- prescreen ---
|
||||||
prescreen_enabled: bool = False
|
# Defaults mirror .env.example (the documented production intent).
|
||||||
prescreen_confidence_threshold: float = 0.65
|
prescreen_enabled: bool = True
|
||||||
prescreen_high_value_threshold: float = 500_000.0
|
prescreen_confidence_threshold: float = 0.75
|
||||||
|
prescreen_high_value_threshold: float = 100_000.0
|
||||||
prescreen_auto_approve: bool = False
|
prescreen_auto_approve: bool = False
|
||||||
prescreen_llm_fallback_enabled: bool = True
|
prescreen_llm_fallback_enabled: bool = False
|
||||||
prescreen_llm_fallback_threshold: float = 0.55
|
prescreen_llm_fallback_threshold: float = 0.75
|
||||||
prescreen_llm_max_chars: int = 20_000
|
prescreen_llm_max_chars: int = 20_000
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
"""Credits & refund policy — billing invariants.
|
"""Credits & refund policy — billing invariants.
|
||||||
|
|
||||||
|
Thin public wrappers around `CreditsRepository`. The actual SQL lives in one
|
||||||
|
place (`core.db.repositories.credits`) so that issue 006 can migrate callers
|
||||||
|
without duplicating the atomic statements.
|
||||||
|
|
||||||
Reserve-on-enqueue is sacred: a credit moves ONLY on `POST /documents` in the
|
Reserve-on-enqueue is sacred: a credit moves ONLY on `POST /documents` in the
|
||||||
api, atomically (never below zero). Refund is sacred: idempotent via the
|
api, atomically (never below zero). Refund is sacred: idempotent via the
|
||||||
`documents.refunded` flag (a retried/DLQ message can never double-refund).
|
`documents.refunded` flag (a retried/DLQ message can never double-refund).
|
||||||
|
|
@ -14,40 +18,26 @@ from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import text
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from .db.enums import FailureClass, RefundPolicyLike
|
from .db.enums import FailureClass, RefundPolicyLike
|
||||||
|
from .db.repositories import CreditsRepository
|
||||||
|
from .db.repositories.credits import (
|
||||||
|
NON_REFUNDABLE_INFRA_ONLY,
|
||||||
|
should_refund,
|
||||||
|
)
|
||||||
|
|
||||||
NON_REFUNDABLE_INFRA_ONLY: frozenset[str] = frozenset({"extraction_failed"})
|
__all__ = [
|
||||||
|
"reserve_credit",
|
||||||
|
"refund_credit",
|
||||||
def should_refund(failure_class: FailureClass | str, policy: RefundPolicyLike) -> bool:
|
"should_refund",
|
||||||
"""Pure policy decision: would this failure be refunded under `policy`?
|
"NON_REFUNDABLE_INFRA_ONLY",
|
||||||
|
]
|
||||||
Under `infra_only`, user-garbage `extraction_failed` is NOT refunded
|
|
||||||
(the user pays for undetectable garbage). Everything else is.
|
|
||||||
"""
|
|
||||||
if policy == "infra_only" and failure_class in NON_REFUNDABLE_INFRA_ONLY:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def reserve_credit(session: AsyncSession, user_id: uuid.UUID) -> bool:
|
async def reserve_credit(session: AsyncSession, user_id: uuid.UUID) -> bool:
|
||||||
"""Atomically decrement credits_left by 1. Returns False if none available.
|
"""Atomically decrement credits_left by 1. Returns False if none available."""
|
||||||
|
return await CreditsRepository(session).reserve(user_id)
|
||||||
Never lets the balance go negative: the `WHERE credits_left > 0` guard makes
|
|
||||||
concurrent reservations race-safe — exactly one of N wins the row.
|
|
||||||
"""
|
|
||||||
result = await session.execute(
|
|
||||||
text(
|
|
||||||
"UPDATE users SET credits_left = credits_left - 1 "
|
|
||||||
"WHERE id = :u AND credits_left > 0 "
|
|
||||||
"RETURNING credits_left"
|
|
||||||
),
|
|
||||||
{"u": user_id},
|
|
||||||
)
|
|
||||||
return result.first() is not None
|
|
||||||
|
|
||||||
|
|
||||||
async def refund_credit(
|
async def refund_credit(
|
||||||
|
|
@ -56,29 +46,5 @@ async def refund_credit(
|
||||||
failure_class: FailureClass | str,
|
failure_class: FailureClass | str,
|
||||||
policy: RefundPolicyLike,
|
policy: RefundPolicyLike,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Refund one credit for a failed document, once. Returns False if skipped.
|
"""Refund one credit for a failed document, once. Returns False if skipped."""
|
||||||
|
return await CreditsRepository(session).refund(document_id, failure_class, policy)
|
||||||
Idempotent: a second call (retried message, requeue) finds `refunded = TRUE`
|
|
||||||
and returns False without crediting again. Under `infra_only`, user-garbage
|
|
||||||
`extraction_failed` is NOT refunded (the user pays for undetectable garbage).
|
|
||||||
"""
|
|
||||||
if not should_refund(failure_class, policy):
|
|
||||||
return False
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
text(
|
|
||||||
"UPDATE users SET credits_left = credits_left + 1 "
|
|
||||||
"WHERE id = (SELECT user_id FROM documents "
|
|
||||||
" WHERE id = :d AND refunded = FALSE) "
|
|
||||||
"RETURNING id"
|
|
||||||
),
|
|
||||||
{"d": document_id},
|
|
||||||
)
|
|
||||||
if result.first() is None:
|
|
||||||
return False # already refunded (idempotent) or document missing
|
|
||||||
|
|
||||||
await session.execute(
|
|
||||||
text("UPDATE documents SET refunded = TRUE WHERE id = :d"),
|
|
||||||
{"d": document_id},
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,25 @@ from __future__ import annotations
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
# documents.status
|
# documents.status
|
||||||
DocStatus = Literal["queued", "extracting", "ocr", "analyzing", "done", "failed"]
|
DocStatus = Literal[
|
||||||
DOC_STATUSES: tuple[str, ...] = (
|
|
||||||
"queued",
|
"queued",
|
||||||
"extracting",
|
"extracting",
|
||||||
|
"prescreening",
|
||||||
"ocr",
|
"ocr",
|
||||||
"analyzing",
|
"analyzing",
|
||||||
"done",
|
"done",
|
||||||
"failed",
|
"failed",
|
||||||
|
"manual_review",
|
||||||
|
]
|
||||||
|
DOC_STATUSES: tuple[str, ...] = (
|
||||||
|
"queued",
|
||||||
|
"extracting",
|
||||||
|
"prescreening",
|
||||||
|
"ocr",
|
||||||
|
"analyzing",
|
||||||
|
"done",
|
||||||
|
"failed",
|
||||||
|
"manual_review",
|
||||||
)
|
)
|
||||||
DOC_TERMINAL: tuple[str, ...] = ("done", "failed")
|
DOC_TERMINAL: tuple[str, ...] = ("done", "failed")
|
||||||
|
|
||||||
|
|
@ -25,8 +36,12 @@ JobStatus = Literal["pending", "running", "retrying", "dlq", "done"]
|
||||||
JOB_STATUSES: tuple[str, ...] = ("pending", "running", "retrying", "dlq", "done")
|
JOB_STATUSES: tuple[str, ...] = ("pending", "running", "retrying", "dlq", "done")
|
||||||
|
|
||||||
# jobs.queue
|
# jobs.queue
|
||||||
QueueName = Literal["extract", "analyze"]
|
QueueName = Literal["extract", "prescreen", "analyze"]
|
||||||
QUEUE_NAMES: tuple[str, ...] = ("extract", "analyze")
|
QUEUE_NAMES: tuple[str, ...] = ("extract", "prescreen", "analyze")
|
||||||
|
|
||||||
|
# prescreen_results.routing_decision
|
||||||
|
RoutingDecision = Literal["auto_approve", "manual_review", "deep_analysis"]
|
||||||
|
ROUTING_DECISIONS: tuple[str, ...] = ("auto_approve", "manual_review", "deep_analysis")
|
||||||
|
|
||||||
# service_tokens.adapter
|
# service_tokens.adapter
|
||||||
AdapterName = Literal["bot", "web", "cli"]
|
AdapterName = Literal["bot", "web", "cli"]
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,27 @@
|
||||||
"""SQLAlchemy 2 declarative models — the 7 production tables.
|
"""SQLAlchemy 2 declarative models — the 10 production tables.
|
||||||
|
|
||||||
Tables: users, documents, reports, jobs, service_tokens, invoices (stub),
|
Tables: users, documents, reports, jobs, service_tokens, invoices (stub),
|
||||||
passkey_credentials. See docs/ARCHITECTURE.md §7. UUIDs default to
|
api_keys, api_key_requests, passkey_credentials, prescreen_results.
|
||||||
gen_random_uuid() server-side (built into Postgres 13+, no extension needed
|
See docs/ARCHITECTURE.md §7. UUIDs default to gen_random_uuid() server-side
|
||||||
for pg16).
|
(built into Postgres 13+, no extension needed for pg16).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import uuid
|
import uuid
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
BigInteger,
|
BigInteger,
|
||||||
Boolean,
|
Boolean,
|
||||||
CheckConstraint,
|
CheckConstraint,
|
||||||
|
Date,
|
||||||
DateTime,
|
DateTime,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Index,
|
Index,
|
||||||
Integer,
|
Integer,
|
||||||
|
Numeric,
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
|
|
@ -59,11 +62,21 @@ class User(Base):
|
||||||
role: Mapped[str] = mapped_column(
|
role: Mapped[str] = mapped_column(
|
||||||
String, nullable=False, default="user", server_default=text("'user'")
|
String, nullable=False, default="user", server_default=text("'user'")
|
||||||
)
|
)
|
||||||
telegram_profile_json: Mapped[str | None] = mapped_column(Text)
|
telegram_profile_json: Mapped[str | None] = mapped_column(
|
||||||
telegram_verified: Mapped[bool] = mapped_column(
|
Text,
|
||||||
Boolean, nullable=False, default=True, server_default=text("true")
|
comment="Snapshot of Telegram first_name/last_name/username/language_code",
|
||||||
|
)
|
||||||
|
telegram_verified: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
nullable=False,
|
||||||
|
default=True,
|
||||||
|
server_default=text("true"),
|
||||||
|
comment="Telegram user passed profile guard; false = admin review",
|
||||||
|
)
|
||||||
|
telegram_bound_at: Mapped[dt.datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
comment="When telegram_id was first set (auto-created or bound)",
|
||||||
)
|
)
|
||||||
telegram_bound_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True))
|
|
||||||
created_at: Mapped[dt.datetime] = mapped_column(
|
created_at: Mapped[dt.datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|
@ -77,6 +90,9 @@ class User(Base):
|
||||||
name="users_identity_present",
|
name="users_identity_present",
|
||||||
),
|
),
|
||||||
CheckConstraint("role IN ('user', 'admin')", name="users_role_check"),
|
CheckConstraint("role IN ('user', 'admin')", name="users_role_check"),
|
||||||
|
Index("users_role_idx", "role"),
|
||||||
|
Index("users_telegram_verified_idx", "telegram_verified"),
|
||||||
|
Index("users_telegram_bound_idx", "telegram_bound_at"),
|
||||||
)
|
)
|
||||||
|
|
||||||
documents: Mapped[list[Document]] = relationship(
|
documents: Mapped[list[Document]] = relationship(
|
||||||
|
|
@ -168,16 +184,20 @@ class Document(Base):
|
||||||
back_populates="document", cascade="all, delete-orphan", uselist=False
|
back_populates="document", cascade="all, delete-orphan", uselist=False
|
||||||
)
|
)
|
||||||
jobs: Mapped[list[Job]] = relationship(back_populates="document", cascade="all, delete-orphan")
|
jobs: Mapped[list[Job]] = relationship(back_populates="document", cascade="all, delete-orphan")
|
||||||
|
prescreen_results: Mapped[list[PrescreenResult]] = relationship(
|
||||||
|
back_populates="document", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
api_key_requests: Mapped[list[ApiKeyRequest]] = relationship(
|
api_key_requests: Mapped[list[ApiKeyRequest]] = relationship(
|
||||||
back_populates="document", cascade="all, delete-orphan"
|
back_populates="document", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
CheckConstraint(
|
CheckConstraint(
|
||||||
"status IN ('queued','extracting','ocr','analyzing','done','failed')",
|
"status IN ('queued','extracting','prescreening','ocr','analyzing'"
|
||||||
|
",'done','failed','manual_review')",
|
||||||
name="documents_status_check",
|
name="documents_status_check",
|
||||||
),
|
),
|
||||||
Index("documents_user_created_idx", "user_id", "created_at"),
|
Index("documents_user_created_idx", "user_id", text("created_at DESC")),
|
||||||
Index("documents_status_idx", "status"),
|
Index("documents_status_idx", "status"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -202,6 +222,15 @@ class Report(Base):
|
||||||
prompt_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
prompt_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
eval_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
eval_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
latency_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
latency_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
prescreen_result_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("prescreen_results.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
prescreen_meta: Mapped[dict[str, object] | None] = mapped_column(
|
||||||
|
JSONB,
|
||||||
|
comment="Serialized PrescreenCompleted metadata used by the analyzer",
|
||||||
|
)
|
||||||
created_at: Mapped[dt.datetime] = mapped_column(
|
created_at: Mapped[dt.datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|
@ -209,6 +238,74 @@ class Report(Base):
|
||||||
document: Mapped[Document] = relationship(back_populates="report")
|
document: Mapped[Document] = relationship(back_populates="report")
|
||||||
|
|
||||||
|
|
||||||
|
class PrescreenResult(Base):
|
||||||
|
"""Deterministic regex-extraction result + routing decision (migration 0006).
|
||||||
|
|
||||||
|
Written by worker_prescreen between extract and analyze; feeds the
|
||||||
|
analyzer via ``reports.prescreen_meta`` when routed to deep_analysis.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "prescreen_results"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
server_default=text("gen_random_uuid()"),
|
||||||
|
)
|
||||||
|
document_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("documents.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
correlation_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||||
|
contract_type: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
party_a: Mapped[str | None] = mapped_column(Text)
|
||||||
|
party_b: Mapped[str | None] = mapped_column(Text)
|
||||||
|
total_amount: Mapped[Decimal | None] = mapped_column(Numeric(18, 2))
|
||||||
|
currency: Mapped[str | None] = mapped_column(String(8))
|
||||||
|
start_date: Mapped[dt.date | None] = mapped_column(Date)
|
||||||
|
end_date: Mapped[dt.date | None] = mapped_column(Date)
|
||||||
|
has_penalty_clause: Mapped[bool | None] = mapped_column(Boolean)
|
||||||
|
has_termination_clause: Mapped[bool | None] = mapped_column(Boolean)
|
||||||
|
has_arbitration: Mapped[bool | None] = mapped_column(Boolean)
|
||||||
|
confidence_score: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(4, 3), comment="Field-coverage ratio 0.000-1.000"
|
||||||
|
)
|
||||||
|
routing_decision: Mapped[str] = mapped_column(
|
||||||
|
String(32), nullable=False, server_default=text("'manual_review'")
|
||||||
|
)
|
||||||
|
prescreened_at: Mapped[dt.datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
processing_ms: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
extractor_version: Mapped[str] = mapped_column(
|
||||||
|
String(32), nullable=False, server_default=text("'regex-v1'")
|
||||||
|
)
|
||||||
|
auto_summary: Mapped[str | None] = mapped_column(Text)
|
||||||
|
auto_findings: Mapped[list[object]] = mapped_column(
|
||||||
|
JSONB, nullable=False, server_default=text("'[]'")
|
||||||
|
)
|
||||||
|
error_message: Mapped[str | None] = mapped_column(Text)
|
||||||
|
retry_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
|
||||||
|
|
||||||
|
document: Mapped[Document] = relationship(back_populates="prescreen_results")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"routing_decision IN ('auto_approve','manual_review','deep_analysis')",
|
||||||
|
name="prescreen_results_routing_decision_check",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"confidence_score IS NULL OR (confidence_score >= 0 AND confidence_score <= 1)",
|
||||||
|
name="prescreen_results_confidence_check",
|
||||||
|
),
|
||||||
|
CheckConstraint("retry_count >= 0", name="prescreen_results_retry_count_nonneg"),
|
||||||
|
Index("prescreen_results_routing_idx", "routing_decision"),
|
||||||
|
Index("prescreen_results_confidence_idx", "confidence_score"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Job(Base):
|
class Job(Base):
|
||||||
__tablename__ = "jobs"
|
__tablename__ = "jobs"
|
||||||
|
|
||||||
|
|
@ -247,11 +344,12 @@ class Job(Base):
|
||||||
document: Mapped[Document] = relationship(back_populates="jobs")
|
document: Mapped[Document] = relationship(back_populates="jobs")
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
CheckConstraint("queue IN ('extract','analyze')", name="jobs_queue_check"),
|
CheckConstraint("queue IN ('extract','prescreen','analyze')", name="jobs_queue_check"),
|
||||||
CheckConstraint(
|
CheckConstraint(
|
||||||
"status IN ('pending','running','retrying','dlq','done')",
|
"status IN ('pending','running','retrying','dlq','done')",
|
||||||
name="jobs_status_check",
|
name="jobs_status_check",
|
||||||
),
|
),
|
||||||
|
UniqueConstraint("document_id", "queue", name="jobs_document_queue_unique"),
|
||||||
Index("jobs_correlation_idx", "correlation_id"),
|
Index("jobs_correlation_idx", "correlation_id"),
|
||||||
Index("jobs_document_idx", "document_id"),
|
Index("jobs_document_idx", "document_id"),
|
||||||
)
|
)
|
||||||
|
|
@ -397,5 +495,5 @@ class Invoice(Base):
|
||||||
"status IN ('draft','pending','succeeded','cancelled','refunded')",
|
"status IN ('draft','pending','succeeded','cancelled','refunded')",
|
||||||
name="invoices_status_check",
|
name="invoices_status_check",
|
||||||
),
|
),
|
||||||
Index("invoices_user_idx", "user_id", "created_at"),
|
Index("invoices_user_idx", "user_id", text("created_at DESC")),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
32
src/contract_check/core/db/repositories/__init__.py
Normal file
32
src/contract_check/core/db/repositories/__init__.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""Lightweight repository layer over SQLAlchemy 2 models.
|
||||||
|
|
||||||
|
Each repository owns one table/cluster of tables and receives an open
|
||||||
|
``AsyncSession`` in its constructor. Callers retain transaction control unless a
|
||||||
|
method is explicitly documented as committing (e.g. credit operations that must
|
||||||
|
run in their own statement).
|
||||||
|
|
||||||
|
This package deliberately avoids generic base classes, service-locator magic,
|
||||||
|
and EXPLAIN logging. See .scratch/refactor-base-api-alignment/issues/005.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .api_keys import ApiKeyRepository
|
||||||
|
from .credits import CreditsRepository
|
||||||
|
from .documents import DocumentRepository
|
||||||
|
from .jobs import JobRepository
|
||||||
|
from .passkeys import PasskeyRepository
|
||||||
|
from .prescreen_results import PrescreenResultRepository
|
||||||
|
from .reports import ReportRepository
|
||||||
|
from .service_tokens import ServiceTokenRepository
|
||||||
|
from .users import UserRepository
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ApiKeyRepository",
|
||||||
|
"CreditsRepository",
|
||||||
|
"DocumentRepository",
|
||||||
|
"JobRepository",
|
||||||
|
"PasskeyRepository",
|
||||||
|
"PrescreenResultRepository",
|
||||||
|
"ReportRepository",
|
||||||
|
"ServiceTokenRepository",
|
||||||
|
"UserRepository",
|
||||||
|
]
|
||||||
220
src/contract_check/core/db/repositories/api_keys.py
Normal file
220
src/contract_check/core/db/repositories/api_keys.py
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
"""B2B API key data access.
|
||||||
|
|
||||||
|
Includes the key-lookup + monthly-quota reset + request ledger paths.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..models import ApiKey, ApiKeyRequest
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ApiKeyAuth:
|
||||||
|
"""Validated API key metadata."""
|
||||||
|
|
||||||
|
api_key_id: uuid.UUID
|
||||||
|
user_id: uuid.UUID
|
||||||
|
rate_limit_rps: int
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyRepository:
|
||||||
|
"""All B2B API-key reads/writes."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def get_by_hash(self, key_hash: str) -> ApiKey | None:
|
||||||
|
"""Return the full key row by hash."""
|
||||||
|
result = await self._session.execute(
|
||||||
|
text(
|
||||||
|
"SELECT id, user_id, name, key_hash, rate_limit_rps, monthly_quota, "
|
||||||
|
"monthly_used, resets_at, revoked, created_at, last_used_at "
|
||||||
|
"FROM api_keys WHERE key_hash = :h"
|
||||||
|
),
|
||||||
|
{"h": key_hash},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return ApiKey(
|
||||||
|
id=row[0],
|
||||||
|
user_id=row[1],
|
||||||
|
name=row[2],
|
||||||
|
key_hash=row[3],
|
||||||
|
rate_limit_rps=row[4],
|
||||||
|
monthly_quota=row[5],
|
||||||
|
monthly_used=row[6],
|
||||||
|
resets_at=row[7],
|
||||||
|
revoked=row[8],
|
||||||
|
created_at=row[9],
|
||||||
|
last_used_at=row[10],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def maybe_reset_monthly_quota(self, api_key_id: uuid.UUID) -> None:
|
||||||
|
"""Reset the quota window if it has expired."""
|
||||||
|
await self._session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE api_keys "
|
||||||
|
"SET monthly_used = 0, resets_at = now() + interval '1 month' "
|
||||||
|
"WHERE id = :k AND resets_at < now()"
|
||||||
|
),
|
||||||
|
{"k": api_key_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_monthly_quota_state(self, api_key_id: uuid.UUID) -> tuple[int | None, int]:
|
||||||
|
"""Return (monthly_quota, monthly_used) after any auto-reset."""
|
||||||
|
await self.maybe_reset_monthly_quota(api_key_id)
|
||||||
|
result = await self._session.execute(
|
||||||
|
text("SELECT monthly_quota, monthly_used FROM api_keys WHERE id = :k"),
|
||||||
|
{"k": api_key_id},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
if row is None:
|
||||||
|
return None, 0
|
||||||
|
return row[0], row[1]
|
||||||
|
|
||||||
|
async def bump_last_used(self, api_key_id: uuid.UUID) -> None:
|
||||||
|
await self._session.execute(
|
||||||
|
text("UPDATE api_keys SET last_used_at = now() WHERE id = :id"),
|
||||||
|
{"id": api_key_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def record_request(self, api_key_id: uuid.UUID, document_id: uuid.UUID) -> None:
|
||||||
|
await self._session.execute(
|
||||||
|
text("INSERT INTO api_key_requests (api_key_id, document_id) VALUES (:k, :d)"),
|
||||||
|
{"k": api_key_id, "d": document_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def bump_monthly_used(self, api_key_id: uuid.UUID) -> None:
|
||||||
|
await self._session.execute(
|
||||||
|
text("UPDATE api_keys SET monthly_used = monthly_used + 1 WHERE id = :k"),
|
||||||
|
{"k": api_key_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def count_recent_requests(self, api_key_id: uuid.UUID, since: dt.datetime) -> int:
|
||||||
|
result = await self._session.execute(
|
||||||
|
text(
|
||||||
|
"SELECT count(*) FROM api_key_requests "
|
||||||
|
"WHERE api_key_id = :k AND created_at > :since"
|
||||||
|
),
|
||||||
|
{"k": api_key_id, "since": since},
|
||||||
|
)
|
||||||
|
return int(result.scalar_one())
|
||||||
|
|
||||||
|
async def get_by_id(self, api_key_id: uuid.UUID) -> ApiKey | None:
|
||||||
|
return await self._session.get(ApiKey, api_key_id)
|
||||||
|
|
||||||
|
async def list_for_user(self, user_id: uuid.UUID) -> list[ApiKey]:
|
||||||
|
result = await self._session.execute(
|
||||||
|
select(ApiKey).where(ApiKey.user_id == user_id).order_by(ApiKey.created_at.desc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
name: str,
|
||||||
|
key_hash: str,
|
||||||
|
rate_limit_rps: int = 3,
|
||||||
|
monthly_quota: int | None = None,
|
||||||
|
) -> ApiKey:
|
||||||
|
result = await self._session.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO api_keys "
|
||||||
|
"(user_id, name, key_hash, rate_limit_rps, monthly_quota) "
|
||||||
|
"VALUES (:u, :n, :h, :r, :q) "
|
||||||
|
"RETURNING id, user_id, name, key_hash, rate_limit_rps, monthly_quota, "
|
||||||
|
"monthly_used, resets_at, revoked, created_at, last_used_at"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"u": user_id,
|
||||||
|
"n": name,
|
||||||
|
"h": key_hash,
|
||||||
|
"r": rate_limit_rps,
|
||||||
|
"q": monthly_quota,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
assert row is not None
|
||||||
|
return ApiKey(
|
||||||
|
id=row[0],
|
||||||
|
user_id=row[1],
|
||||||
|
name=row[2],
|
||||||
|
key_hash=row[3],
|
||||||
|
rate_limit_rps=row[4],
|
||||||
|
monthly_quota=row[5],
|
||||||
|
monthly_used=row[6],
|
||||||
|
resets_at=row[7],
|
||||||
|
revoked=row[8],
|
||||||
|
created_at=row[9],
|
||||||
|
last_used_at=row[10],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def revoke(self, api_key_id: uuid.UUID) -> None:
|
||||||
|
await self._session.execute(
|
||||||
|
text("UPDATE api_keys SET revoked = TRUE WHERE id = :k"),
|
||||||
|
{"k": api_key_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_usage_by_key(
|
||||||
|
self,
|
||||||
|
api_key_id: uuid.UUID,
|
||||||
|
*,
|
||||||
|
since: dt.datetime | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[ApiKeyRequest]:
|
||||||
|
where_parts = ["api_key_id = :k"]
|
||||||
|
params: dict[str, Any] = {"k": api_key_id, "limit": limit, "offset": offset}
|
||||||
|
if since is not None:
|
||||||
|
where_parts.append("created_at > :since")
|
||||||
|
params["since"] = since
|
||||||
|
where = " AND ".join(where_parts)
|
||||||
|
result = await self._session.execute(
|
||||||
|
text(
|
||||||
|
f"SELECT id, api_key_id, document_id, created_at FROM api_key_requests "
|
||||||
|
f"WHERE {where} ORDER BY created_at DESC LIMIT :limit OFFSET :offset"
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
ApiKeyRequest(id=r[0], api_key_id=r[1], document_id=r[2], created_at=r[3])
|
||||||
|
for r in result.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
async def count_usage_by_key(
|
||||||
|
self,
|
||||||
|
api_key_id: uuid.UUID,
|
||||||
|
since: dt.datetime | None = None,
|
||||||
|
) -> int:
|
||||||
|
where_parts = ["api_key_id = :k"]
|
||||||
|
params: dict[str, Any] = {"k": api_key_id}
|
||||||
|
if since is not None:
|
||||||
|
where_parts.append("created_at > :since")
|
||||||
|
params["since"] = since
|
||||||
|
where = " AND ".join(where_parts)
|
||||||
|
result = await self._session.execute(
|
||||||
|
text(f"SELECT count(*) FROM api_key_requests WHERE {where}"),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
return int(result.scalar_one())
|
||||||
|
|
||||||
|
async def count_requests_this_month(self, api_key_id: uuid.UUID) -> int:
|
||||||
|
"""Count requests since the start of the current calendar month."""
|
||||||
|
result = await self._session.execute(
|
||||||
|
text(
|
||||||
|
"SELECT count(*) FROM api_key_requests "
|
||||||
|
"WHERE api_key_id = :k AND created_at >= DATE_TRUNC('month', now())"
|
||||||
|
),
|
||||||
|
{"k": api_key_id},
|
||||||
|
)
|
||||||
|
return int(result.scalar_one())
|
||||||
97
src/contract_check/core/db/repositories/credits.py
Normal file
97
src/contract_check/core/db/repositories/credits.py
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
"""Credits & refund repository.
|
||||||
|
|
||||||
|
The atomic ``UPDATE ... WHERE credits_left > 0 RETURNING`` and the idempotent
|
||||||
|
refund block are kept as raw SQL because their concurrency semantics are
|
||||||
|
load-bearing. Everything else is policy logic in ``core/credits.py``.
|
||||||
|
|
||||||
|
This module is intentionally stateless: callers own transactions (commit after
|
||||||
|
calling reserve/refund), except for the pure ``should_refund`` policy helper.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..enums import FailureClass, RefundPolicyLike
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
pass
|
||||||
|
|
||||||
|
NON_REFUNDABLE_INFRA_ONLY: frozenset[str] = frozenset({"extraction_failed"})
|
||||||
|
|
||||||
|
|
||||||
|
def should_refund(failure_class: FailureClass | str, policy: RefundPolicyLike) -> bool:
|
||||||
|
"""Pure policy decision: would this failure be refunded under ``policy``?
|
||||||
|
|
||||||
|
Under ``infra_only``, user-garbage ``extraction_failed`` is NOT refunded
|
||||||
|
(the user pays for undetectable garbage). Everything else is.
|
||||||
|
"""
|
||||||
|
if policy == "infra_only" and failure_class in NON_REFUNDABLE_INFRA_ONLY:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class CreditsRepository:
|
||||||
|
"""Atomic credit reservations and idempotent refunds."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def reserve(self, user_id: uuid.UUID) -> bool:
|
||||||
|
"""Decrement credits_left by 1 if balance > 0. Returns True on success."""
|
||||||
|
result = await self._session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE users SET credits_left = credits_left - 1 "
|
||||||
|
"WHERE id = :u AND credits_left > 0 "
|
||||||
|
"RETURNING credits_left"
|
||||||
|
),
|
||||||
|
{"u": user_id},
|
||||||
|
)
|
||||||
|
return result.first() is not None
|
||||||
|
|
||||||
|
async def refund(
|
||||||
|
self,
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
failure_class: FailureClass | str,
|
||||||
|
policy: RefundPolicyLike,
|
||||||
|
) -> bool:
|
||||||
|
"""Refund one credit once, idempotent via ``documents.refunded``."""
|
||||||
|
if not should_refund(failure_class, policy):
|
||||||
|
return False
|
||||||
|
|
||||||
|
result = await self._session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE users SET credits_left = credits_left + 1 "
|
||||||
|
"WHERE id = (SELECT user_id FROM documents "
|
||||||
|
" WHERE id = :d AND refunded = FALSE) "
|
||||||
|
"RETURNING id"
|
||||||
|
),
|
||||||
|
{"d": document_id},
|
||||||
|
)
|
||||||
|
if result.first() is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await self._session.execute(
|
||||||
|
text("UPDATE documents SET refunded = TRUE WHERE id = :d"),
|
||||||
|
{"d": document_id},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def adjust(self, user_id: uuid.UUID, delta: int) -> None:
|
||||||
|
"""Admin/manual credit adjustment that never drops below zero."""
|
||||||
|
await self._session.execute(
|
||||||
|
text("UPDATE users SET credits_left = greatest(0, credits_left + :d) WHERE id = :u"),
|
||||||
|
{"d": delta, "u": user_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_balance(self, user_id: uuid.UUID) -> int:
|
||||||
|
result = await self._session.execute(
|
||||||
|
text("SELECT credits_left FROM users WHERE id = :u"),
|
||||||
|
{"u": user_id},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
return int(row[0]) if row else 0
|
||||||
226
src/contract_check/core/db/repositories/documents.py
Normal file
226
src/contract_check/core/db/repositories/documents.py
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
"""Document data access.
|
||||||
|
|
||||||
|
Keeps raw ``SELECT ... FOR UPDATE`` used by workers for race-safe idempotency,
|
||||||
|
but otherwise uses the ORM model now that it matches the schema.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..models import Document
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DocumentStatus:
|
||||||
|
"""B2B API status payload (document + optional report)."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
status: str
|
||||||
|
stage: str | None
|
||||||
|
filename: str
|
||||||
|
created_at: Any
|
||||||
|
markdown: str | None
|
||||||
|
content_json: Any
|
||||||
|
model_used: str | None
|
||||||
|
prompt_tokens: int | None
|
||||||
|
eval_tokens: int | None
|
||||||
|
latency_ms: int | None
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentRepository:
|
||||||
|
"""All document reads/writes."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def get_status(self, document_id: uuid.UUID) -> str | None:
|
||||||
|
"""Plain status lookup (no lock)."""
|
||||||
|
result = await self._session.execute(
|
||||||
|
text("SELECT status FROM documents WHERE id = :d"),
|
||||||
|
{"d": document_id},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
return str(row[0]) if row else None
|
||||||
|
|
||||||
|
async def get_status_for_update(self, document_id: uuid.UUID) -> str | None:
|
||||||
|
"""Worker idempotency check with row lock."""
|
||||||
|
result = await self._session.execute(
|
||||||
|
text("SELECT status FROM documents WHERE id = :d FOR UPDATE"),
|
||||||
|
{"d": document_id},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
return str(row[0]) if row else None
|
||||||
|
|
||||||
|
async def get_status_and_filename_for_update(
|
||||||
|
self, document_id: uuid.UUID
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Analyze worker needs the filename for rendering while locking status."""
|
||||||
|
result = await self._session.execute(
|
||||||
|
text("SELECT status, filename FROM documents WHERE id = :d FOR UPDATE"),
|
||||||
|
{"d": document_id},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
if row is None:
|
||||||
|
return None, None
|
||||||
|
return (None if row[0] is None else str(row[0]), str(row[1]))
|
||||||
|
|
||||||
|
async def get_with_report_by_id_for_user(
|
||||||
|
self, document_id: uuid.UUID, user_id: uuid.UUID
|
||||||
|
) -> DocumentStatus | None:
|
||||||
|
"""B2B API scoped status + report lookup."""
|
||||||
|
result = await self._session.execute(
|
||||||
|
text(
|
||||||
|
"SELECT d.id, d.status, d.stage, d.filename, d.created_at, "
|
||||||
|
" r.markdown, r.content_json, r.model_used, "
|
||||||
|
" r.prompt_tokens, r.eval_tokens, r.latency_ms "
|
||||||
|
"FROM documents d "
|
||||||
|
"LEFT JOIN reports r ON r.document_id = d.id "
|
||||||
|
"WHERE d.id = :d AND d.user_id = :u"
|
||||||
|
),
|
||||||
|
{"d": document_id, "u": user_id},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return DocumentStatus(
|
||||||
|
id=row[0],
|
||||||
|
status=row[1],
|
||||||
|
stage=row[2],
|
||||||
|
filename=row[3],
|
||||||
|
created_at=row[4],
|
||||||
|
markdown=row[5],
|
||||||
|
content_json=row[6],
|
||||||
|
model_used=row[7],
|
||||||
|
prompt_tokens=row[8],
|
||||||
|
eval_tokens=row[9],
|
||||||
|
latency_ms=row[10],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def exists(self, document_id: uuid.UUID) -> bool:
|
||||||
|
result = await self._session.execute(
|
||||||
|
text("SELECT 1 FROM documents WHERE id = :d"),
|
||||||
|
{"d": document_id},
|
||||||
|
)
|
||||||
|
return result.first() is not None
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
s3_key: str,
|
||||||
|
filename: str,
|
||||||
|
mime: str,
|
||||||
|
bytes_: int,
|
||||||
|
status: str = "queued",
|
||||||
|
) -> Document:
|
||||||
|
"""Insert a document row returning the ORM object."""
|
||||||
|
result = await self._session.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO documents (id, user_id, s3_key, filename, mime, bytes, status) "
|
||||||
|
"VALUES (:id, :uid, :s3, :fn, :mime, :bytes, :status) "
|
||||||
|
"RETURNING id, user_id, s3_key, extracted_s3_key, filename, mime, "
|
||||||
|
" bytes, status, stage, refunded, created_at, updated_at"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"id": document_id,
|
||||||
|
"uid": user_id,
|
||||||
|
"s3": s3_key,
|
||||||
|
"fn": filename,
|
||||||
|
"mime": mime,
|
||||||
|
"bytes": bytes_,
|
||||||
|
"status": status,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
assert row is not None
|
||||||
|
return Document(
|
||||||
|
id=row[0],
|
||||||
|
user_id=row[1],
|
||||||
|
s3_key=row[2],
|
||||||
|
extracted_s3_key=row[3],
|
||||||
|
filename=row[4],
|
||||||
|
mime=row[5],
|
||||||
|
bytes_=row[6],
|
||||||
|
status=row[7],
|
||||||
|
stage=row[8],
|
||||||
|
refunded=row[9],
|
||||||
|
created_at=row[10],
|
||||||
|
updated_at=row[11],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_status(
|
||||||
|
self,
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
*,
|
||||||
|
status: str | None = None,
|
||||||
|
stage: str | None = None,
|
||||||
|
extracted_s3_key: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Partial update of mutable document columns."""
|
||||||
|
fields: list[str] = []
|
||||||
|
params: dict[str, Any] = {"d": document_id}
|
||||||
|
if status is not None:
|
||||||
|
fields.append("status = :status")
|
||||||
|
params["status"] = status
|
||||||
|
if stage is not None:
|
||||||
|
fields.append("stage = :stage")
|
||||||
|
params["stage"] = stage
|
||||||
|
if extracted_s3_key is not None:
|
||||||
|
fields.append("extracted_s3_key = :key")
|
||||||
|
params["key"] = extracted_s3_key
|
||||||
|
if not fields:
|
||||||
|
return
|
||||||
|
await self._session.execute(
|
||||||
|
text(f"UPDATE documents SET {', '.join(fields)} WHERE id = :d"),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_stage(self, document_id: uuid.UUID, *, stage: str) -> None:
|
||||||
|
await self.update_status(document_id, stage=stage)
|
||||||
|
|
||||||
|
async def mark_failed(self, document_id: uuid.UUID, stage: str) -> None:
|
||||||
|
await self._session.execute(
|
||||||
|
text("UPDATE documents SET status = 'failed', stage = :stage WHERE id = :d"),
|
||||||
|
{"stage": stage, "d": document_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_by_id(self, document_id: uuid.UUID) -> Document | None:
|
||||||
|
return await self._session.get(Document, document_id)
|
||||||
|
|
||||||
|
async def list_for_user(
|
||||||
|
self, user_id: uuid.UUID, *, limit: int = 100, offset: int = 0
|
||||||
|
) -> list[Document]:
|
||||||
|
result = await self._session.execute(
|
||||||
|
select(Document)
|
||||||
|
.where(Document.user_id == user_id)
|
||||||
|
.order_by(Document.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def stats_for_user(self, user_id: uuid.UUID) -> dict[str, int]:
|
||||||
|
result = await self._session.execute(
|
||||||
|
text(
|
||||||
|
"SELECT count(*), "
|
||||||
|
" count(*) FILTER (WHERE status = 'done'), "
|
||||||
|
" count(*) FILTER (WHERE status = 'failed') "
|
||||||
|
"FROM documents WHERE user_id = :u"
|
||||||
|
),
|
||||||
|
{"u": user_id},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
if row is None:
|
||||||
|
return {"docs_total": 0, "docs_done": 0, "docs_failed": 0}
|
||||||
|
return {
|
||||||
|
"docs_total": int(row[0] or 0),
|
||||||
|
"docs_done": int(row[1] or 0),
|
||||||
|
"docs_failed": int(row[2] or 0),
|
||||||
|
}
|
||||||
175
src/contract_check/core/db/repositories/jobs.py
Normal file
175
src/contract_check/core/db/repositories/jobs.py
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
"""Job queue data access.
|
||||||
|
|
||||||
|
Uses raw SQL for upserts/ON CONFLICT and row-level state transitions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..models import Job
|
||||||
|
|
||||||
|
|
||||||
|
class JobRepository:
|
||||||
|
"""All jobs reads/writes."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def get_by_document_id_and_queue(self, document_id: uuid.UUID, queue: str) -> Job | None:
|
||||||
|
result = await self._session.execute(
|
||||||
|
text("SELECT * FROM jobs WHERE document_id = :d AND queue = :q"),
|
||||||
|
{"d": document_id, "q": queue},
|
||||||
|
)
|
||||||
|
row = result.mappings().first()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return Job(**dict(row))
|
||||||
|
|
||||||
|
async def upsert_running(
|
||||||
|
self, document_id: uuid.UUID, correlation_id: uuid.UUID, queue: str
|
||||||
|
) -> None:
|
||||||
|
"""Prescreen worker idempotently creates the prescreen row then marks running."""
|
||||||
|
await self._session.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO jobs (document_id, correlation_id, queue, status) "
|
||||||
|
"VALUES (:d, :cid, :q, 'running') "
|
||||||
|
"ON CONFLICT (document_id, queue) DO NOTHING"
|
||||||
|
),
|
||||||
|
{"d": document_id, "cid": correlation_id, "q": queue},
|
||||||
|
)
|
||||||
|
await self._session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE jobs SET status = 'running', attempts = attempts + 1 "
|
||||||
|
"WHERE document_id = :d AND queue = :q"
|
||||||
|
),
|
||||||
|
{"d": document_id, "q": queue},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def claim_start(self, document_id: uuid.UUID, queue: str) -> None:
|
||||||
|
"""Mark a job running and bump attempts."""
|
||||||
|
await self._session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE jobs SET status = 'running', attempts = attempts + 1 "
|
||||||
|
"WHERE document_id = :d AND queue = :q"
|
||||||
|
),
|
||||||
|
{"d": document_id, "q": queue},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def mark_done(self, document_id: uuid.UUID, queue: str) -> None:
|
||||||
|
await self._session.execute(
|
||||||
|
text("UPDATE jobs SET status = 'done' WHERE document_id = :d AND queue = :q"),
|
||||||
|
{"d": document_id, "q": queue},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def mark_retrying(
|
||||||
|
self,
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
queue: str,
|
||||||
|
*,
|
||||||
|
attempt: int,
|
||||||
|
failure_class: str,
|
||||||
|
error: str,
|
||||||
|
) -> None:
|
||||||
|
await self._session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE jobs SET status = 'retrying', attempts = :a, "
|
||||||
|
"last_failure_class = :fc, last_error = :err "
|
||||||
|
"WHERE document_id = :d AND queue = :q"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"a": attempt,
|
||||||
|
"fc": failure_class,
|
||||||
|
"err": error[:1000],
|
||||||
|
"d": document_id,
|
||||||
|
"q": queue,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def mark_dlq(
|
||||||
|
self,
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
queue: str,
|
||||||
|
*,
|
||||||
|
failure_class: str,
|
||||||
|
error: str,
|
||||||
|
) -> None:
|
||||||
|
await self._session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE jobs SET status = 'dlq', dlq = TRUE, "
|
||||||
|
"last_failure_class = :fc, last_error = :err "
|
||||||
|
"WHERE document_id = :d AND queue = :q"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"fc": failure_class,
|
||||||
|
"err": error[:1000],
|
||||||
|
"d": document_id,
|
||||||
|
"q": queue,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
correlation_id: uuid.UUID,
|
||||||
|
queue: str,
|
||||||
|
status: str = "pending",
|
||||||
|
) -> Job:
|
||||||
|
result = await self._session.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO jobs (document_id, correlation_id, queue, status) "
|
||||||
|
"VALUES (:d, :cid, :q, :s) "
|
||||||
|
"RETURNING id, document_id, correlation_id, queue, attempts, max_attempts, "
|
||||||
|
"last_failure_class, last_error, dlq, status, created_at, updated_at"
|
||||||
|
),
|
||||||
|
{"d": document_id, "cid": correlation_id, "q": queue, "s": status},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
assert row is not None
|
||||||
|
return Job(
|
||||||
|
id=row[0],
|
||||||
|
document_id=row[1],
|
||||||
|
correlation_id=row[2],
|
||||||
|
queue=row[3],
|
||||||
|
attempts=row[4],
|
||||||
|
max_attempts=row[5],
|
||||||
|
last_failure_class=row[6],
|
||||||
|
last_error=row[7],
|
||||||
|
dlq=row[8],
|
||||||
|
status=row[9],
|
||||||
|
created_at=row[10],
|
||||||
|
updated_at=row[11],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def count_active_by_document(self, document_id: uuid.UUID) -> int:
|
||||||
|
result = await self._session.execute(
|
||||||
|
text(
|
||||||
|
"SELECT count(*) FROM jobs "
|
||||||
|
"WHERE document_id = :d AND status IN ('pending','running','retrying')"
|
||||||
|
),
|
||||||
|
{"d": document_id},
|
||||||
|
)
|
||||||
|
return int(result.scalar_one())
|
||||||
|
|
||||||
|
async def count_dlq_by_document(self, document_id: uuid.UUID) -> int:
|
||||||
|
result = await self._session.execute(
|
||||||
|
text("SELECT count(*) FROM jobs WHERE document_id = :d AND dlq = TRUE"),
|
||||||
|
{"d": document_id},
|
||||||
|
)
|
||||||
|
return int(result.scalar_one())
|
||||||
|
|
||||||
|
async def list_for_document(
|
||||||
|
self, document_id: uuid.UUID, *, limit: int = 100, offset: int = 0
|
||||||
|
) -> list[Job]:
|
||||||
|
result = await self._session.execute(
|
||||||
|
text(
|
||||||
|
"SELECT * FROM jobs WHERE document_id = :d "
|
||||||
|
"ORDER BY created_at DESC LIMIT :limit OFFSET :offset"
|
||||||
|
),
|
||||||
|
{"d": document_id, "limit": limit, "offset": offset},
|
||||||
|
)
|
||||||
|
return [Job(**dict(row)) for row in result.mappings().all()]
|
||||||
45
src/contract_check/core/db/repositories/passkeys.py
Normal file
45
src/contract_check/core/db/repositories/passkeys.py
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
"""Passkey credential data access."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..models import PasskeyCredential
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyRepository:
|
||||||
|
"""WebAuthn passkey credential reads/writes."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def get_by_id_for_user(
|
||||||
|
self, passkey_id: uuid.UUID, user_id: uuid.UUID
|
||||||
|
) -> PasskeyCredential | None:
|
||||||
|
result = await self._session.execute(
|
||||||
|
select(PasskeyCredential).where(
|
||||||
|
PasskeyCredential.id == passkey_id,
|
||||||
|
PasskeyCredential.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
async def delete_by_id_for_user(self, passkey_id: uuid.UUID, user_id: uuid.UUID) -> bool:
|
||||||
|
result = await self._session.execute(
|
||||||
|
delete(PasskeyCredential).where(
|
||||||
|
PasskeyCredential.id == passkey_id,
|
||||||
|
PasskeyCredential.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return bool(getattr(result, "rowcount", 0) or 0)
|
||||||
|
|
||||||
|
async def list_for_user(self, user_id: uuid.UUID) -> list[PasskeyCredential]:
|
||||||
|
result = await self._session.execute(
|
||||||
|
select(PasskeyCredential)
|
||||||
|
.where(PasskeyCredential.user_id == user_id)
|
||||||
|
.order_by(PasskeyCredential.created_at.desc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
98
src/contract_check/core/db/repositories/prescreen_results.py
Normal file
98
src/contract_check/core/db/repositories/prescreen_results.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
"""Prescreen result data access."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import decimal
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..models import PrescreenResult
|
||||||
|
|
||||||
|
|
||||||
|
class PrescreenResultRepository:
|
||||||
|
"""Reads/writes for the prescreen_results table."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def create_from_completed(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
correlation_id: uuid.UUID,
|
||||||
|
contract_type: str | None,
|
||||||
|
party_a: str | None,
|
||||||
|
party_b: str | None,
|
||||||
|
total_amount: decimal.Decimal | float | int | None,
|
||||||
|
currency: str | None,
|
||||||
|
start_date: dt.date | None,
|
||||||
|
end_date: dt.date | None,
|
||||||
|
has_penalty_clause: bool | None,
|
||||||
|
has_termination_clause: bool | None,
|
||||||
|
has_arbitration: bool | None,
|
||||||
|
confidence_score: decimal.Decimal | float | int | None,
|
||||||
|
routing_decision: str,
|
||||||
|
prescreened_at: dt.datetime,
|
||||||
|
processing_ms: int,
|
||||||
|
auto_summary: str | None,
|
||||||
|
auto_findings: list[dict[str, Any]] | None,
|
||||||
|
extractor_version: str,
|
||||||
|
) -> uuid.UUID:
|
||||||
|
"""Insert a prescreen result row as worker_prescreen did previously."""
|
||||||
|
result = await self._session.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO prescreen_results "
|
||||||
|
"(document_id, correlation_id, contract_type, party_a, party_b, "
|
||||||
|
" total_amount, currency, start_date, end_date, has_penalty_clause, "
|
||||||
|
" has_termination_clause, has_arbitration, confidence_score, "
|
||||||
|
" routing_decision, prescreened_at, processing_ms, auto_summary, "
|
||||||
|
" auto_findings, extractor_version) "
|
||||||
|
"VALUES (:d, :cid, :ct, :pa, :pb, :ta, :cur, :sd, :ed, :hpc, :htc, :ha, "
|
||||||
|
" :cs, :rd, :psa, :ms, :asum, :af, :ev) "
|
||||||
|
"RETURNING id"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"d": document_id,
|
||||||
|
"cid": correlation_id,
|
||||||
|
"ct": contract_type,
|
||||||
|
"pa": party_a,
|
||||||
|
"pb": party_b,
|
||||||
|
"ta": total_amount,
|
||||||
|
"cur": currency,
|
||||||
|
"sd": start_date,
|
||||||
|
"ed": end_date,
|
||||||
|
"hpc": has_penalty_clause,
|
||||||
|
"htc": has_termination_clause,
|
||||||
|
"ha": has_arbitration,
|
||||||
|
"cs": confidence_score,
|
||||||
|
"rd": routing_decision,
|
||||||
|
"psa": prescreened_at,
|
||||||
|
"ms": processing_ms,
|
||||||
|
"asum": auto_summary,
|
||||||
|
"af": json.dumps(auto_findings or []),
|
||||||
|
"ev": extractor_version,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
assert row is not None
|
||||||
|
return row[0]
|
||||||
|
|
||||||
|
async def get_by_id(self, prescreen_result_id: uuid.UUID) -> PrescreenResult | None:
|
||||||
|
return await self._session.get(PrescreenResult, prescreen_result_id)
|
||||||
|
|
||||||
|
async def list_for_document(
|
||||||
|
self, document_id: uuid.UUID, *, limit: int = 10
|
||||||
|
) -> list[PrescreenResult]:
|
||||||
|
result = await self._session.execute(
|
||||||
|
text(
|
||||||
|
"SELECT * FROM prescreen_results WHERE document_id = :d "
|
||||||
|
"ORDER BY prescreened_at DESC LIMIT :limit"
|
||||||
|
),
|
||||||
|
{"d": document_id, "limit": limit},
|
||||||
|
)
|
||||||
|
return [PrescreenResult(**dict(row)) for row in result.mappings().all()]
|
||||||
144
src/contract_check/core/db/repositories/reports.py
Normal file
144
src/contract_check/core/db/repositories/reports.py
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
"""Report data access.
|
||||||
|
|
||||||
|
Both the analyze worker upsert and the prescreen auto-approve report live here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..models import Report
|
||||||
|
|
||||||
|
|
||||||
|
class ReportRepository:
|
||||||
|
"""All report reads/writes."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def get_by_document_id(self, document_id: uuid.UUID) -> Report | None:
|
||||||
|
result = await self._session.execute(
|
||||||
|
select(Report).where(Report.document_id == document_id)
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
async def upsert_analyze_report(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
content_json: dict[str, Any],
|
||||||
|
markdown: str,
|
||||||
|
model_used: str | None,
|
||||||
|
prompt_tokens: int,
|
||||||
|
eval_tokens: int,
|
||||||
|
latency_ms: int,
|
||||||
|
) -> uuid.UUID:
|
||||||
|
"""Idempotent insert for the analyze worker (ON CONFLICT document_id)."""
|
||||||
|
content_text = json.dumps(content_json, ensure_ascii=False)
|
||||||
|
result = await self._session.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO reports "
|
||||||
|
"(document_id, content_json, markdown, model_used, "
|
||||||
|
" prompt_tokens, eval_tokens, latency_ms) "
|
||||||
|
"VALUES (:d, CAST(:content AS jsonb), :md, :model, :pt, :et, :lat) "
|
||||||
|
"ON CONFLICT (document_id) DO UPDATE SET "
|
||||||
|
" content_json = EXCLUDED.content_json, "
|
||||||
|
" markdown = EXCLUDED.markdown, "
|
||||||
|
" model_used = EXCLUDED.model_used, "
|
||||||
|
" prompt_tokens = EXCLUDED.prompt_tokens, "
|
||||||
|
" eval_tokens = EXCLUDED.eval_tokens, "
|
||||||
|
" latency_ms = EXCLUDED.latency_ms "
|
||||||
|
"RETURNING id"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"d": document_id,
|
||||||
|
"content": content_text,
|
||||||
|
"md": markdown,
|
||||||
|
"model": model_used,
|
||||||
|
"pt": prompt_tokens,
|
||||||
|
"et": eval_tokens,
|
||||||
|
"lat": latency_ms,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
assert row is not None
|
||||||
|
return row[0]
|
||||||
|
|
||||||
|
async def upsert_auto_approved(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
content_json: dict[str, Any],
|
||||||
|
markdown: str,
|
||||||
|
prescreen_result_id: uuid.UUID,
|
||||||
|
prescreen_meta: dict[str, Any],
|
||||||
|
) -> uuid.UUID:
|
||||||
|
"""Lightweight auto-approve report written by worker_prescreen."""
|
||||||
|
result = await self._session.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO reports "
|
||||||
|
"(document_id, content_json, markdown, model_used, prompt_tokens, "
|
||||||
|
" eval_tokens, latency_ms, prescreen_result_id, prescreen_meta) "
|
||||||
|
"VALUES (:d, CAST(:content AS jsonb), :md, :model, 0, 0, 0, :prid, :pm) "
|
||||||
|
"ON CONFLICT (document_id) DO UPDATE SET "
|
||||||
|
" content_json = EXCLUDED.content_json, "
|
||||||
|
" markdown = EXCLUDED.markdown, "
|
||||||
|
" model_used = EXCLUDED.model_used, "
|
||||||
|
" prompt_tokens = EXCLUDED.prompt_tokens, "
|
||||||
|
" eval_tokens = EXCLUDED.eval_tokens, "
|
||||||
|
" latency_ms = EXCLUDED.latency_ms, "
|
||||||
|
" prescreen_result_id = EXCLUDED.prescreen_result_id, "
|
||||||
|
" prescreen_meta = EXCLUDED.prescreen_meta "
|
||||||
|
"RETURNING id"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"d": document_id,
|
||||||
|
"content": json.dumps(content_json),
|
||||||
|
"md": markdown,
|
||||||
|
"model": "prescreen-auto",
|
||||||
|
"prid": prescreen_result_id,
|
||||||
|
"pm": json.dumps(prescreen_meta),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
assert row is not None
|
||||||
|
return row[0]
|
||||||
|
|
||||||
|
async def set_prescreen_result(
|
||||||
|
self,
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
*,
|
||||||
|
prescreen_result_id: uuid.UUID,
|
||||||
|
prescreen_meta: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Attach an existing prescreen result to a report."""
|
||||||
|
await self._session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE reports SET prescreen_result_id = :prid, prescreen_meta = :pm "
|
||||||
|
"WHERE document_id = :d"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"d": document_id,
|
||||||
|
"prid": prescreen_result_id,
|
||||||
|
"pm": None if prescreen_meta is None else json.dumps(prescreen_meta),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_for_user(
|
||||||
|
self, user_id: uuid.UUID, *, limit: int = 100, offset: int = 0
|
||||||
|
) -> list[Report]:
|
||||||
|
"""Reports owned by a user via the document relationship."""
|
||||||
|
result = await self._session.execute(
|
||||||
|
select(Report)
|
||||||
|
.join(Report.document)
|
||||||
|
.where(Report.document.has(user_id=user_id)) # type: ignore[attr-defined]
|
||||||
|
.order_by(Report.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
44
src/contract_check/core/db/repositories/service_tokens.py
Normal file
44
src/contract_check/core/db/repositories/service_tokens.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""Service-token data access (bot/web/cli adapters)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceTokenRepository:
|
||||||
|
"""Service-token validation + last-used tracking."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def get_id_by_hash(self, token_hash: str) -> uuid.UUID | None:
|
||||||
|
"""Return token id if hash is valid and not revoked."""
|
||||||
|
result = await self._session.execute(
|
||||||
|
text("SELECT id FROM service_tokens WHERE token_hash = :h AND revoked = FALSE"),
|
||||||
|
{"h": token_hash},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
async def bump_last_used(self, token_id: uuid.UUID) -> None:
|
||||||
|
await self._session.execute(
|
||||||
|
text("UPDATE service_tokens SET last_used_at = now() WHERE id = :id"),
|
||||||
|
{"id": token_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def upsert(self, *, name: str, token_hash: str, adapter: str) -> None:
|
||||||
|
"""Insert or replace a service token by name."""
|
||||||
|
await self._session.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO service_tokens (name, token_hash, adapter) "
|
||||||
|
"VALUES (:name, :hash, :adapter) "
|
||||||
|
"ON CONFLICT (name) DO UPDATE SET "
|
||||||
|
" token_hash = EXCLUDED.token_hash, "
|
||||||
|
" revoked = FALSE, "
|
||||||
|
" adapter = EXCLUDED.adapter"
|
||||||
|
),
|
||||||
|
{"name": name, "hash": token_hash, "adapter": adapter},
|
||||||
|
)
|
||||||
398
src/contract_check/core/db/repositories/users.py
Normal file
398
src/contract_check/core/db/repositories/users.py
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
"""User data access.
|
||||||
|
|
||||||
|
Replaces raw ``text()`` SELECT/UPDATE/INSERT blocks in api/deps.py and
|
||||||
|
api/admin/users.py. Magic-link token handling is also centralized here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import func, select, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..models import User
|
||||||
|
|
||||||
|
|
||||||
|
class UserRepository:
|
||||||
|
"""All user reads/writes."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def get_by_id(self, user_id: uuid.UUID) -> User | None:
|
||||||
|
return await self._session.get(User, user_id)
|
||||||
|
|
||||||
|
async def get_by_id_or_none(self, user_id: uuid.UUID) -> User | None:
|
||||||
|
"""Alias used by auth dependency; returns None instead of raising."""
|
||||||
|
return await self.get_by_id(user_id)
|
||||||
|
|
||||||
|
async def get_by_email(self, email: str) -> User | None:
|
||||||
|
result = await self._session.execute(select(User).where(User.email == email))
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
async def get_password_reset_window(
|
||||||
|
self, token_hash: str
|
||||||
|
) -> tuple[uuid.UUID, dt.datetime] | None:
|
||||||
|
"""Return (user_id, expires_at) for a valid password-reset token hash."""
|
||||||
|
result = await self._session.execute(
|
||||||
|
text(
|
||||||
|
"SELECT id, password_reset_expires_at FROM users WHERE password_reset_token_hash = :h"
|
||||||
|
),
|
||||||
|
{"h": token_hash},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return row[0], row[1]
|
||||||
|
|
||||||
|
async def get_by_telegram_id(self, telegram_id: int) -> User | None:
|
||||||
|
result = await self._session.execute(select(User).where(User.telegram_id == telegram_id))
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
async def exists(self, user_id: uuid.UUID) -> bool:
|
||||||
|
result = await self._session.execute(
|
||||||
|
text("SELECT 1 FROM users WHERE id = :u"),
|
||||||
|
{"u": user_id},
|
||||||
|
)
|
||||||
|
return result.first() is not None
|
||||||
|
|
||||||
|
async def telegram_id_exists_for_other_user(
|
||||||
|
self, telegram_id: int, exclude_user_id: uuid.UUID
|
||||||
|
) -> bool:
|
||||||
|
result = await self._session.execute(
|
||||||
|
text("SELECT 1 FROM users WHERE telegram_id = :t AND id != :u"),
|
||||||
|
{"t": telegram_id, "u": exclude_user_id},
|
||||||
|
)
|
||||||
|
return result.first() is not None
|
||||||
|
|
||||||
|
async def email_exists(self, email: str) -> bool:
|
||||||
|
result = await self._session.execute(
|
||||||
|
text("SELECT 1 FROM users WHERE email = :e"),
|
||||||
|
{"e": email},
|
||||||
|
)
|
||||||
|
return result.first() is not None
|
||||||
|
|
||||||
|
async def telegram_id_exists(self, telegram_id: int) -> bool:
|
||||||
|
result = await self._session.execute(
|
||||||
|
text("SELECT 1 FROM users WHERE telegram_id = :t"),
|
||||||
|
{"t": telegram_id},
|
||||||
|
)
|
||||||
|
return result.first() is not None
|
||||||
|
|
||||||
|
async def create_telegram_user(
|
||||||
|
self,
|
||||||
|
telegram_id: int,
|
||||||
|
*,
|
||||||
|
profile: dict[str, object] | None = None,
|
||||||
|
verified: bool = True,
|
||||||
|
credits_left: int = 0,
|
||||||
|
) -> User:
|
||||||
|
profile_json = json.dumps(profile, ensure_ascii=False) if profile else None
|
||||||
|
bound_at = dt.datetime.now(tz=dt.UTC)
|
||||||
|
result = await self._session.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO users (telegram_id, credits_left, telegram_profile_json, "
|
||||||
|
"telegram_verified, telegram_bound_at) "
|
||||||
|
"VALUES (:t, :c, :p, :v, :b) "
|
||||||
|
"RETURNING id, telegram_id, created_at, credits_left, is_active, "
|
||||||
|
"telegram_verified"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"t": telegram_id,
|
||||||
|
"c": credits_left,
|
||||||
|
"p": profile_json,
|
||||||
|
"v": verified,
|
||||||
|
"b": bound_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
assert row is not None
|
||||||
|
return User(
|
||||||
|
id=row[0],
|
||||||
|
telegram_id=row[1],
|
||||||
|
created_at=row[2],
|
||||||
|
credits_left=row[3],
|
||||||
|
is_active=row[4],
|
||||||
|
telegram_verified=verified,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def create_email_user(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
name: str | None,
|
||||||
|
password_hash: str,
|
||||||
|
credits_left: int = 0,
|
||||||
|
role: str = "user",
|
||||||
|
telegram_id: int | None = None,
|
||||||
|
is_active: bool = True,
|
||||||
|
) -> User:
|
||||||
|
result = await self._session.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO users (email, name, password_hash, credits_left, role, telegram_id, is_active) "
|
||||||
|
"VALUES (:e, :n, :p, :c, :r, :t, :a) "
|
||||||
|
"RETURNING id, telegram_id, email, name, password_hash, role, is_active, "
|
||||||
|
"created_at, credits_left, telegram_verified"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"e": email,
|
||||||
|
"n": name,
|
||||||
|
"p": password_hash,
|
||||||
|
"c": credits_left,
|
||||||
|
"r": role,
|
||||||
|
"t": telegram_id,
|
||||||
|
"a": is_active,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
assert row is not None
|
||||||
|
return User(
|
||||||
|
id=row[0],
|
||||||
|
telegram_id=row[1],
|
||||||
|
email=row[2],
|
||||||
|
name=row[3],
|
||||||
|
password_hash=row[4],
|
||||||
|
role=row[5],
|
||||||
|
is_active=row[6],
|
||||||
|
created_at=row[7],
|
||||||
|
credits_left=row[8],
|
||||||
|
telegram_verified=row[9],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_telegram_profile(
|
||||||
|
self,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
*,
|
||||||
|
profile: dict[str, object] | None = None,
|
||||||
|
verified: bool | None = None,
|
||||||
|
) -> None:
|
||||||
|
fields: list[str] = []
|
||||||
|
params: dict[str, Any] = {"u": user_id}
|
||||||
|
if profile is not None:
|
||||||
|
fields.append("telegram_profile_json = :p")
|
||||||
|
params["p"] = json.dumps(profile, ensure_ascii=False)
|
||||||
|
if verified is not None:
|
||||||
|
fields.append("telegram_verified = :v")
|
||||||
|
params["v"] = verified
|
||||||
|
if not fields:
|
||||||
|
return
|
||||||
|
await self._session.execute(
|
||||||
|
text(f"UPDATE users SET {', '.join(fields)} WHERE id = :u"),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def bind_telegram(
|
||||||
|
self, user_id: uuid.UUID, telegram_id: int, *, bound_at: dt.datetime | None = None
|
||||||
|
) -> None:
|
||||||
|
await self._session.execute(
|
||||||
|
text("UPDATE users SET telegram_id = :t, telegram_bound_at = :b WHERE id = :u"),
|
||||||
|
{
|
||||||
|
"t": telegram_id,
|
||||||
|
"u": user_id,
|
||||||
|
"b": bound_at or func.now(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_password(self, user_id: uuid.UUID, password_hash: str) -> None:
|
||||||
|
await self._session.execute(
|
||||||
|
text("UPDATE users SET password_hash = :p WHERE id = :u"),
|
||||||
|
{"p": password_hash, "u": user_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def reset_password(self, user_id: uuid.UUID, password_hash: str) -> None:
|
||||||
|
"""Rotate the password and invalidate the password-reset token."""
|
||||||
|
await self._session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE users SET password_hash = :p, "
|
||||||
|
"password_reset_token_hash = NULL, password_reset_expires_at = NULL "
|
||||||
|
"WHERE id = :u"
|
||||||
|
),
|
||||||
|
{"p": password_hash, "u": user_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_password_reset_token(
|
||||||
|
self,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
token_hash: str,
|
||||||
|
expires_at: dt.datetime,
|
||||||
|
) -> None:
|
||||||
|
await self._session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE users SET password_reset_token_hash = :h, "
|
||||||
|
"password_reset_expires_at = :e WHERE id = :u"
|
||||||
|
),
|
||||||
|
{"h": token_hash, "e": expires_at, "u": user_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_name(self, user_id: uuid.UUID, name: str) -> None:
|
||||||
|
await self._session.execute(
|
||||||
|
text("UPDATE users SET name = :n WHERE id = :u"),
|
||||||
|
{"n": name, "u": user_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_active(self, user_id: uuid.UUID, active: bool) -> None:
|
||||||
|
await self._session.execute(
|
||||||
|
text("UPDATE users SET is_active = :a WHERE id = :u"),
|
||||||
|
{"a": active, "u": user_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def toggle_active(self, user_id: uuid.UUID) -> None:
|
||||||
|
await self._session.execute(
|
||||||
|
text("UPDATE users SET is_active = NOT is_active WHERE id = :u"),
|
||||||
|
{"u": user_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_role(self, user_id: uuid.UUID, role: str) -> None:
|
||||||
|
await self._session.execute(
|
||||||
|
text("UPDATE users SET role = :r WHERE id = :u"),
|
||||||
|
{"r": role, "u": user_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def verify_telegram(self, user_id: uuid.UUID) -> None:
|
||||||
|
await self._session.execute(
|
||||||
|
text("UPDATE users SET telegram_verified = TRUE WHERE id = :u"),
|
||||||
|
{"u": user_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def adjust_credits(self, user_id: uuid.UUID, delta: int) -> None:
|
||||||
|
await self._session.execute(
|
||||||
|
text("UPDATE users SET credits_left = greatest(0, credits_left + :d) WHERE id = :u"),
|
||||||
|
{"d": delta, "u": user_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_credits(self, user_id: uuid.UUID) -> int | None:
|
||||||
|
result = await self._session.execute(
|
||||||
|
text("SELECT credits_left FROM users WHERE id = :u"),
|
||||||
|
{"u": user_id},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
return None if row is None else int(row[0])
|
||||||
|
|
||||||
|
async def set_magic_link_token(
|
||||||
|
self,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
token_hash: str,
|
||||||
|
expires_at: dt.datetime,
|
||||||
|
) -> None:
|
||||||
|
await self._session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE users SET magic_link_token_hash = :h, "
|
||||||
|
"magic_link_expires_at = :e WHERE id = :u"
|
||||||
|
),
|
||||||
|
{"h": token_hash, "e": expires_at, "u": user_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_by_magic_link_token_hash(self, token_hash: str) -> User | None:
|
||||||
|
result = await self._session.execute(
|
||||||
|
text("SELECT id, magic_link_expires_at FROM users WHERE magic_link_token_hash = :h"),
|
||||||
|
{"h": token_hash},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
# Build a lightweight User shell with only the loaded columns.
|
||||||
|
return User(id=row[0], magic_link_expires_at=row[1])
|
||||||
|
|
||||||
|
async def consume_magic_link_token(self, user_id: uuid.UUID) -> None:
|
||||||
|
await self._session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE users SET magic_link_token_hash = NULL, "
|
||||||
|
"magic_link_expires_at = NULL WHERE id = :u"
|
||||||
|
),
|
||||||
|
{"u": user_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_users(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
role: str | None = None,
|
||||||
|
is_active: bool | None = None,
|
||||||
|
has_telegram: bool | None = None,
|
||||||
|
telegram_verified: bool | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[User]:
|
||||||
|
clauses: list[str] = ["1 = 1"]
|
||||||
|
params: dict[str, Any] = {"limit": limit, "offset": offset}
|
||||||
|
if role is not None:
|
||||||
|
clauses.append("role = :role")
|
||||||
|
params["role"] = role
|
||||||
|
if is_active is not None:
|
||||||
|
clauses.append("is_active = :active")
|
||||||
|
params["active"] = is_active
|
||||||
|
if has_telegram is not None:
|
||||||
|
clauses.append("telegram_id IS NOT NULL" if has_telegram else "telegram_id IS NULL")
|
||||||
|
if telegram_verified is not None:
|
||||||
|
clauses.append("telegram_verified = :tv")
|
||||||
|
params["tv"] = telegram_verified
|
||||||
|
if search:
|
||||||
|
clauses.append("(email ILIKE :s OR telegram_id::text ILIKE :s OR name ILIKE :s)")
|
||||||
|
params["s"] = f"%{search}%"
|
||||||
|
where = " AND ".join(clauses)
|
||||||
|
result = await self._session.execute(
|
||||||
|
text(
|
||||||
|
f"SELECT {self._user_columns()} FROM users WHERE {where} "
|
||||||
|
"ORDER BY created_at DESC LIMIT :limit OFFSET :offset"
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
return [self._row_to_user(row) for row in result.all()]
|
||||||
|
|
||||||
|
async def count_users(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
role: str | None = None,
|
||||||
|
is_active: bool | None = None,
|
||||||
|
has_telegram: bool | None = None,
|
||||||
|
telegram_verified: bool | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
) -> int:
|
||||||
|
clauses: list[str] = ["1 = 1"]
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
if role is not None:
|
||||||
|
clauses.append("role = :role")
|
||||||
|
params["role"] = role
|
||||||
|
if is_active is not None:
|
||||||
|
clauses.append("is_active = :active")
|
||||||
|
params["active"] = is_active
|
||||||
|
if has_telegram is not None:
|
||||||
|
clauses.append("telegram_id IS NOT NULL" if has_telegram else "telegram_id IS NULL")
|
||||||
|
if telegram_verified is not None:
|
||||||
|
clauses.append("telegram_verified = :tv")
|
||||||
|
params["tv"] = telegram_verified
|
||||||
|
if search:
|
||||||
|
clauses.append("(email ILIKE :s OR telegram_id::text ILIKE :s OR name ILIKE :s)")
|
||||||
|
params["s"] = f"%{search}%"
|
||||||
|
where = " AND ".join(clauses)
|
||||||
|
result = await self._session.execute(
|
||||||
|
text(f"SELECT count(*) FROM users WHERE {where}"),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
return int(result.scalar_one())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _user_columns() -> str:
|
||||||
|
return (
|
||||||
|
"id, telegram_id, email, name, password_hash, role, is_active, "
|
||||||
|
"created_at, credits_left, telegram_verified"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _row_to_user(row: Any) -> User:
|
||||||
|
return User(
|
||||||
|
id=row[0],
|
||||||
|
telegram_id=row[1],
|
||||||
|
email=row[2],
|
||||||
|
name=row[3],
|
||||||
|
password_hash=row[4],
|
||||||
|
role=row[5],
|
||||||
|
is_active=row[6],
|
||||||
|
created_at=row[7],
|
||||||
|
credits_left=row[8],
|
||||||
|
telegram_verified=row[9],
|
||||||
|
)
|
||||||
|
|
@ -13,7 +13,8 @@ from .adapters.ocr_tesseract import TesseractOcrExtractor
|
||||||
from .adapters.pdf_pymupdf import PyMuPDFExtractor
|
from .adapters.pdf_pymupdf import PyMuPDFExtractor
|
||||||
from .adapters.rtf_striprtf import RtfExtractor
|
from .adapters.rtf_striprtf import RtfExtractor
|
||||||
from .adapters.txt_chardet import TxtExtractor
|
from .adapters.txt_chardet import TxtExtractor
|
||||||
from .factory import SUPPORTED_SUFFIXES, ExtractorFactory, detect_format, get_factory
|
from .factory import ExtractorFactory, detect_format, get_factory
|
||||||
|
from .formats import SUPPORTED_SUFFIXES
|
||||||
from .port import (
|
from .port import (
|
||||||
MIN_TEXT_CHARS,
|
MIN_TEXT_CHARS,
|
||||||
DocumentExtractor,
|
DocumentExtractor,
|
||||||
|
|
|
||||||
|
|
@ -24,22 +24,9 @@ from .adapters.ocr_tesseract import TesseractOcrExtractor
|
||||||
from .adapters.pdf_pymupdf import PyMuPDFExtractor
|
from .adapters.pdf_pymupdf import PyMuPDFExtractor
|
||||||
from .adapters.rtf_striprtf import RtfExtractor
|
from .adapters.rtf_striprtf import RtfExtractor
|
||||||
from .adapters.txt_chardet import TxtExtractor
|
from .adapters.txt_chardet import TxtExtractor
|
||||||
|
from .formats import SUPPORTED_SUFFIXES
|
||||||
from .port import DocumentExtractor, UnsupportedFormatError
|
from .port import DocumentExtractor, UnsupportedFormatError
|
||||||
|
|
||||||
# Canonical upload gate (mirrored by the api and the bot adapter).
|
|
||||||
SUPPORTED_SUFFIXES = {
|
|
||||||
".pdf",
|
|
||||||
".docx",
|
|
||||||
".rtf",
|
|
||||||
".txt",
|
|
||||||
".csv",
|
|
||||||
".png",
|
|
||||||
".jpg",
|
|
||||||
".jpeg",
|
|
||||||
".tif",
|
|
||||||
".tiff",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Canonical format ids → suffix(es). `image` covers every raster suffix.
|
# Canonical format ids → suffix(es). `image` covers every raster suffix.
|
||||||
_SUFFIX_TO_FORMAT = {
|
_SUFFIX_TO_FORMAT = {
|
||||||
".pdf": "pdf",
|
".pdf": "pdf",
|
||||||
|
|
|
||||||
21
src/contract_check/core/extraction/formats.py
Normal file
21
src/contract_check/core/extraction/formats.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
"""Canonical supported-format definitions.
|
||||||
|
|
||||||
|
Kept lightweight so the bot adapter and the API can share the suffix gate
|
||||||
|
without importing heavy adapter implementations (PyMuPDF, Mammoth, Tesseract).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Upload gate shared by the API, the bot adapter, and extraction tooling.
|
||||||
|
SUPPORTED_SUFFIXES: set[str] = {
|
||||||
|
".pdf",
|
||||||
|
".docx",
|
||||||
|
".rtf",
|
||||||
|
".txt",
|
||||||
|
".csv",
|
||||||
|
".png",
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".tif",
|
||||||
|
".tiff",
|
||||||
|
}
|
||||||
34
src/contract_check/core/llm/errors.py
Normal file
34
src/contract_check/core/llm/errors.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
"""Canonical LLM error hierarchy shared by all provider adapters.
|
||||||
|
|
||||||
|
Provider modules re-export these classes so existing import paths keep
|
||||||
|
working; classification/retry logic must import from here, never from a
|
||||||
|
specific adapter (see issue 002).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..errors import TerminalError
|
||||||
|
|
||||||
|
|
||||||
|
class LLMError(Exception):
|
||||||
|
"""Unrecoverable LLM failure (after all retries)."""
|
||||||
|
|
||||||
|
|
||||||
|
class LLMQuotaError(LLMError):
|
||||||
|
"""Quota/rate-limit exhausted on both primary and fallback — refundable failure."""
|
||||||
|
|
||||||
|
|
||||||
|
class LLMUnavailableError(LLMError):
|
||||||
|
"""Provider API unreachable or returns non-200 status — retryable."""
|
||||||
|
|
||||||
|
|
||||||
|
class LLMConfigError(LLMError, TerminalError):
|
||||||
|
"""Misconfigured provider credentials/endpoint — terminal, do not retry."""
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LLMConfigError",
|
||||||
|
"LLMError",
|
||||||
|
"LLMQuotaError",
|
||||||
|
"LLMUnavailableError",
|
||||||
|
]
|
||||||
|
|
@ -9,7 +9,6 @@ dependency that uses these lives in `api/deps.py` (Step 2).
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
|
||||||
import secrets
|
import secrets
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
|
|
@ -26,12 +25,6 @@ def hash_token(token: str) -> str:
|
||||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def verify_token(token: str, token_hash: str) -> bool:
|
|
||||||
"""Constant-time check that `token` matches the stored `token_hash`."""
|
|
||||||
digest = hash_token(token)
|
|
||||||
return hmac.compare_digest(digest, token_hash)
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_adapter(adapter: str) -> bool:
|
def is_valid_adapter(adapter: str) -> bool:
|
||||||
return adapter in ADAPTER_NAMES
|
return adapter in ADAPTER_NAMES
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,17 +12,15 @@ in-process without spinning up a real RabbitMQ consumer. It owns:
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
from ..core.analysis.analyzer import RenderMetrics, render_markdown
|
from ..core.analysis.analyzer import RenderMetrics, render_markdown
|
||||||
from ..core.analysis.checklist import checklist_for_prompt
|
from ..core.analysis.checklist import checklist_for_prompt
|
||||||
from ..core.config import get_settings
|
from ..core.config import get_settings
|
||||||
from ..core.credits import refund_credit
|
from ..core.credits import refund_credit
|
||||||
from ..core.db.enums import DOC_TERMINAL, FailureClass
|
from ..core.db.enums import DOC_TERMINAL, FailureClass
|
||||||
|
from ..core.db.repositories import DocumentRepository, JobRepository, ReportRepository
|
||||||
from ..core.llm.errors import (
|
from ..core.llm.errors import (
|
||||||
LLMConfigError,
|
LLMConfigError,
|
||||||
LLMError,
|
LLMError,
|
||||||
|
|
@ -75,17 +73,12 @@ class AnalyzeHandler:
|
||||||
async def _db_state(
|
async def _db_state(
|
||||||
self, session: AsyncSession, document_id: uuid.UUID
|
self, session: AsyncSession, document_id: uuid.UUID
|
||||||
) -> tuple[str | None, str | None]:
|
) -> tuple[str | None, str | None]:
|
||||||
row = await session.execute(
|
return await DocumentRepository(session).get_status_and_filename_for_update(document_id)
|
||||||
text("SELECT status, filename FROM documents WHERE id = :d FOR UPDATE"),
|
|
||||||
{"d": document_id},
|
|
||||||
)
|
|
||||||
result = row.first()
|
|
||||||
if result is None:
|
|
||||||
return None, None
|
|
||||||
return (None if result[0] is None else str(result[0]), str(result[1]))
|
|
||||||
|
|
||||||
async def handle(self, payload: DocumentExtracted) -> None:
|
async def handle(self, payload: DocumentExtracted) -> None:
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
|
docs = DocumentRepository(session)
|
||||||
|
jobs = JobRepository(session)
|
||||||
status, filename = await self._db_state(session, payload.document_id)
|
status, filename = await self._db_state(session, payload.document_id)
|
||||||
if status is None:
|
if status is None:
|
||||||
log.warning("document_not_found", document_id=str(payload.document_id))
|
log.warning("document_not_found", document_id=str(payload.document_id))
|
||||||
|
|
@ -94,17 +87,8 @@ class AnalyzeHandler:
|
||||||
log.info("document_already_terminal", status=status)
|
log.info("document_already_terminal", status=status)
|
||||||
return
|
return
|
||||||
|
|
||||||
await session.execute(
|
await docs.update_status(payload.document_id, status="analyzing", stage="llm")
|
||||||
text("UPDATE documents SET status = 'analyzing', stage = 'llm' WHERE id = :d"),
|
await jobs.claim_start(payload.document_id, "analyze")
|
||||||
{"d": payload.document_id},
|
|
||||||
)
|
|
||||||
await session.execute(
|
|
||||||
text(
|
|
||||||
"UPDATE jobs SET status = 'running', attempts = attempts + 1 "
|
|
||||||
"WHERE document_id = :d AND queue = 'analyze'"
|
|
||||||
),
|
|
||||||
{"d": payload.document_id},
|
|
||||||
)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
text_bytes = await self._storage.get(payload.extracted_s3_key)
|
text_bytes = await self._storage.get(payload.extracted_s3_key)
|
||||||
|
|
@ -137,46 +121,22 @@ class AnalyzeHandler:
|
||||||
repaired=result.repaired,
|
repaired=result.repaired,
|
||||||
)
|
)
|
||||||
markdown = render_markdown(result.findings, source_name, metrics)
|
markdown = render_markdown(result.findings, source_name, metrics)
|
||||||
content_json = json.dumps(
|
|
||||||
{"findings": [f.model_dump() for f in result.findings]},
|
|
||||||
ensure_ascii=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
await session.execute(
|
docs = DocumentRepository(session)
|
||||||
text(
|
jobs = JobRepository(session)
|
||||||
"INSERT INTO reports "
|
reports = ReportRepository(session)
|
||||||
"(document_id, content_json, markdown, model_used, "
|
await reports.upsert_analyze_report(
|
||||||
" prompt_tokens, eval_tokens, latency_ms) "
|
document_id=payload.document_id,
|
||||||
"VALUES (:d, CAST(:content AS jsonb), :md, :model, :pt, :et, :lat) "
|
content_json={"findings": [f.model_dump() for f in result.findings]},
|
||||||
"ON CONFLICT (document_id) DO UPDATE SET "
|
markdown=markdown,
|
||||||
" content_json = EXCLUDED.content_json, "
|
model_used=result.model_used,
|
||||||
" markdown = EXCLUDED.markdown, "
|
prompt_tokens=result.prompt_tokens,
|
||||||
" model_used = EXCLUDED.model_used, "
|
eval_tokens=result.eval_tokens,
|
||||||
" prompt_tokens = EXCLUDED.prompt_tokens, "
|
latency_ms=int(result.latency_sec * 1000),
|
||||||
" eval_tokens = EXCLUDED.eval_tokens, "
|
|
||||||
" latency_ms = EXCLUDED.latency_ms"
|
|
||||||
),
|
|
||||||
{
|
|
||||||
"d": payload.document_id,
|
|
||||||
"content": content_json,
|
|
||||||
"md": markdown,
|
|
||||||
"model": result.model_used,
|
|
||||||
"pt": result.prompt_tokens,
|
|
||||||
"et": result.eval_tokens,
|
|
||||||
"lat": int(result.latency_sec * 1000),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await session.execute(
|
|
||||||
text("UPDATE documents SET status = 'done', stage = 'done' WHERE id = :d"),
|
|
||||||
{"d": payload.document_id},
|
|
||||||
)
|
|
||||||
await session.execute(
|
|
||||||
text(
|
|
||||||
"UPDATE jobs SET status = 'done' WHERE document_id = :d AND queue = 'analyze'"
|
|
||||||
),
|
|
||||||
{"d": payload.document_id},
|
|
||||||
)
|
)
|
||||||
|
await docs.update_status(payload.document_id, status="done", stage="done")
|
||||||
|
await jobs.mark_done(payload.document_id, "analyze")
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
|
|
@ -212,18 +172,12 @@ class AnalyzeHandler:
|
||||||
error=error,
|
error=error,
|
||||||
)
|
)
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
await session.execute(
|
await JobRepository(session).mark_retrying(
|
||||||
text(
|
payload.document_id,
|
||||||
"UPDATE jobs SET status = 'retrying', attempts = :a, "
|
"analyze",
|
||||||
"last_failure_class = :fc, last_error = :err "
|
attempt=attempt,
|
||||||
"WHERE document_id = :d AND queue = 'analyze'"
|
failure_class=failure_class,
|
||||||
),
|
error=error,
|
||||||
{
|
|
||||||
"a": attempt,
|
|
||||||
"fc": failure_class,
|
|
||||||
"err": error[:1000],
|
|
||||||
"d": payload.document_id,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
@ -232,22 +186,12 @@ class AnalyzeHandler:
|
||||||
) -> None:
|
) -> None:
|
||||||
mq_failed.labels(queue="analyze", failure_class=failure_class).inc()
|
mq_failed.labels(queue="analyze", failure_class=failure_class).inc()
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
await session.execute(
|
docs = DocumentRepository(session)
|
||||||
text(
|
jobs = JobRepository(session)
|
||||||
"UPDATE jobs SET status = 'dlq', dlq = TRUE, "
|
await jobs.mark_dlq(
|
||||||
"last_failure_class = :fc, last_error = :err "
|
payload.document_id, "analyze", failure_class=failure_class, error=error
|
||||||
"WHERE document_id = :d AND queue = 'analyze'"
|
|
||||||
),
|
|
||||||
{
|
|
||||||
"fc": failure_class,
|
|
||||||
"err": error[:1000],
|
|
||||||
"d": payload.document_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await session.execute(
|
|
||||||
text("UPDATE documents SET status = 'failed', stage = :stage WHERE id = :d"),
|
|
||||||
{"stage": failure_class, "d": payload.document_id},
|
|
||||||
)
|
)
|
||||||
|
await docs.mark_failed(payload.document_id, stage=failure_class)
|
||||||
await refund_credit(
|
await refund_credit(
|
||||||
session,
|
session,
|
||||||
payload.document_id,
|
payload.document_id,
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,10 @@ import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
from ..core.config import get_settings
|
from ..core.config import get_settings
|
||||||
from ..core.credits import refund_credit
|
from ..core.credits import refund_credit
|
||||||
from ..core.db.enums import DOC_TERMINAL, FailureClass
|
from ..core.db.enums import DOC_TERMINAL, FailureClass
|
||||||
|
from ..core.db.repositories import DocumentRepository, JobRepository
|
||||||
from ..core.logging import get_logger
|
from ..core.logging import get_logger
|
||||||
from ..core.metrics import extract_duration, mq_failed, mq_published
|
from ..core.metrics import extract_duration, mq_failed, mq_published
|
||||||
from ..core.mq.messages import (
|
from ..core.mq.messages import (
|
||||||
|
|
@ -69,15 +68,12 @@ class ExtractHandler:
|
||||||
return self._publisher
|
return self._publisher
|
||||||
|
|
||||||
async def _db_status(self, session: AsyncSession, document_id: uuid.UUID) -> str | None:
|
async def _db_status(self, session: AsyncSession, document_id: uuid.UUID) -> str | None:
|
||||||
row = await session.execute(
|
return await DocumentRepository(session).get_status_for_update(document_id)
|
||||||
text("SELECT status FROM documents WHERE id = :d FOR UPDATE"),
|
|
||||||
{"d": document_id},
|
|
||||||
)
|
|
||||||
result = row.first()
|
|
||||||
return str(result[0]) if result else None
|
|
||||||
|
|
||||||
async def handle(self, payload: DocumentUploaded) -> None:
|
async def handle(self, payload: DocumentUploaded) -> None:
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
|
docs = DocumentRepository(session)
|
||||||
|
jobs = JobRepository(session)
|
||||||
current = await self._db_status(session, payload.document_id)
|
current = await self._db_status(session, payload.document_id)
|
||||||
if current is None:
|
if current is None:
|
||||||
log.warning("document_not_found", document_id=str(payload.document_id))
|
log.warning("document_not_found", document_id=str(payload.document_id))
|
||||||
|
|
@ -86,20 +82,8 @@ class ExtractHandler:
|
||||||
log.info("document_already_terminal", status=current)
|
log.info("document_already_terminal", status=current)
|
||||||
return
|
return
|
||||||
|
|
||||||
await session.execute(
|
await docs.update_status(payload.document_id, status="extracting", stage="downloading")
|
||||||
text(
|
await jobs.claim_start(payload.document_id, "extract")
|
||||||
"UPDATE documents SET status = 'extracting', stage = 'downloading' "
|
|
||||||
"WHERE id = :d"
|
|
||||||
),
|
|
||||||
{"d": payload.document_id},
|
|
||||||
)
|
|
||||||
await session.execute(
|
|
||||||
text(
|
|
||||||
"UPDATE jobs SET status = 'running', attempts = attempts + 1 "
|
|
||||||
"WHERE document_id = :d AND queue = 'extract'"
|
|
||||||
),
|
|
||||||
{"d": payload.document_id},
|
|
||||||
)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
with extract_duration.time():
|
with extract_duration.time():
|
||||||
|
|
@ -146,24 +130,15 @@ class ExtractHandler:
|
||||||
mq_published.labels(queue=queue_metric).inc()
|
mq_published.labels(queue=queue_metric).inc()
|
||||||
|
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
await session.execute(
|
docs = DocumentRepository(session)
|
||||||
text(
|
jobs = JobRepository(session)
|
||||||
"UPDATE documents SET status = :status, stage = :stage, "
|
await docs.update_status(
|
||||||
"extracted_s3_key = :key WHERE id = :d"
|
payload.document_id,
|
||||||
),
|
status=doc_status,
|
||||||
{
|
stage=doc_stage,
|
||||||
"status": doc_status,
|
extracted_s3_key=ext_key,
|
||||||
"stage": doc_stage,
|
|
||||||
"key": ext_key,
|
|
||||||
"d": payload.document_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await session.execute(
|
|
||||||
text(
|
|
||||||
"UPDATE jobs SET status = 'done' WHERE document_id = :d AND queue = 'extract'"
|
|
||||||
),
|
|
||||||
{"d": payload.document_id},
|
|
||||||
)
|
)
|
||||||
|
await jobs.mark_done(payload.document_id, "extract")
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
|
|
@ -194,13 +169,8 @@ class ExtractHandler:
|
||||||
tmp_path.unlink(missing_ok=True)
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
async def _update_stage(self, document_id: uuid.UUID, stage: str) -> None:
|
async def _update_stage(self, document_id: uuid.UUID, stage: str) -> None:
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
await session.execute(
|
await DocumentRepository(session).update_stage(document_id, stage=stage)
|
||||||
text("UPDATE documents SET stage = :stage WHERE id = :d"),
|
|
||||||
{"stage": stage, "d": document_id},
|
|
||||||
)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
def classify(self, exc: BaseException) -> FailureClass:
|
def classify(self, exc: BaseException) -> FailureClass:
|
||||||
|
|
@ -216,22 +186,14 @@ class ExtractHandler:
|
||||||
async def on_failure(
|
async def on_failure(
|
||||||
self, payload: DocumentUploaded, failure_class: FailureClass, attempt: int, error: str
|
self, payload: DocumentUploaded, failure_class: FailureClass, attempt: int, error: str
|
||||||
) -> None:
|
) -> None:
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
mq_failed.labels(queue="extract", failure_class=failure_class).inc()
|
mq_failed.labels(queue="extract", failure_class=failure_class).inc()
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
await session.execute(
|
await JobRepository(session).mark_retrying(
|
||||||
text(
|
payload.document_id,
|
||||||
"UPDATE jobs SET status = 'retrying', attempts = :a, "
|
"extract",
|
||||||
"last_failure_class = :fc, last_error = :err "
|
attempt=attempt,
|
||||||
"WHERE document_id = :d AND queue = 'extract'"
|
failure_class=failure_class,
|
||||||
),
|
error=error,
|
||||||
{
|
|
||||||
"a": attempt,
|
|
||||||
"fc": failure_class,
|
|
||||||
"err": error[:1000],
|
|
||||||
"d": payload.document_id,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
@ -240,22 +202,12 @@ class ExtractHandler:
|
||||||
) -> None:
|
) -> None:
|
||||||
mq_failed.labels(queue="extract", failure_class=failure_class).inc()
|
mq_failed.labels(queue="extract", failure_class=failure_class).inc()
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
await session.execute(
|
docs = DocumentRepository(session)
|
||||||
text(
|
jobs = JobRepository(session)
|
||||||
"UPDATE jobs SET status = 'dlq', dlq = TRUE, "
|
await jobs.mark_dlq(
|
||||||
"last_failure_class = :fc, last_error = :err "
|
payload.document_id, "extract", failure_class=failure_class, error=error
|
||||||
"WHERE document_id = :d AND queue = 'extract'"
|
|
||||||
),
|
|
||||||
{
|
|
||||||
"fc": failure_class,
|
|
||||||
"err": error[:1000],
|
|
||||||
"d": payload.document_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await session.execute(
|
|
||||||
text("UPDATE documents SET status = 'failed', stage = :stage WHERE id = :d"),
|
|
||||||
{"stage": failure_class, "d": payload.document_id},
|
|
||||||
)
|
)
|
||||||
|
await docs.mark_failed(payload.document_id, stage=failure_class)
|
||||||
await refund_credit(
|
await refund_credit(
|
||||||
session,
|
session,
|
||||||
payload.document_id,
|
payload.document_id,
|
||||||
|
|
|
||||||
|
|
@ -14,17 +14,20 @@ in-process without spinning up a real RabbitMQ consumer. It owns:
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, date, datetime
|
from datetime import UTC, date, datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
from ..core.config import get_settings
|
from ..core.config import get_settings
|
||||||
from ..core.credits import refund_credit
|
from ..core.credits import refund_credit
|
||||||
from ..core.db.enums import DOC_TERMINAL, FailureClass
|
from ..core.db.enums import DOC_TERMINAL, FailureClass
|
||||||
|
from ..core.db.repositories import (
|
||||||
|
DocumentRepository,
|
||||||
|
JobRepository,
|
||||||
|
PrescreenResultRepository,
|
||||||
|
ReportRepository,
|
||||||
|
)
|
||||||
from ..core.logging import (
|
from ..core.logging import (
|
||||||
Timer,
|
Timer,
|
||||||
get_logger,
|
get_logger,
|
||||||
|
|
@ -104,14 +107,10 @@ class PrescreenHandler:
|
||||||
return self._publisher
|
return self._publisher
|
||||||
|
|
||||||
async def _db_status(self, session: AsyncSession, document_id: uuid.UUID) -> str | None:
|
async def _db_status(self, session: AsyncSession, document_id: uuid.UUID) -> str | None:
|
||||||
row = await session.execute(
|
return await DocumentRepository(session).get_status_for_update(document_id)
|
||||||
text("SELECT status FROM documents WHERE id = :d FOR UPDATE"),
|
|
||||||
{"d": document_id},
|
|
||||||
)
|
|
||||||
result = row.first()
|
|
||||||
return str(result[0]) if result else None
|
|
||||||
|
|
||||||
async def handle(self, payload: PrescreenRequested) -> None:
|
async def handle(self, payload: PrescreenRequested) -> None:
|
||||||
|
"""Orchestrate the prescreen pipeline in eight stages."""
|
||||||
started_at = time.perf_counter()
|
started_at = time.perf_counter()
|
||||||
log_message_processing(
|
log_message_processing(
|
||||||
log,
|
log,
|
||||||
|
|
@ -125,7 +124,103 @@ class PrescreenHandler:
|
||||||
attempt=payload.attempt,
|
attempt=payload.attempt,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Stage 1: Database validation
|
current = await self._validate_payload(payload)
|
||||||
|
if current is None:
|
||||||
|
return
|
||||||
|
await self._transition_status(payload)
|
||||||
|
text_bytes, contract_text = await self._fetch_document(payload)
|
||||||
|
meta, extraction = await self._extract_metadata(contract_text, payload.document_id)
|
||||||
|
decision = self._route(meta, payload.document_id)
|
||||||
|
|
||||||
|
processing_ms = int((time.perf_counter() - started_at) * 1000)
|
||||||
|
prescreened_at = datetime.now(UTC)
|
||||||
|
completed = self._build_completed(payload, meta, extraction, decision, prescreened_at)
|
||||||
|
log_stage_complete(
|
||||||
|
log,
|
||||||
|
"prescreen_extraction",
|
||||||
|
processing_ms,
|
||||||
|
document_id=str(payload.document_id),
|
||||||
|
decision=decision,
|
||||||
|
confidence=meta.confidence_score,
|
||||||
|
contract_type=meta.contract_type,
|
||||||
|
extractor_version=extraction.extractor_version,
|
||||||
|
matched_fields=[
|
||||||
|
field for field in self._meta_field_names() if getattr(meta, field) is not None
|
||||||
|
],
|
||||||
|
missing_fields=[
|
||||||
|
field for field in self._meta_field_names() if getattr(meta, field) is None
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
prescreen_result_id = await self._persist(
|
||||||
|
payload, completed, extraction, processing_ms, prescreened_at
|
||||||
|
)
|
||||||
|
prescreen_duration.labels(decision=decision).observe(processing_ms / 1000.0)
|
||||||
|
prescreen_runs.labels(
|
||||||
|
decision=decision, contract_type=meta.contract_type or "unknown"
|
||||||
|
).inc()
|
||||||
|
prescreen_confidence.observe(meta.confidence_score)
|
||||||
|
|
||||||
|
next_stage, doc_status = await self._publish(
|
||||||
|
payload, completed, decision, meta, prescreen_result_id
|
||||||
|
)
|
||||||
|
await self._finalize(payload, doc_status, next_stage)
|
||||||
|
|
||||||
|
log_message_processing(
|
||||||
|
log,
|
||||||
|
"completed",
|
||||||
|
"prescreen.q",
|
||||||
|
correlation_id=str(payload.correlation_id),
|
||||||
|
document_id=str(payload.document_id),
|
||||||
|
user_id=str(payload.user_id),
|
||||||
|
decision=decision,
|
||||||
|
confidence=meta.confidence_score,
|
||||||
|
contract_type=meta.contract_type,
|
||||||
|
processing_ms=processing_ms,
|
||||||
|
next_stage=next_stage,
|
||||||
|
prescreen_result_id=str(prescreen_result_id) if prescreen_result_id else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_completed(
|
||||||
|
self,
|
||||||
|
payload: PrescreenRequested,
|
||||||
|
meta: PrescreenContractMeta,
|
||||||
|
extraction: ExtractionResult,
|
||||||
|
decision: RoutingDecision,
|
||||||
|
prescreened_at: datetime,
|
||||||
|
) -> PrescreenCompleted:
|
||||||
|
"""Assemble the outgoing PrescreenCompleted message from the stage outputs."""
|
||||||
|
return PrescreenCompleted(
|
||||||
|
correlation_id=payload.correlation_id,
|
||||||
|
document_id=payload.document_id,
|
||||||
|
user_id=payload.user_id,
|
||||||
|
text_s3_key=payload.text_s3_key,
|
||||||
|
filename=payload.filename,
|
||||||
|
prescreened_at=prescreened_at.isoformat(),
|
||||||
|
contract_type=meta.contract_type,
|
||||||
|
party_a=meta.party_a,
|
||||||
|
party_b=meta.party_b,
|
||||||
|
total_amount=meta.total_amount,
|
||||||
|
currency=meta.currency,
|
||||||
|
start_date=meta.start_date,
|
||||||
|
end_date=meta.end_date,
|
||||||
|
has_penalty_clause=meta.has_penalty_clause,
|
||||||
|
has_termination_clause=meta.has_termination_clause,
|
||||||
|
has_arbitration=meta.has_arbitration,
|
||||||
|
confidence_score=meta.confidence_score,
|
||||||
|
routing_decision=decision,
|
||||||
|
auto_summary=self._router.summarize(meta, decision)
|
||||||
|
if decision == "auto_approve"
|
||||||
|
else None,
|
||||||
|
# A failed LLM fallback is recorded here (plan §3.4) — never fatal.
|
||||||
|
auto_findings=[{"llm_fallback_error": extraction.llm_fallback_error}]
|
||||||
|
if extraction.llm_fallback_error
|
||||||
|
else [],
|
||||||
|
attempt=payload.attempt,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _validate_payload(self, payload: PrescreenRequested) -> str | None:
|
||||||
|
"""Return current document status or None if not found/terminal."""
|
||||||
with Timer(log, "prescreen_db_validation", document_id=str(payload.document_id)):
|
with Timer(log, "prescreen_db_validation", document_id=str(payload.document_id)):
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
current = await self._db_status(session, payload.document_id)
|
current = await self._db_status(session, payload.document_id)
|
||||||
|
|
@ -136,39 +231,31 @@ class PrescreenHandler:
|
||||||
user_id=str(payload.user_id),
|
user_id=str(payload.user_id),
|
||||||
text_s3_key=payload.text_s3_key,
|
text_s3_key=payload.text_s3_key,
|
||||||
)
|
)
|
||||||
return
|
return None
|
||||||
if current in DOC_TERMINAL:
|
if current in DOC_TERMINAL:
|
||||||
log.info(
|
log.info(
|
||||||
"document_already_terminal",
|
"document_already_terminal",
|
||||||
document_id=str(payload.document_id),
|
document_id=str(payload.document_id),
|
||||||
status=current,
|
status=current,
|
||||||
)
|
)
|
||||||
return
|
return None
|
||||||
|
return current
|
||||||
|
|
||||||
# Stage 2: Update document status and job tracking
|
async def _transition_status(self, payload: PrescreenRequested) -> None:
|
||||||
|
"""Move document to prescreening and upsert the running job row."""
|
||||||
with Timer(log, "prescreen_status_update", document_id=str(payload.document_id)):
|
with Timer(log, "prescreen_status_update", document_id=str(payload.document_id)):
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
await session.execute(
|
docs = DocumentRepository(session)
|
||||||
text(
|
jobs = JobRepository(session)
|
||||||
"UPDATE documents SET status = 'prescreening', stage = 'extracting_meta' "
|
await docs.update_status(
|
||||||
"WHERE id = :d"
|
payload.document_id,
|
||||||
),
|
status="prescreening",
|
||||||
{"d": payload.document_id},
|
stage="extracting_meta",
|
||||||
)
|
)
|
||||||
await session.execute(
|
await jobs.upsert_running(
|
||||||
text(
|
payload.document_id,
|
||||||
"INSERT INTO jobs (document_id, correlation_id, queue, status) "
|
payload.correlation_id,
|
||||||
"VALUES (:d, :cid, 'prescreen', 'running') "
|
queue="prescreen",
|
||||||
"ON CONFLICT (document_id, queue) DO NOTHING"
|
|
||||||
),
|
|
||||||
{"d": payload.document_id, "cid": payload.correlation_id},
|
|
||||||
)
|
|
||||||
await session.execute(
|
|
||||||
text(
|
|
||||||
"UPDATE jobs SET status = 'running', attempts = attempts + 1 "
|
|
||||||
"WHERE document_id = :d AND queue = 'prescreen'"
|
|
||||||
),
|
|
||||||
{"d": payload.document_id},
|
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
log_db_operation(
|
log_db_operation(
|
||||||
|
|
@ -180,7 +267,8 @@ class PrescreenHandler:
|
||||||
stage="extracting_meta",
|
stage="extracting_meta",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Stage 3: Download text from S3
|
async def _fetch_document(self, payload: PrescreenRequested) -> tuple[bytes, str]:
|
||||||
|
"""Download the extracted Markdown from S3 and decode it."""
|
||||||
text_bytes: bytes
|
text_bytes: bytes
|
||||||
contract_text: str
|
contract_text: str
|
||||||
with Timer(
|
with Timer(
|
||||||
|
|
@ -216,11 +304,15 @@ class PrescreenHandler:
|
||||||
text_s3_key=payload.text_s3_key,
|
text_s3_key=payload.text_s3_key,
|
||||||
char_count=len(contract_text),
|
char_count=len(contract_text),
|
||||||
)
|
)
|
||||||
|
return text_bytes, contract_text
|
||||||
|
|
||||||
# Stage 4: Extract contract metadata (heuristic Stage 1 + optional LLM Stage 2)
|
async def _extract_metadata(
|
||||||
|
self, contract_text: str, document_id: uuid.UUID
|
||||||
|
) -> tuple[PrescreenContractMeta, ExtractionResult]:
|
||||||
|
"""Run the hybrid metadata extractor over the contract text."""
|
||||||
meta: PrescreenContractMeta
|
meta: PrescreenContractMeta
|
||||||
extraction: ExtractionResult
|
extraction: ExtractionResult
|
||||||
with Timer(log, "prescreen_meta_extraction", document_id=str(payload.document_id)):
|
with Timer(log, "prescreen_meta_extraction", document_id=str(document_id)):
|
||||||
try:
|
try:
|
||||||
extraction = await self._extractor.extract(contract_text)
|
extraction = await self._extractor.extract(contract_text)
|
||||||
meta = extraction.meta
|
meta = extraction.meta
|
||||||
|
|
@ -229,7 +321,7 @@ class PrescreenHandler:
|
||||||
"extraction_confidence",
|
"extraction_confidence",
|
||||||
meta.confidence_score,
|
meta.confidence_score,
|
||||||
"ratio",
|
"ratio",
|
||||||
document_id=str(payload.document_id),
|
document_id=str(document_id),
|
||||||
extractor_version=extraction.extractor_version,
|
extractor_version=extraction.extractor_version,
|
||||||
llm_fallback_error=extraction.llm_fallback_error,
|
llm_fallback_error=extraction.llm_fallback_error,
|
||||||
extracted_fields=sum(
|
extracted_fields=sum(
|
||||||
|
|
@ -239,113 +331,64 @@ class PrescreenHandler:
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log_stage_failure(
|
log_stage_failure(
|
||||||
log, "prescreen_meta_extraction", exc, document_id=str(payload.document_id)
|
log, "prescreen_meta_extraction", exc, document_id=str(document_id)
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# Stage 5: Make routing decision
|
return meta, extraction
|
||||||
|
|
||||||
|
def _route(self, meta: PrescreenContractMeta, document_id: uuid.UUID) -> RoutingDecision:
|
||||||
|
"""Make the routing decision based on extracted metadata."""
|
||||||
decision: RoutingDecision
|
decision: RoutingDecision
|
||||||
with Timer(log, "prescreen_routing_decision", document_id=str(payload.document_id)):
|
with Timer(log, "prescreen_routing_decision", document_id=str(document_id)):
|
||||||
decision = self._router.decide(meta)
|
decision = self._router.decide(meta)
|
||||||
log_stage_progress(
|
log_stage_progress(
|
||||||
log,
|
log,
|
||||||
"prescreen_routing_decision",
|
"prescreen_routing_decision",
|
||||||
"decision_made",
|
"decision_made",
|
||||||
document_id=str(payload.document_id),
|
document_id=str(document_id),
|
||||||
decision=decision,
|
decision=decision,
|
||||||
confidence=meta.confidence_score,
|
confidence=meta.confidence_score,
|
||||||
)
|
)
|
||||||
|
|
||||||
processing_ms = int((time.perf_counter() - started_at) * 1000)
|
return decision
|
||||||
|
|
||||||
prescreened_at = datetime.now(UTC)
|
async def _persist(
|
||||||
prescreened_at_str = prescreened_at.isoformat()
|
self,
|
||||||
completed = PrescreenCompleted(
|
payload: PrescreenRequested,
|
||||||
correlation_id=payload.correlation_id,
|
completed: PrescreenCompleted,
|
||||||
document_id=payload.document_id,
|
extraction: ExtractionResult,
|
||||||
user_id=payload.user_id,
|
processing_ms: int,
|
||||||
text_s3_key=payload.text_s3_key,
|
prescreened_at: datetime,
|
||||||
filename=payload.filename,
|
) -> uuid.UUID | None:
|
||||||
prescreened_at=prescreened_at_str,
|
"""Persist the prescreen result row and return its id."""
|
||||||
contract_type=meta.contract_type,
|
|
||||||
party_a=meta.party_a,
|
|
||||||
party_b=meta.party_b,
|
|
||||||
total_amount=meta.total_amount,
|
|
||||||
currency=meta.currency,
|
|
||||||
start_date=meta.start_date,
|
|
||||||
end_date=meta.end_date,
|
|
||||||
has_penalty_clause=meta.has_penalty_clause,
|
|
||||||
has_termination_clause=meta.has_termination_clause,
|
|
||||||
has_arbitration=meta.has_arbitration,
|
|
||||||
confidence_score=meta.confidence_score,
|
|
||||||
routing_decision=decision,
|
|
||||||
auto_summary=self._router.summarize(meta, decision)
|
|
||||||
if decision == "auto_approve"
|
|
||||||
else None,
|
|
||||||
# A failed LLM fallback is recorded here (plan §3.4) — never fatal.
|
|
||||||
auto_findings=[{"llm_fallback_error": extraction.llm_fallback_error}]
|
|
||||||
if extraction.llm_fallback_error
|
|
||||||
else [],
|
|
||||||
attempt=payload.attempt,
|
|
||||||
)
|
|
||||||
|
|
||||||
log_stage_complete(
|
|
||||||
log,
|
|
||||||
"prescreen_extraction",
|
|
||||||
processing_ms,
|
|
||||||
document_id=str(payload.document_id),
|
|
||||||
decision=decision,
|
|
||||||
confidence=meta.confidence_score,
|
|
||||||
contract_type=meta.contract_type,
|
|
||||||
extractor_version=extraction.extractor_version,
|
|
||||||
matched_fields=[
|
|
||||||
field for field in self._meta_field_names() if getattr(meta, field) is not None
|
|
||||||
],
|
|
||||||
missing_fields=[
|
|
||||||
field for field in self._meta_field_names() if getattr(meta, field) is None
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Stage 6: Persist prescreen result
|
|
||||||
prescreen_result_id: uuid.UUID | None = None
|
prescreen_result_id: uuid.UUID | None = None
|
||||||
with Timer(log, "prescreen_result_persistence", document_id=str(payload.document_id)):
|
with Timer(log, "prescreen_result_persistence", document_id=str(payload.document_id)):
|
||||||
try:
|
try:
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
result = await session.execute(
|
prescreen_result_id = await PrescreenResultRepository(
|
||||||
text(
|
session
|
||||||
"INSERT INTO prescreen_results "
|
).create_from_completed(
|
||||||
"(document_id, correlation_id, contract_type, party_a, party_b, "
|
document_id=payload.document_id,
|
||||||
" total_amount, currency, start_date, end_date, has_penalty_clause, "
|
correlation_id=payload.correlation_id,
|
||||||
" has_termination_clause, has_arbitration, confidence_score, "
|
contract_type=completed.contract_type,
|
||||||
" routing_decision, prescreened_at, processing_ms, auto_summary, "
|
party_a=completed.party_a,
|
||||||
" auto_findings, extractor_version) "
|
party_b=completed.party_b,
|
||||||
"VALUES (:d, :cid, :ct, :pa, :pb, :ta, :cur, :sd, :ed, :hpc, :htc, :ha, "
|
total_amount=completed.total_amount,
|
||||||
" :cs, :rd, :psa, :ms, :asum, :af, :ev) "
|
currency=completed.currency,
|
||||||
"RETURNING id"
|
start_date=_as_date(completed.start_date),
|
||||||
),
|
end_date=_as_date(completed.end_date),
|
||||||
{
|
has_penalty_clause=completed.has_penalty_clause,
|
||||||
"d": payload.document_id,
|
has_termination_clause=completed.has_termination_clause,
|
||||||
"cid": payload.correlation_id,
|
has_arbitration=completed.has_arbitration,
|
||||||
"ct": completed.contract_type,
|
confidence_score=completed.confidence_score,
|
||||||
"pa": completed.party_a,
|
routing_decision=completed.routing_decision,
|
||||||
"pb": completed.party_b,
|
prescreened_at=prescreened_at,
|
||||||
"ta": completed.total_amount,
|
processing_ms=processing_ms,
|
||||||
"cur": completed.currency,
|
auto_summary=completed.auto_summary,
|
||||||
"sd": _as_date(completed.start_date),
|
auto_findings=completed.auto_findings,
|
||||||
"ed": _as_date(completed.end_date),
|
extractor_version=extraction.extractor_version,
|
||||||
"hpc": completed.has_penalty_clause,
|
|
||||||
"htc": completed.has_termination_clause,
|
|
||||||
"ha": completed.has_arbitration,
|
|
||||||
"cs": completed.confidence_score,
|
|
||||||
"rd": completed.routing_decision,
|
|
||||||
"psa": prescreened_at,
|
|
||||||
"ms": processing_ms,
|
|
||||||
"asum": completed.auto_summary,
|
|
||||||
"af": json.dumps(completed.auto_findings),
|
|
||||||
"ev": extraction.extractor_version,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
prescreen_result_id = result.scalar()
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
log_db_operation(
|
log_db_operation(
|
||||||
log,
|
log,
|
||||||
|
|
@ -360,13 +403,17 @@ class PrescreenHandler:
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
prescreen_duration.labels(decision=decision).observe(processing_ms / 1000.0)
|
return prescreen_result_id
|
||||||
prescreen_runs.labels(
|
|
||||||
decision=decision, contract_type=meta.contract_type or "unknown"
|
|
||||||
).inc()
|
|
||||||
prescreen_confidence.observe(meta.confidence_score)
|
|
||||||
|
|
||||||
# Stage 7: Publish next message or complete
|
async def _publish(
|
||||||
|
self,
|
||||||
|
payload: PrescreenRequested,
|
||||||
|
completed: PrescreenCompleted,
|
||||||
|
decision: RoutingDecision,
|
||||||
|
meta: PrescreenContractMeta,
|
||||||
|
prescreen_result_id: uuid.UUID | None,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Publish the next message or complete auto-approved reports."""
|
||||||
publisher = await self._publisher_instance()
|
publisher = await self._publisher_instance()
|
||||||
next_stage: str
|
next_stage: str
|
||||||
|
|
||||||
|
|
@ -419,7 +466,7 @@ class PrescreenHandler:
|
||||||
session_factory=self._session_factory,
|
session_factory=self._session_factory,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
completed=completed,
|
completed=completed,
|
||||||
prescreen_result_id=prescreen_result_id, # type: ignore[arg-type]
|
prescreen_result_id=prescreen_result_id,
|
||||||
)
|
)
|
||||||
doc_status = "done"
|
doc_status = "done"
|
||||||
next_stage = "auto_approved"
|
next_stage = "auto_approved"
|
||||||
|
|
@ -430,19 +477,22 @@ class PrescreenHandler:
|
||||||
document_id=str(payload.document_id),
|
document_id=str(payload.document_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Stage 8: Final status update
|
return next_stage, doc_status
|
||||||
|
|
||||||
|
async def _finalize(
|
||||||
|
self, payload: PrescreenRequested, doc_status: str, next_stage: str
|
||||||
|
) -> None:
|
||||||
|
"""Update the final document status and mark the prescreen job done."""
|
||||||
with Timer(log, "prescreen_final_status_update", document_id=str(payload.document_id)):
|
with Timer(log, "prescreen_final_status_update", document_id=str(payload.document_id)):
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
await session.execute(
|
docs = DocumentRepository(session)
|
||||||
text("UPDATE documents SET status = :status, stage = :stage WHERE id = :d"),
|
jobs = JobRepository(session)
|
||||||
{"status": doc_status, "stage": next_stage, "d": payload.document_id},
|
await docs.update_status(
|
||||||
)
|
payload.document_id,
|
||||||
await session.execute(
|
status=doc_status,
|
||||||
text(
|
stage=next_stage,
|
||||||
"UPDATE jobs SET status = 'done' WHERE document_id = :d AND queue = 'prescreen'"
|
|
||||||
),
|
|
||||||
{"d": payload.document_id},
|
|
||||||
)
|
)
|
||||||
|
await jobs.mark_done(payload.document_id, queue="prescreen")
|
||||||
await session.commit()
|
await session.commit()
|
||||||
log_db_operation(
|
log_db_operation(
|
||||||
log,
|
log,
|
||||||
|
|
@ -453,28 +503,13 @@ class PrescreenHandler:
|
||||||
final_stage=next_stage,
|
final_stage=next_stage,
|
||||||
)
|
)
|
||||||
|
|
||||||
log_message_processing(
|
|
||||||
log,
|
|
||||||
"completed",
|
|
||||||
"prescreen.q",
|
|
||||||
correlation_id=str(payload.correlation_id),
|
|
||||||
document_id=str(payload.document_id),
|
|
||||||
user_id=str(payload.user_id),
|
|
||||||
decision=decision,
|
|
||||||
confidence=meta.confidence_score,
|
|
||||||
contract_type=meta.contract_type,
|
|
||||||
processing_ms=processing_ms,
|
|
||||||
next_stage=next_stage,
|
|
||||||
prescreen_result_id=str(prescreen_result_id) if prescreen_result_id else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _write_auto_approved_report(
|
async def _write_auto_approved_report(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
session_factory: async_sessionmaker[AsyncSession],
|
session_factory: async_sessionmaker[AsyncSession],
|
||||||
payload: PrescreenRequested,
|
payload: PrescreenRequested,
|
||||||
completed: PrescreenCompleted,
|
completed: PrescreenCompleted,
|
||||||
prescreen_result_id: uuid.UUID,
|
prescreen_result_id: uuid.UUID | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
content: dict[str, object] = {
|
content: dict[str, object] = {
|
||||||
"findings": [
|
"findings": [
|
||||||
|
|
@ -496,30 +531,12 @@ class PrescreenHandler:
|
||||||
"*Это автоматический отчёт без полного анализа ИИ.*"
|
"*Это автоматический отчёт без полного анализа ИИ.*"
|
||||||
)
|
)
|
||||||
async with session_factory() as session:
|
async with session_factory() as session:
|
||||||
await session.execute(
|
await ReportRepository(session).upsert_auto_approved(
|
||||||
text(
|
document_id=payload.document_id,
|
||||||
"INSERT INTO reports "
|
content_json=content,
|
||||||
"(document_id, content_json, markdown, model_used, prompt_tokens, "
|
markdown=markdown,
|
||||||
" eval_tokens, latency_ms, prescreen_result_id, prescreen_meta) "
|
prescreen_result_id=prescreen_result_id or uuid.UUID(int=0),
|
||||||
"VALUES (:d, CAST(:content AS jsonb), :md, :model, 0, 0, 0, :prid, :pm) "
|
prescreen_meta=completed.model_dump(),
|
||||||
"ON CONFLICT (document_id) DO UPDATE SET "
|
|
||||||
" content_json = EXCLUDED.content_json, "
|
|
||||||
" markdown = EXCLUDED.markdown, "
|
|
||||||
" model_used = EXCLUDED.model_used, "
|
|
||||||
" prompt_tokens = EXCLUDED.prompt_tokens, "
|
|
||||||
" eval_tokens = EXCLUDED.eval_tokens, "
|
|
||||||
" latency_ms = EXCLUDED.latency_ms, "
|
|
||||||
" prescreen_result_id = EXCLUDED.prescreen_result_id, "
|
|
||||||
" prescreen_meta = EXCLUDED.prescreen_meta"
|
|
||||||
),
|
|
||||||
{
|
|
||||||
"d": payload.document_id,
|
|
||||||
"content": json.dumps(content),
|
|
||||||
"md": markdown,
|
|
||||||
"model": "prescreen-auto",
|
|
||||||
"prid": prescreen_result_id,
|
|
||||||
"pm": completed.model_dump(),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
@ -561,18 +578,12 @@ class PrescreenHandler:
|
||||||
error=error,
|
error=error,
|
||||||
)
|
)
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
await session.execute(
|
await JobRepository(session).mark_retrying(
|
||||||
text(
|
payload.document_id,
|
||||||
"UPDATE jobs SET status = 'retrying', attempts = :a, "
|
queue="prescreen",
|
||||||
"last_failure_class = :fc, last_error = :err "
|
attempt=attempt,
|
||||||
"WHERE document_id = :d AND queue = 'prescreen'"
|
failure_class=failure_class,
|
||||||
),
|
error=error,
|
||||||
{
|
|
||||||
"a": attempt,
|
|
||||||
"fc": failure_class,
|
|
||||||
"err": error[:1000],
|
|
||||||
"d": payload.document_id,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
@ -581,22 +592,15 @@ class PrescreenHandler:
|
||||||
) -> None:
|
) -> None:
|
||||||
mq_failed.labels(queue="prescreen", failure_class=failure_class).inc()
|
mq_failed.labels(queue="prescreen", failure_class=failure_class).inc()
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
await session.execute(
|
jobs = JobRepository(session)
|
||||||
text(
|
docs = DocumentRepository(session)
|
||||||
"UPDATE jobs SET status = 'dlq', dlq = TRUE, "
|
await jobs.mark_dlq(
|
||||||
"last_failure_class = :fc, last_error = :err "
|
payload.document_id,
|
||||||
"WHERE document_id = :d AND queue = 'prescreen'"
|
queue="prescreen",
|
||||||
),
|
failure_class=failure_class,
|
||||||
{
|
error=error,
|
||||||
"fc": failure_class,
|
|
||||||
"err": error[:1000],
|
|
||||||
"d": payload.document_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await session.execute(
|
|
||||||
text("UPDATE documents SET status = 'failed', stage = :stage WHERE id = :d"),
|
|
||||||
{"stage": failure_class, "d": payload.document_id},
|
|
||||||
)
|
)
|
||||||
|
await docs.mark_failed(payload.document_id, stage=failure_class)
|
||||||
await refund_credit(
|
await refund_credit(
|
||||||
session,
|
session,
|
||||||
payload.document_id,
|
payload.document_id,
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,12 @@ When disabled, any auto_approve decision is remapped to manual_review.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Literal
|
from ..core.db.enums import RoutingDecision
|
||||||
|
|
||||||
from ..core.logging import get_logger
|
from ..core.logging import get_logger
|
||||||
from .extractor import PrescreenContractMeta
|
from .extractor import PrescreenContractMeta
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
type RoutingDecision = Literal["auto_approve", "manual_review", "deep_analysis"]
|
|
||||||
|
|
||||||
|
|
||||||
class PrescreenRouter:
|
class PrescreenRouter:
|
||||||
"""Decide where a prescreened document goes next."""
|
"""Decide where a prescreened document goes next."""
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"""Shared pytest fixtures.
|
"""Shared pytest fixtures.
|
||||||
|
|
||||||
Unit tests stay fast and dependency-free (no DB/MQ/S3). Integration tests use
|
Unit tests stay fast and dependency-free (no DB/MQ/S3). Integration tests use
|
||||||
testcontainers and are marked `@pytest.mark.integration` (deselected by the
|
the running Docker Compose infrastructure and are marked `@pytest.mark.integration`
|
||||||
default `pytest -q` run).
|
(deselected by the default `pytest -q` run).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"""Credits DB integration tests (requires real Postgres via testcontainers).
|
"""Credits DB integration tests (requires running Docker Compose infrastructure).
|
||||||
|
|
||||||
Marked `integration`; not run by the default fast suite. Verifies the atomic
|
Marked `integration`; not run by the default fast suite. Verifies the atomic
|
||||||
reserve, idempotent refund, and the race-safety of `reserve_credit`.
|
reserve, idempotent refund, and the race-safety of `reserve_credit` against
|
||||||
|
the real Postgres schema.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -10,50 +11,21 @@ import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from testcontainers.community.postgres import PostgresContainer
|
|
||||||
|
|
||||||
from contract_check.core.credits import refund_credit, reserve_credit
|
from contract_check.core.credits import refund_credit, reserve_credit
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def pg_session():
|
|
||||||
with PostgresContainer("postgres:16-alpine") as pg:
|
|
||||||
url = pg.get_connection_url().replace("psycopg2", "asyncpg")
|
|
||||||
engine = create_async_engine(url)
|
|
||||||
# Create minimal schema in-memory (Postgres handles UUID/JSONB fine).
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pgcrypto"))
|
|
||||||
await conn.execute(
|
|
||||||
text(
|
|
||||||
"CREATE TABLE users ("
|
|
||||||
"id UUID PRIMARY KEY DEFAULT gen_random_uuid(),"
|
|
||||||
"credits_left INT NOT NULL DEFAULT 0 CHECK (credits_left >= 0)"
|
|
||||||
")"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
text(
|
|
||||||
"CREATE TABLE documents ("
|
|
||||||
"id UUID PRIMARY KEY DEFAULT gen_random_uuid(),"
|
|
||||||
"user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,"
|
|
||||||
"refunded BOOLEAN NOT NULL DEFAULT FALSE"
|
|
||||||
")"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
async with engine.connect() as conn:
|
|
||||||
async_session = AsyncSession(bind=conn)
|
|
||||||
yield async_session
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
async def insert_user(session: AsyncSession, credits: int = 5) -> uuid.UUID:
|
async def insert_user(session: AsyncSession, credits: int = 5) -> uuid.UUID:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
text("INSERT INTO users (credits_left) VALUES (:c) RETURNING id"),
|
text(
|
||||||
|
"INSERT INTO users (id, email, password_hash, credits_left, is_active) "
|
||||||
|
"VALUES (gen_random_uuid(), gen_random_uuid() || '@test.local', '', :c, TRUE) "
|
||||||
|
"RETURNING id"
|
||||||
|
),
|
||||||
{"c": credits},
|
{"c": credits},
|
||||||
)
|
)
|
||||||
user_id = result.scalar_one()
|
user_id = result.scalar_one()
|
||||||
|
|
@ -63,7 +35,11 @@ async def insert_user(session: AsyncSession, credits: int = 5) -> uuid.UUID:
|
||||||
|
|
||||||
async def insert_doc(session: AsyncSession, user_id: uuid.UUID) -> uuid.UUID:
|
async def insert_doc(session: AsyncSession, user_id: uuid.UUID) -> uuid.UUID:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
text("INSERT INTO documents (user_id, refunded) VALUES (:u, FALSE) RETURNING id"),
|
text(
|
||||||
|
"INSERT INTO documents (id, user_id, s3_key, filename, mime, bytes, status) "
|
||||||
|
"VALUES (gen_random_uuid(), :u, 's3://test', 'test.pdf', 'application/pdf', 0, 'queued') "
|
||||||
|
"RETURNING id"
|
||||||
|
),
|
||||||
{"u": user_id},
|
{"u": user_id},
|
||||||
)
|
)
|
||||||
doc_id = result.scalar_one()
|
doc_id = result.scalar_one()
|
||||||
|
|
@ -78,60 +54,63 @@ async def credit_balance(session: AsyncSession, user_id: uuid.UUID) -> int:
|
||||||
return int(result.scalar_one())
|
return int(result.scalar_one())
|
||||||
|
|
||||||
|
|
||||||
async def test_reserve_credit_decrements_once(pg_session: AsyncSession) -> None:
|
async def test_reserve_credit_decrements_once(db_session: AsyncSession) -> None:
|
||||||
u = await insert_user(pg_session, credits=2)
|
u = await insert_user(db_session, credits=2)
|
||||||
ok = await reserve_credit(pg_session, u)
|
ok = await reserve_credit(db_session, u)
|
||||||
assert ok is True
|
assert ok is True
|
||||||
await pg_session.commit()
|
await db_session.commit()
|
||||||
assert await credit_balance(pg_session, u) == 1
|
assert await credit_balance(db_session, u) == 1
|
||||||
|
|
||||||
|
|
||||||
async def test_reserve_credit_rejects_when_zero(pg_session: AsyncSession) -> None:
|
async def test_reserve_credit_rejects_when_zero(db_session: AsyncSession) -> None:
|
||||||
u = await insert_user(pg_session, credits=0)
|
u = await insert_user(db_session, credits=0)
|
||||||
ok = await reserve_credit(pg_session, u)
|
ok = await reserve_credit(db_session, u)
|
||||||
assert ok is False
|
assert ok is False
|
||||||
|
|
||||||
|
|
||||||
async def test_reserve_credit_never_goes_negative(pg_session: AsyncSession) -> None:
|
async def test_reserve_credit_never_goes_negative(db_session: AsyncSession) -> None:
|
||||||
u = await insert_user(pg_session, credits=1)
|
u = await insert_user(db_session, credits=1)
|
||||||
results = await asyncio.gather(
|
|
||||||
reserve_credit(pg_session, u),
|
async def _attempt() -> bool:
|
||||||
reserve_credit(pg_session, u),
|
# Each concurrent attempt must use its own DB session/connection.
|
||||||
reserve_credit(pg_session, u),
|
async with AsyncSession(db_session.bind) as session:
|
||||||
)
|
result = await reserve_credit(session, u)
|
||||||
|
await session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
results = await asyncio.gather(_attempt(), _attempt(), _attempt())
|
||||||
# The atomic UPDATE WHERE credits_left>0 serializes concurrent attempts.
|
# The atomic UPDATE WHERE credits_left>0 serializes concurrent attempts.
|
||||||
assert sum(1 for r in results if r) == 1
|
assert sum(1 for r in results if r) == 1
|
||||||
await pg_session.commit()
|
assert await credit_balance(db_session, u) == 0
|
||||||
assert await credit_balance(pg_session, u) == 0
|
|
||||||
|
|
||||||
|
|
||||||
async def test_refund_credit_idempotent(pg_session: AsyncSession) -> None:
|
async def test_refund_credit_idempotent(db_session: AsyncSession) -> None:
|
||||||
u = await insert_user(pg_session, credits=0)
|
u = await insert_user(db_session, credits=0)
|
||||||
d = await insert_doc(pg_session, u)
|
d = await insert_doc(db_session, u)
|
||||||
|
|
||||||
ok1 = await refund_credit(pg_session, d, "llm_quota", "all")
|
ok1 = await refund_credit(db_session, d, "llm_quota", "all")
|
||||||
assert ok1 is True
|
assert ok1 is True
|
||||||
await pg_session.commit()
|
await db_session.commit()
|
||||||
assert await credit_balance(pg_session, u) == 1
|
assert await credit_balance(db_session, u) == 1
|
||||||
|
|
||||||
ok2 = await refund_credit(pg_session, d, "llm_quota", "all")
|
ok2 = await refund_credit(db_session, d, "llm_quota", "all")
|
||||||
assert ok2 is False # already refunded
|
assert ok2 is False # already refunded
|
||||||
await pg_session.commit()
|
await db_session.commit()
|
||||||
assert await credit_balance(pg_session, u) == 1
|
assert await credit_balance(db_session, u) == 1
|
||||||
|
|
||||||
|
|
||||||
async def test_refund_credit_respects_infra_only(pg_session: AsyncSession) -> None:
|
async def test_refund_credit_respects_infra_only(db_session: AsyncSession) -> None:
|
||||||
u = await insert_user(pg_session, credits=0)
|
u = await insert_user(db_session, credits=0)
|
||||||
d = await insert_doc(pg_session, u)
|
d = await insert_doc(db_session, u)
|
||||||
|
|
||||||
ok = await refund_credit(pg_session, d, "extraction_failed", "infra_only")
|
ok = await refund_credit(db_session, d, "extraction_failed", "infra_only")
|
||||||
assert ok is False # user pays for garbage
|
assert ok is False # user pays for garbage
|
||||||
await pg_session.commit()
|
await db_session.commit()
|
||||||
assert await credit_balance(pg_session, u) == 0
|
assert await credit_balance(db_session, u) == 0
|
||||||
|
|
||||||
# LLM failure is refunded under infra_only.
|
# LLM failure is refunded under infra_only.
|
||||||
d2 = await insert_doc(pg_session, u)
|
d2 = await insert_doc(db_session, u)
|
||||||
ok2 = await refund_credit(pg_session, d2, "llm_quota", "infra_only")
|
ok2 = await refund_credit(db_session, d2, "llm_quota", "infra_only")
|
||||||
assert ok2 is True
|
assert ok2 is True
|
||||||
await pg_session.commit()
|
await db_session.commit()
|
||||||
assert await credit_balance(pg_session, u) == 1
|
assert await credit_balance(db_session, u) == 1
|
||||||
|
|
|
||||||
41
tests/unit/conftest.py
Normal file
41
tests/unit/conftest.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""Unit-test fixtures for repository tests against the running dev database.
|
||||||
|
|
||||||
|
Repository tests roll back every test via a nested transaction so the shared
|
||||||
|
dev database stays clean. If the compose stack is not up, the tests are skipped.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
_DB_URL = "postgresql+asyncpg://contract_check:contract_check@localhost:15432/contract_check"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def db_session() -> AsyncIterator[AsyncSession]:
|
||||||
|
"""Open a rolled-back transaction against the dev database."""
|
||||||
|
engine = create_async_engine(_DB_URL, poolclass=NullPool)
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
trans = await conn.begin_nested()
|
||||||
|
factory = async_sessionmaker(
|
||||||
|
bind=conn,
|
||||||
|
expire_on_commit=False,
|
||||||
|
autoflush=False,
|
||||||
|
autocommit=False,
|
||||||
|
)
|
||||||
|
session = factory()
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
await trans.rollback()
|
||||||
|
except OSError as exc:
|
||||||
|
pytest.skip(f"Postgres not reachable for repository tests: {exc}")
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
56
tests/unit/test_analyze_classify.py
Normal file
56
tests/unit/test_analyze_classify.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
"""Unit tests for AnalyzeHandler.classify (issue 002).
|
||||||
|
|
||||||
|
Verifies that every canonical LLM error class — no matter which provider
|
||||||
|
module re-exports it — maps to the correct FailureClass, and that the
|
||||||
|
ollama_cloud/yandex_gpt hierarchies are the same canonical classes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from contract_check.core.llm import errors as llm_errors
|
||||||
|
from contract_check.core.llm import ollama_cloud, yandex_gpt
|
||||||
|
from contract_check.worker_analyze.handler import AnalyzeHandler
|
||||||
|
|
||||||
|
|
||||||
|
def _handler() -> AnalyzeHandler:
|
||||||
|
return AnalyzeHandler(session_factory=MagicMock())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("error_class", "expected"),
|
||||||
|
[
|
||||||
|
(llm_errors.LLMQuotaError, "llm_quota"),
|
||||||
|
(llm_errors.LLMConfigError, "infra"),
|
||||||
|
(llm_errors.LLMUnavailableError, "llm_timeout"),
|
||||||
|
(llm_errors.LLMError, "llm_invalid_output"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("provider_module", [ollama_cloud, yandex_gpt])
|
||||||
|
def test_classify_llm_errors_from_both_providers(
|
||||||
|
error_class: type[Exception], expected: str, provider_module: object
|
||||||
|
) -> None:
|
||||||
|
"""Re-exported classes from either provider must classify identically."""
|
||||||
|
handler = _handler()
|
||||||
|
raised = error_class("boom")
|
||||||
|
assert handler.classify(raised) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_hierarchies_are_canonical() -> None:
|
||||||
|
"""Both adapters must re-export the same canonical error classes."""
|
||||||
|
assert ollama_cloud.LLMError is llm_errors.LLMError
|
||||||
|
assert ollama_cloud.LLMQuotaError is llm_errors.LLMQuotaError
|
||||||
|
assert ollama_cloud.LLMUnavailableError is llm_errors.LLMUnavailableError
|
||||||
|
assert ollama_cloud.LLMConfigError is llm_errors.LLMConfigError
|
||||||
|
|
||||||
|
assert yandex_gpt.LLMError is llm_errors.LLMError
|
||||||
|
assert yandex_gpt.LLMQuotaError is llm_errors.LLMQuotaError
|
||||||
|
assert yandex_gpt.LLMUnavailableError is llm_errors.LLMUnavailableError
|
||||||
|
assert yandex_gpt.LLMConfigError is llm_errors.LLMConfigError
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_unknown_error_is_infra() -> None:
|
||||||
|
assert _handler().classify(RuntimeError("boom")) == "infra"
|
||||||
94
tests/unit/test_db_models_sync.py
Normal file
94
tests/unit/test_db_models_sync.py
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
"""Model/enum layer must match the migrated schema (issue 004).
|
||||||
|
|
||||||
|
Guards the drift fixed in .scratch/refactor-base-api-alignment/issues/004:
|
||||||
|
TEXT+CHECK status/queue sets, the jobs (document_id, queue) unique
|
||||||
|
constraint, and the PrescreenResult mapping for `prescreen_results`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import CheckConstraint, UniqueConstraint
|
||||||
|
|
||||||
|
from contract_check.core.db.enums import (
|
||||||
|
DOC_STATUSES,
|
||||||
|
QUEUE_NAMES,
|
||||||
|
ROUTING_DECISIONS,
|
||||||
|
)
|
||||||
|
from contract_check.core.db.models import Base, PrescreenResult
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnumsMatchMigrations:
|
||||||
|
def test_doc_statuses_cover_migrations_0006_and_0008(self) -> None:
|
||||||
|
assert "prescreening" in DOC_STATUSES
|
||||||
|
assert "manual_review" in DOC_STATUSES
|
||||||
|
|
||||||
|
def test_queue_names_cover_migration_0006(self) -> None:
|
||||||
|
assert "prescreen" in QUEUE_NAMES
|
||||||
|
|
||||||
|
def test_routing_decisions_match_prescreen_check(self) -> None:
|
||||||
|
assert ROUTING_DECISIONS == ("auto_approve", "manual_review", "deep_analysis")
|
||||||
|
|
||||||
|
|
||||||
|
class TestPrescreenResultModel:
|
||||||
|
@pytest.fixture
|
||||||
|
def table(self): # type: ignore[no-untyped-def]
|
||||||
|
return PrescreenResult.__table__
|
||||||
|
|
||||||
|
def test_mapped_to_prescreen_results(self, table) -> None: # type: ignore[no-untyped-def]
|
||||||
|
assert table.name == "prescreen_results"
|
||||||
|
assert "prescreen_results" in Base.metadata.tables
|
||||||
|
|
||||||
|
def test_check_constraints_match_migration_0006(self, table) -> None: # type: ignore[no-untyped-def]
|
||||||
|
by_name = {c.name: c for c in table.constraints if isinstance(c, CheckConstraint)}
|
||||||
|
assert str(by_name["prescreen_results_routing_decision_check"].sqltext) == (
|
||||||
|
"routing_decision IN ('auto_approve','manual_review','deep_analysis')"
|
||||||
|
)
|
||||||
|
assert "confidence_score >= 0" in str(by_name["prescreen_results_confidence_check"].sqltext)
|
||||||
|
assert str(by_name["prescreen_results_retry_count_nonneg"].sqltext) == "retry_count >= 0"
|
||||||
|
|
||||||
|
def test_indexes_match_migration_0006(self, table) -> None: # type: ignore[no-untyped-def]
|
||||||
|
names = {idx.name for idx in table.indexes}
|
||||||
|
assert names == {
|
||||||
|
"ix_prescreen_results_document_id",
|
||||||
|
"prescreen_results_routing_idx",
|
||||||
|
"prescreen_results_confidence_idx",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_server_defaults_match_migration_0006(self, table) -> None: # type: ignore[no-untyped-def]
|
||||||
|
for column in ("routing_decision", "extractor_version", "auto_findings", "retry_count"):
|
||||||
|
assert table.c[column].server_default is not None, column
|
||||||
|
|
||||||
|
|
||||||
|
class TestJobsUniqueConstraint:
|
||||||
|
def test_document_queue_unique_present(self) -> None:
|
||||||
|
jobs = Base.metadata.tables["jobs"]
|
||||||
|
uniques = [
|
||||||
|
c
|
||||||
|
for c in jobs.constraints
|
||||||
|
if isinstance(c, UniqueConstraint) and c.name == "jobs_document_queue_unique"
|
||||||
|
]
|
||||||
|
assert len(uniques) == 1
|
||||||
|
assert [c.name for c in uniques[0].columns] == ["document_id", "queue"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatusCheckConstraints:
|
||||||
|
def test_documents_status_check_matches_migration_0008(self) -> None:
|
||||||
|
documents = Base.metadata.tables["documents"]
|
||||||
|
check = next(
|
||||||
|
c
|
||||||
|
for c in documents.constraints
|
||||||
|
if isinstance(c, CheckConstraint) and c.name == "documents_status_check"
|
||||||
|
)
|
||||||
|
sql = str(check.sqltext)
|
||||||
|
for status in ("prescreening", "manual_review"):
|
||||||
|
assert f"'{status}'" in sql
|
||||||
|
|
||||||
|
def test_jobs_queue_check_matches_migration_0006(self) -> None:
|
||||||
|
jobs = Base.metadata.tables["jobs"]
|
||||||
|
check = next(
|
||||||
|
c
|
||||||
|
for c in jobs.constraints
|
||||||
|
if isinstance(c, CheckConstraint) and c.name == "jobs_queue_check"
|
||||||
|
)
|
||||||
|
assert "'prescreen'" in str(check.sqltext)
|
||||||
127
tests/unit/test_db_session.py
Normal file
127
tests/unit/test_db_session.py
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
"""Unit tests for DB session singleton pattern (issue 001).
|
||||||
|
|
||||||
|
Verifies that the engine is created only once per process and that sessions
|
||||||
|
from the API dependency reuse the shared factory instead of building engines
|
||||||
|
on every request.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from contract_check.api.deps import get_db_session
|
||||||
|
from contract_check.core.db import session as session_mod
|
||||||
|
from contract_check.core.db.session import (
|
||||||
|
_get_engine,
|
||||||
|
_get_session_factory,
|
||||||
|
create_engine,
|
||||||
|
dispose_engine,
|
||||||
|
get_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_singletons() -> Any:
|
||||||
|
"""Isolate the module-level singletons between tests."""
|
||||||
|
session_mod._engine = None
|
||||||
|
session_mod._session_factory = None
|
||||||
|
yield
|
||||||
|
session_mod._engine = None
|
||||||
|
session_mod._session_factory = None
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_session() -> AsyncMock:
|
||||||
|
session = AsyncMock()
|
||||||
|
session.__aenter__.return_value = session
|
||||||
|
session.__aexit__.return_value = False
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_factory(session: AsyncMock) -> MagicMock:
|
||||||
|
factory = MagicMock()
|
||||||
|
factory.return_value = session
|
||||||
|
return factory
|
||||||
|
|
||||||
|
|
||||||
|
async def test_engine_created_once_across_many_requests() -> None:
|
||||||
|
"""N get_db_session invocations must build exactly one engine (AC)."""
|
||||||
|
with (
|
||||||
|
patch("contract_check.core.db.session.create_async_engine") as mock_create,
|
||||||
|
patch("contract_check.core.db.session.async_sessionmaker") as mock_maker,
|
||||||
|
):
|
||||||
|
mock_create.return_value = AsyncMock()
|
||||||
|
mock_maker.return_value = _mock_factory(_mock_session())
|
||||||
|
|
||||||
|
for _ in range(5):
|
||||||
|
agen = get_db_session()
|
||||||
|
await agen.__anext__()
|
||||||
|
await agen.aclose()
|
||||||
|
|
||||||
|
mock_create.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_session_factory_singleton() -> None:
|
||||||
|
with patch("contract_check.core.db.session._get_engine") as mock_get_engine:
|
||||||
|
mock_get_engine.return_value = MagicMock()
|
||||||
|
|
||||||
|
factory1 = _get_session_factory()
|
||||||
|
factory2 = _get_session_factory()
|
||||||
|
|
||||||
|
assert factory1 is factory2
|
||||||
|
mock_get_engine.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_session_uses_shared_factory() -> None:
|
||||||
|
session = _mock_session()
|
||||||
|
with patch("contract_check.core.db.session._get_session_factory") as mock_get_factory:
|
||||||
|
mock_get_factory.return_value = _mock_factory(session)
|
||||||
|
|
||||||
|
agen = get_session()
|
||||||
|
assert await agen.__anext__() is session
|
||||||
|
await agen.aclose()
|
||||||
|
|
||||||
|
mock_get_factory.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_session_accepts_explicit_factory() -> None:
|
||||||
|
session = _mock_session()
|
||||||
|
factory = _mock_factory(session)
|
||||||
|
|
||||||
|
with patch("contract_check.core.db.session._get_session_factory") as mock_get_factory:
|
||||||
|
agen = get_session(factory=factory)
|
||||||
|
assert await agen.__anext__() is session
|
||||||
|
await agen.aclose()
|
||||||
|
|
||||||
|
mock_get_factory.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dispose_engine_disposes_and_resets_singletons() -> None:
|
||||||
|
with patch("contract_check.core.db.session.create_async_engine") as mock_create:
|
||||||
|
engine1 = AsyncMock()
|
||||||
|
engine2 = AsyncMock()
|
||||||
|
mock_create.side_effect = [engine1, engine2]
|
||||||
|
|
||||||
|
assert _get_engine() is engine1
|
||||||
|
await dispose_engine()
|
||||||
|
assert _get_engine() is engine2
|
||||||
|
|
||||||
|
engine1.dispose.assert_awaited_once()
|
||||||
|
assert session_mod._session_factory is None
|
||||||
|
assert mock_create.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dispose_engine_noop_when_never_built() -> None:
|
||||||
|
with patch("contract_check.core.db.session.create_async_engine") as mock_create:
|
||||||
|
await dispose_engine()
|
||||||
|
mock_create.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_explicit_create_still_builds_fresh_engines() -> None:
|
||||||
|
with patch("contract_check.core.db.session.create_async_engine") as mock_create:
|
||||||
|
mock_create.side_effect = [AsyncMock(), AsyncMock()]
|
||||||
|
|
||||||
|
assert create_engine() is not create_engine()
|
||||||
|
assert mock_create.call_count == 2
|
||||||
|
|
@ -99,7 +99,7 @@ def test_build_registration_options_matches_frontend_schema() -> None:
|
||||||
assert options["excludeCredentials"] == [{"id": existing, "type": "public-key"}]
|
assert options["excludeCredentials"] == [{"id": existing, "type": "public-key"}]
|
||||||
|
|
||||||
# Roundtrips through the API response model without coercion errors.
|
# Roundtrips through the API response model without coercion errors.
|
||||||
from contract_check.api.routes.auth import PasskeyRegistrationOptions
|
from contract_check.api.schemas import PasskeyRegistrationOptions
|
||||||
|
|
||||||
assert PasskeyRegistrationOptions(**options).challenge == challenge
|
assert PasskeyRegistrationOptions(**options).challenge == challenge
|
||||||
|
|
||||||
|
|
@ -113,7 +113,7 @@ def test_build_authentication_options_matches_frontend_schema() -> None:
|
||||||
assert options["allowCredentials"] == []
|
assert options["allowCredentials"] == []
|
||||||
assert options["userVerification"] == "required"
|
assert options["userVerification"] == "required"
|
||||||
|
|
||||||
from contract_check.api.routes.auth import PasskeyAuthenticationOptions
|
from contract_check.api.schemas import PasskeyAuthenticationOptions
|
||||||
|
|
||||||
assert PasskeyAuthenticationOptions(**options).rpId == "localhost"
|
assert PasskeyAuthenticationOptions(**options).rpId == "localhost"
|
||||||
|
|
||||||
|
|
|
||||||
233
tests/unit/test_repositories.py
Normal file
233
tests/unit/test_repositories.py
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
"""Unit tests for the repository layer.
|
||||||
|
|
||||||
|
Run against the compose Postgres stack; skipped automatically if it is down.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from contract_check.core.db.repositories import (
|
||||||
|
ApiKeyRepository,
|
||||||
|
CreditsRepository,
|
||||||
|
DocumentRepository,
|
||||||
|
JobRepository,
|
||||||
|
ReportRepository,
|
||||||
|
UserRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.usefixtures("db_session")
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserRepository:
|
||||||
|
async def test_create_and_get_email_user(self, db_session: AsyncSession) -> None:
|
||||||
|
repo = UserRepository(db_session)
|
||||||
|
user = await repo.create_email_user(
|
||||||
|
email="repo-test@example.com",
|
||||||
|
name="Repo Test",
|
||||||
|
password_hash="hash",
|
||||||
|
)
|
||||||
|
assert user.email == "repo-test@example.com"
|
||||||
|
loaded = await repo.get_by_id(user.id)
|
||||||
|
assert loaded is not None
|
||||||
|
assert loaded.email == user.email
|
||||||
|
|
||||||
|
async def test_telegram_user_unique(self, db_session: AsyncSession) -> None:
|
||||||
|
repo = UserRepository(db_session)
|
||||||
|
first = await repo.create_telegram_user(telegram_id=987654321)
|
||||||
|
second = await repo.create_telegram_user(telegram_id=987654322)
|
||||||
|
assert first.telegram_id != second.telegram_id
|
||||||
|
assert await repo.get_by_telegram_id(987654321) is not None
|
||||||
|
|
||||||
|
async def test_password_and_credits_adjustments(self, db_session: AsyncSession) -> None:
|
||||||
|
repo = UserRepository(db_session)
|
||||||
|
user = await repo.create_email_user(email="adjust@example.com", name="A", password_hash="h")
|
||||||
|
await repo.set_password(user.id, "new_hash")
|
||||||
|
await repo.adjust_credits(user.id, 5)
|
||||||
|
loaded = await repo.get_by_id(user.id)
|
||||||
|
assert loaded is not None
|
||||||
|
assert loaded.password_hash == "new_hash"
|
||||||
|
assert loaded.credits_left == 5
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreditsRepository:
|
||||||
|
async def test_reserve_refund_cycle(self, db_session: AsyncSession) -> None:
|
||||||
|
users = UserRepository(db_session)
|
||||||
|
credits = CreditsRepository(db_session)
|
||||||
|
user = await users.create_email_user(
|
||||||
|
email="credits@example.com", name="C", password_hash="h"
|
||||||
|
)
|
||||||
|
await users.adjust_credits(user.id, 10)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert await credits.reserve(user.id) is True
|
||||||
|
await db_session.commit()
|
||||||
|
assert await credits.get_balance(user.id) == 9
|
||||||
|
|
||||||
|
# Refund requires a document, so create one.
|
||||||
|
docs = DocumentRepository(db_session)
|
||||||
|
doc = await docs.create(
|
||||||
|
document_id=uuid.uuid4(),
|
||||||
|
user_id=user.id,
|
||||||
|
s3_key="s3://x",
|
||||||
|
filename="x.pdf",
|
||||||
|
mime="application/pdf",
|
||||||
|
bytes_=123,
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
refunded = await credits.refund(doc.id, "infra", "infra_only")
|
||||||
|
await db_session.commit()
|
||||||
|
assert refunded is True
|
||||||
|
assert await credits.get_balance(user.id) == 10
|
||||||
|
|
||||||
|
# Idempotent second refund.
|
||||||
|
assert await credits.refund(doc.id, "infra", "infra_only") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestDocumentRepository:
|
||||||
|
async def test_create_update_status(self, db_session: AsyncSession) -> None:
|
||||||
|
users = UserRepository(db_session)
|
||||||
|
repo = DocumentRepository(db_session)
|
||||||
|
user = await users.create_email_user(email="doc@example.com", name="D", password_hash="h")
|
||||||
|
doc_id = uuid.uuid4()
|
||||||
|
doc = await repo.create(
|
||||||
|
document_id=doc_id,
|
||||||
|
user_id=user.id,
|
||||||
|
s3_key="k",
|
||||||
|
filename="f.pdf",
|
||||||
|
mime="application/pdf",
|
||||||
|
bytes_=100,
|
||||||
|
)
|
||||||
|
await repo.update_status(doc.id, status="extracting", stage="downloading")
|
||||||
|
status = await repo.get_status(doc.id)
|
||||||
|
assert status == "extracting"
|
||||||
|
|
||||||
|
async def test_for_update_returns_status(self, db_session: AsyncSession) -> None:
|
||||||
|
users = UserRepository(db_session)
|
||||||
|
repo = DocumentRepository(db_session)
|
||||||
|
user = await users.create_email_user(email="lock@example.com", name="L", password_hash="h")
|
||||||
|
doc_id = uuid.uuid4()
|
||||||
|
await repo.create(
|
||||||
|
document_id=doc_id,
|
||||||
|
user_id=user.id,
|
||||||
|
s3_key="k",
|
||||||
|
filename="f.pdf",
|
||||||
|
mime="application/pdf",
|
||||||
|
bytes_=100,
|
||||||
|
)
|
||||||
|
status, filename = await repo.get_status_and_filename_for_update(doc_id)
|
||||||
|
assert status == "queued"
|
||||||
|
assert filename == "f.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
class TestJobRepository:
|
||||||
|
async def test_create_claim_done(self, db_session: AsyncSession) -> None:
|
||||||
|
users = UserRepository(db_session)
|
||||||
|
docs = DocumentRepository(db_session)
|
||||||
|
repo = JobRepository(db_session)
|
||||||
|
user = await users.create_email_user(email="job@example.com", name="J", password_hash="h")
|
||||||
|
doc = await docs.create(
|
||||||
|
document_id=uuid.uuid4(),
|
||||||
|
user_id=user.id,
|
||||||
|
s3_key="k",
|
||||||
|
filename="f.pdf",
|
||||||
|
mime="application/pdf",
|
||||||
|
bytes_=1,
|
||||||
|
)
|
||||||
|
cid = uuid.uuid4()
|
||||||
|
job = await repo.create(document_id=doc.id, correlation_id=cid, queue="extract")
|
||||||
|
assert job.status == "pending"
|
||||||
|
await repo.claim_start(doc.id, "extract")
|
||||||
|
loaded = await repo.get_by_document_id_and_queue(doc.id, "extract")
|
||||||
|
assert loaded is not None
|
||||||
|
assert loaded.status == "running"
|
||||||
|
assert loaded.attempts == 1
|
||||||
|
await repo.mark_done(doc.id, "extract")
|
||||||
|
loaded = await repo.get_by_document_id_and_queue(doc.id, "extract")
|
||||||
|
assert loaded is not None
|
||||||
|
assert loaded.status == "done"
|
||||||
|
|
||||||
|
|
||||||
|
class TestApiKeyRepository:
|
||||||
|
async def test_key_hash_lookup_and_quota(self, db_session: AsyncSession) -> None:
|
||||||
|
users = UserRepository(db_session)
|
||||||
|
repo = ApiKeyRepository(db_session)
|
||||||
|
user = await users.create_email_user(email="key@example.com", name="K", password_hash="h")
|
||||||
|
key = await repo.create(
|
||||||
|
user_id=user.id, name="test", key_hash="sha256-deadbeef", monthly_quota=10
|
||||||
|
)
|
||||||
|
found = await repo.get_by_hash("sha256-deadbeef")
|
||||||
|
assert found is not None
|
||||||
|
assert found.id == key.id
|
||||||
|
assert not found.revoked
|
||||||
|
quota, used = await repo.get_monthly_quota_state(key.id)
|
||||||
|
assert quota == 10
|
||||||
|
assert used == 0
|
||||||
|
await repo.bump_monthly_used(key.id)
|
||||||
|
quota, used = await repo.get_monthly_quota_state(key.id)
|
||||||
|
assert used == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestReportRepository:
|
||||||
|
async def test_upsert_analyze_report(self, db_session: AsyncSession) -> None:
|
||||||
|
users = UserRepository(db_session)
|
||||||
|
docs = DocumentRepository(db_session)
|
||||||
|
repo = ReportRepository(db_session)
|
||||||
|
user = await users.create_email_user(
|
||||||
|
email="report@example.com", name="R", password_hash="h"
|
||||||
|
)
|
||||||
|
doc = await docs.create(
|
||||||
|
document_id=uuid.uuid4(),
|
||||||
|
user_id=user.id,
|
||||||
|
s3_key="k",
|
||||||
|
filename="f.pdf",
|
||||||
|
mime="application/pdf",
|
||||||
|
bytes_=1,
|
||||||
|
)
|
||||||
|
report_id = await repo.upsert_analyze_report(
|
||||||
|
document_id=doc.id,
|
||||||
|
content_json={"findings": []},
|
||||||
|
markdown="# Report",
|
||||||
|
model_used="gpt-4",
|
||||||
|
prompt_tokens=10,
|
||||||
|
eval_tokens=20,
|
||||||
|
latency_ms=100,
|
||||||
|
)
|
||||||
|
loaded = await repo.get_by_document_id(doc.id)
|
||||||
|
assert loaded is not None
|
||||||
|
assert loaded.id == report_id
|
||||||
|
assert loaded.markdown == "# Report"
|
||||||
|
|
||||||
|
# Upsert is idempotent and updates.
|
||||||
|
await repo.upsert_analyze_report(
|
||||||
|
document_id=doc.id,
|
||||||
|
content_json={"findings": [{"x": 1}]},
|
||||||
|
markdown="# Updated",
|
||||||
|
model_used="gpt-4",
|
||||||
|
prompt_tokens=11,
|
||||||
|
eval_tokens=21,
|
||||||
|
latency_ms=101,
|
||||||
|
)
|
||||||
|
await db_session.refresh(loaded)
|
||||||
|
assert loaded.markdown == "# Updated"
|
||||||
|
|
||||||
|
|
||||||
|
class TestMagicLink:
|
||||||
|
async def test_round_trip(self, db_session: AsyncSession) -> None:
|
||||||
|
users = UserRepository(db_session)
|
||||||
|
user = await users.create_email_user(email="magic@example.com", name="M", password_hash="h")
|
||||||
|
expires = datetime.now(UTC) + timedelta(hours=1)
|
||||||
|
await users.set_magic_link_token(user.id, "sha256-abc", expires)
|
||||||
|
loaded = await users.get_by_magic_link_token_hash("sha256-abc")
|
||||||
|
assert loaded is not None
|
||||||
|
assert loaded.id == user.id
|
||||||
|
await users.consume_magic_link_token(user.id)
|
||||||
|
consumed = await users.get_by_id(user.id)
|
||||||
|
assert consumed is not None
|
||||||
|
assert consumed.magic_link_token_hash is None
|
||||||
|
assert consumed.magic_link_expires_at is None
|
||||||
383
uv.lock
generated
383
uv.lock
generated
|
|
@ -287,70 +287,6 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" },
|
{ url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ast-serialize"
|
|
||||||
version = "0.8.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e1/a9/11851c3e02a3fea2ddc9932d1fdc7d2edaeecc0d2e11bc5f2a7fde2b0934/ast_serialize-0.8.0.tar.gz", hash = "sha256:6c37c43e4004dfb42d321ddedc569dc17ff4259296f3af577c9ea46a809bc010", size = 845638, upload-time = "2026-08-07T11:29:02.152Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/34/16/6e520b57cd8c75914b38c670ad4593d13c22911e4306cc7165dab8b0789b/ast_serialize-0.8.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3d822605fa7bb326ef868d25fafced7fc660fa46d9b90c02ea86d5e2f5d325f7", size = 863924, upload-time = "2026-08-07T11:27:34.579Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/e1/48802de9b22a2bcad42ec80601a17e3f69172fe4f590e6311bcc2b323aeb/ast_serialize-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:2efa40b068197d5efb62655b43baadb842ed71c4958cccd3e8b86a35726f0119", size = 1177662, upload-time = "2026-08-07T11:27:36.196Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/d4/323438db76bded3a1f3523a3167b8325916b2ddceb2107a330c6ec9fcf4d/ast_serialize-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:db1b957291bca08c7e72f43a12357b2948e20775d970e3fc3dac0aa3160ab725", size = 1167072, upload-time = "2026-08-07T11:27:37.646Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/77/82/53c5400b54144b56de8ed7f957fd1ccd97e42482009292ab46121d15f8dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdc0d5b18ff8fb364e87923e47c0a91d0d69dbcaeaa274591f7fd26892cc3a3a", size = 1225497, upload-time = "2026-08-07T11:27:39.225Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/5f/36c07327a8b91303fbf1382c7c3e8a2902072dbe1b9546138a5288e75ff0/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9da7330f3e235bf7da89b8d39205c6350fc0c08a85379743f2df9fff87d6d980", size = 1227101, upload-time = "2026-08-07T11:27:40.799Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/48/5adf5c67addc7ddb328122208c6d375a84cf154984f412b4087330a157bd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3186969ee66a9863b00acc6523ace44c56974eecb348a7ea4b228d9f0b80e19", size = 1424001, upload-time = "2026-08-07T11:27:42.708Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/a1/70074dd3869d2b0e934f91891d8d6b734361cd3b80f85ca7ece2e668ecdd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40a57b73731be45da4fa41430c4d5dc94a24b3a4faba7b9e069978c0402064ea", size = 1245545, upload-time = "2026-08-07T11:27:44.4Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e3/be/53b9c0a8a6399950c2e3546bdfab96d2b299d5b114b47eb94fd3c49c4054/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b9da3ef807eda752502446dfecea3b381c4900b7e27a5d5f4f899eb39951", size = 1248961, upload-time = "2026-08-07T11:27:45.781Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/13/3651d3812548a2bda15e26e5dd51aadb48cf682d0865370255fcf0e367dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:293cc1c5bfa741f8e3fbe8175b9c07beee487c9a6fdbb25a5acad9f1df2d30a9", size = 1243877, upload-time = "2026-08-07T11:27:47.325Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/a0/521f0bf000f675e9312a4aae2c8ba7a992405d072a85c485e08fd59433b9/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0910c3442a75216dde0f102d854ba2aaa71d2482e0ee213630b9bf29584fba3", size = 1293903, upload-time = "2026-08-07T11:27:49.264Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/7e/402fc902568aa2ee65865a3e151f000db0153da8ce6b1be4c9c349025f8d/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43dd6d596879bb1cb8a12cc9dae7bb10090a39a35883026c24f82488a195619a", size = 1401070, upload-time = "2026-08-07T11:27:50.947Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/7c/97d4b66c057f1706fc8be6dd532cc77c988794357c8f4ffdb6adabb39562/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8c9d537f59e936392cfd3597789d1390304dd659efc3c486ce7f40fb6b8a9f53", size = 1502602, upload-time = "2026-08-07T11:27:52.364Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/6f/72cc3b71562001bba46e898ccfbf1844f7939b3e28912736206102f2e5a8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f0190a33d7f97c65e9069f7a7f40499eea6b5cbe260c558378109caf20ce934b", size = 1495848, upload-time = "2026-08-07T11:27:53.803Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/53/d6f629d1e49308b2f363dae028baa213ec222c9106fa1f7f0d1f7b41499a/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:77308ae6c5cf5264cc0f01a7c556ec77a9e68eb1f61b093534d698139fdc3b14", size = 1556556, upload-time = "2026-08-07T11:27:55.342Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ee/22/340f35dd8dfc6d412d53dc20699ca014b8d228db923e8ed4759c512b162c/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8d53a23f27e1ed3a36b2d26fd2a1a6228c8e85a1ed62ff7cdb44bd610769f20a", size = 1417822, upload-time = "2026-08-07T11:27:56.712Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/11/29/6dde5c13fbebc051d3a6df4ec0a6fd1d5359333cc1193f7f609f3410b4d8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa5e7cb08f96fed9121f77b224151e41caf88feab9d652bb46c78202b6fbeda", size = 1445153, upload-time = "2026-08-07T11:27:58.275Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/c5/f473a8ed030f7a0ca24b9849cca184677a50c053867a7b808c2e1289bbd3/ast_serialize-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:fa70ed4dea0bb18b30a1789c77baa701d0ef30c474f2ccabdea61e25623a8827", size = 1063711, upload-time = "2026-08-07T11:27:59.793Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/63/39e171fcd38ca057c2e1979d5ee81ac7a3502784abe3d83df7454f7a0978/ast_serialize-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d8b3c8eee4c1baef9d4e84d2a59a805501617127be42615cb48970b15b0892b6", size = 1103740, upload-time = "2026-08-07T11:28:01.405Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/1c/d00762b399e7726d68d0a088cc946e3a4c60f1c6176f557608f672f627f3/ast_serialize-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:ac4f0a83c55a9b782f79ad55a5247b7db123c1db405959791c2ef886e9710c9f", size = 1076021, upload-time = "2026-08-07T11:28:02.947Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/11/911210c3c78923273a9211a2b6cfc4c8aa723b30dab3e1c8d19afb983b40/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:86b8a1e6d90467345356098b040150e82fbc26d24a7a202224b13dc1f6264ca0", size = 1177715, upload-time = "2026-08-07T11:28:04.654Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/77/89/6282881c8587606638db153cbe21e1e0c4d1f3970dee1aa0610a1c62a026/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:39e92ff8e8cb45947fe9007174b2950e1fb098e6abd00266a13cd3bcf6675068", size = 1169347, upload-time = "2026-08-07T11:28:06.1Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/78/a9f846a03a340ff3728c915f23338ca742742f3292700559cdb3ad999b1e/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85d8d18db5b2dfcb3b7e38a4d600ca35504c0ed8a6f75cd1c811e4ffe248a15", size = 1225916, upload-time = "2026-08-07T11:28:07.654Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/15/aba6ef8a988a6eceb6f0359589aac509e29ae2dba67fd9bfd5af0c3f13e7/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9830ff7e764f74d9eefb01170c61a9f0fd2c027dac5fcb72e064decd57d56371", size = 1227135, upload-time = "2026-08-07T11:28:09.504Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/29/3f63d696ea7c5b8abadcecc3505be51bd900daaccc522ed8322fa5b05a93/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6479d9722a4cd21b578f5478074c41e6169f04811996ec881655560f703a5bba", size = 1425040, upload-time = "2026-08-07T11:28:11.044Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/5d/0aac338604ff59df5774d4304307898982252f325ff7cafe31d52fedcb65/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a63bed264e818cd83eec11feed0f50aa162542b91132ef58afebc857182763a5", size = 1246278, upload-time = "2026-08-07T11:28:12.519Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/ca/9f1ef795bb724719532bd86dbec11e5b66857d3fbe9b6772baec0191a6ed/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d187197d234aa45d6cfa2b096be5f666e8cc2e7eb3722d0ab8926293cf5720c", size = 1250029, upload-time = "2026-08-07T11:28:13.896Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/dc/25/5e061372d2ed953b9ba3b9c4f73de3b8e9234cda3f6c088db4686801d0e1/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:2d39a56282cfcc0d8eeea37267c754be59c98d48505c23b1dae5c6011f3813dd", size = 1243575, upload-time = "2026-08-07T11:28:15.37Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/c1/ae7da218053120635a4ca802366c69f707203641af95372eeb83f70dfd52/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7cc5f10386994c0f4844f1e6d6a97127e9b478660eb6dec2b257644f0acab64", size = 1294396, upload-time = "2026-08-07T11:28:16.813Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2e/89/271d1f49c5269fcddcc789ea3f25be401f6723fc1138aeda539f4d05516d/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6102f2f985c2e542be85cd857678ec9356fefa792b93cadfadd31139f5696f27", size = 1401987, upload-time = "2026-08-07T11:28:18.333Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/55/be/4e7d77fcf571ac7cb5cf7115a20c36642bd7d29473b45dfaaefeb9618f90/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:3a8660fe66667b76a6e9dccd1d33e66b229fde3b308db991c041609226c005b6", size = 1502904, upload-time = "2026-08-07T11:28:20.039Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/ae/ed1de2db7e019d4236fbc164ffa5ef9a6022a300a342bbf142d21b7c141e/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:e7266307e5fba39836edb79def8608887af48820508bff3c5f2941e1e04d1534", size = 1496967, upload-time = "2026-08-07T11:28:21.734Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/89/5fea507fae5c5f18b7dc7f95e5c00956574b8c717b8fd2049c504fab0b18/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca7e6fd1ad845d1cc649dc2ecd499db2f8f46af5bf8da7b70dd858774cc038b", size = 1559041, upload-time = "2026-08-07T11:28:23.194Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/71/478d69df21b64e064554a68134c94be304270316ca676a94e63c389a636a/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:2880350b13d3eae69a0d70bc1fb6c9bfaca4dbd0e20ba8cd1aa483080b56ff06", size = 1417367, upload-time = "2026-08-07T11:28:24.601Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/2d/8962dc8d5b3a9dc27b36f9db199afa25264c741505469d9ec10ffbfd2ba7/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:ab0f9a59f7d63d0d441b56b9a818b273705264352d5115cfee12e940e816d958", size = 1446178, upload-time = "2026-08-07T11:28:26.152Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4f/22/14d2ad4fd1d1bcd0dc687ca268e0630069f45162496260c0efb70ee0ea72/ast_serialize-0.8.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:0485a25ef519c62e749ee3c1ad8070e591b380d67226349eb5a70b228dc1ac4a", size = 1063811, upload-time = "2026-08-07T11:28:27.864Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/1d/84a327c0202a41aa5fdba3ade33904d6d8f3b9e6806fa83568d835395850/ast_serialize-0.8.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:bd84d60bca7079e741be4ac5dbe237751a59d7f6f9f0126b11880d63822cbe16", size = 1105518, upload-time = "2026-08-07T11:28:29.691Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8c/92/74556dec52fde85a2ad84ed159991b916241043788609c15d8b77e14570b/ast_serialize-0.8.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:057769b5921336eb2d9124f2a731b42ed05ffdac559b840dbdf6f3937cf153dc", size = 1076319, upload-time = "2026-08-07T11:28:31.282Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/5d/c650b1f2cc1e75193358da95a080261422e8cd10b66d7370b1688c9915c5/ast_serialize-0.8.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:a02cbed7d8bfdcdee88edaac12bd50d53d9953aaa2e1852ef078625be5f1c0b5", size = 852914, upload-time = "2026-08-07T11:28:32.929Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/e3/6142e920fec6ef7bccabd8c24ed8ed99f8bdc6cb8b065e1df7c6a3b2d667/ast_serialize-0.8.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e1bd223df0f6c96b396975fa604cb33bce53d9b4a0185490be4c4a289f7c9c87", size = 1184007, upload-time = "2026-08-07T11:28:34.654Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/e9/6e8be8df02b35d85e2b8809f7f1cfa290bdf5882b55127a539d049482db0/ast_serialize-0.8.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ddd3b61f45c132da66c5476b281891e08c1fd87fbdabe8a6973e1622efc85f06", size = 1177588, upload-time = "2026-08-07T11:28:36.318Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8c/80/7e0fd2e2e2aba257820db4a8657c4c356844d36b914b20a4af294bcfb902/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f9caa63fad8241257ae401b5ff0a64026c6adb36b8e86cbe8782d9ea505daf6", size = 1234575, upload-time = "2026-08-07T11:28:37.772Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/6a/3bae0af06f9b1bae3001c44d64215f5b567877e7aae9ffd45db11c3a7647/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3926fa117b5e65019853a2969966d11c7175af377a3425991f3fe73784412405", size = 1236015, upload-time = "2026-08-07T11:28:39.14Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/c4/ce2d41a1bc22508e82618901f7e10f2a5e2f9556553fea90624daf9875e2/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485f1113af805e9e170b95ef993ca3fbd4f89c04bab25c58b4fc632d854801ab", size = 1432808, upload-time = "2026-08-07T11:28:40.664Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/90/f5058f209756dd70e958b7538aaa82d25d24944baf9ec8ae6f27b06fcacc/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccebbed24f1281062d5852353c72c47502955926cfcb8345ffb3a44d87ff3d3", size = 1256251, upload-time = "2026-08-07T11:28:42.223Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/32/7f77ea87fa0836daab706ed5cb7f903bb25fa26a77439011aee626af11d8/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:252f883290d1cdb728eb7fe1d9a7221b88af5a329aae0bc91ddee4dafb820331", size = 1258574, upload-time = "2026-08-07T11:28:43.751Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/5a/75b82ad2725b5e8e8c742732f9e76c6738a292d0709e1f60d10a973730b4/ast_serialize-0.8.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:96abc072ad29db8d02194afd47d68987322622787daceae82398d7b69f3ba2e6", size = 1254075, upload-time = "2026-08-07T11:28:45.28Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/54/8c20ed4eea805516a3fd23dd4a721ce28c64f50f0e4b359969f60a8c97a6/ast_serialize-0.8.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9118ad3e369727060b2696fc4078f250ecffca4248ba87f537f55cea9f9dce06", size = 1301018, upload-time = "2026-08-07T11:28:46.851Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/5b/9f14430f12fe830b656fb38f8e2e05ee13b02a88967660bef46af0ab22a8/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f359df4bd921918af8bebd142a376c77511d7151cc8ba852760b587b5a4a54f3", size = 1409951, upload-time = "2026-08-07T11:28:48.312Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2d/3d/084882eca93c842bd4262591a071ec7f825340644035e51501208cc5a8d4/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e94f9121d13fa36cbf21314783c77d05ae3a0868decd18cf5233fdcc6de49ac8", size = 1509544, upload-time = "2026-08-07T11:28:49.847Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/73/ea84852096c2036c61cc0b2f97b90242207419f534dc671060ee1c8e05cb/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:54f95b486018d262bcb387a9afd96f0da74508b442762b80c769454a6fbb3ee3", size = 1505671, upload-time = "2026-08-07T11:28:51.239Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/88/287b9a5300c1f2f651d259f670931b63110adc265b7613c885b44c5bc53d/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c38b915511e32bc718c49dbce98ff9af36bac0ad6a604f58000cd5e3aecdba7", size = 1563685, upload-time = "2026-08-07T11:28:53.112Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ee/f3/1bc3a79afcf0c2a8d2c37182d0d659d1545a9d7f7f6dc9cf3e63d6c17135/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a2ef9cf12f2de4f1028c42c1dd7d775255e0fb3e5bb48896c97e35ef52366fe", size = 1427977, upload-time = "2026-08-07T11:28:54.418Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/cd/440c798957e14e31776bfeb024d8fafe0bb1d5b89c51c2f067e69938f7b0/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f18048fe9f6dd266bd577cdec48bdcecb74faaa01fe941324435483b013ed2a", size = 1454335, upload-time = "2026-08-07T11:28:55.968Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4f/4a/587eb36dcc240a54c8660f599464516b469ecad96f0dbdb6bccbedb50745/ast_serialize-0.8.0-cp39-abi3-win32.whl", hash = "sha256:31883542dd6c94d178f5db3d32fbd69c5eb88b3a7c018e7ac8cc0c45195ddbed", size = 1068858, upload-time = "2026-08-07T11:28:57.541Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5f/a4/3e887bbd92164e183cb6e412c6a3e9198ddd446d7fe405958293ef5ef49c/ast_serialize-0.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:861794565b06337005c1447ef23103a3d5a627d08bdc827870d00d0b28ef5f51", size = 1111839, upload-time = "2026-08-07T11:28:59Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "asyncpg"
|
name = "asyncpg"
|
||||||
version = "0.31.0"
|
version = "0.31.0"
|
||||||
|
|
@ -769,7 +705,6 @@ dev = [
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2" },
|
||||||
{ name = "mammoth" },
|
{ name = "mammoth" },
|
||||||
{ name = "minio" },
|
{ name = "minio" },
|
||||||
{ name = "mypy" },
|
|
||||||
{ name = "opentelemetry-exporter-otlp" },
|
{ name = "opentelemetry-exporter-otlp" },
|
||||||
{ name = "opentelemetry-instrumentation-asgi" },
|
{ name = "opentelemetry-instrumentation-asgi" },
|
||||||
{ name = "opentelemetry-instrumentation-fastapi" },
|
{ name = "opentelemetry-instrumentation-fastapi" },
|
||||||
|
|
@ -783,6 +718,7 @@ dev = [
|
||||||
{ name = "pytesseract" },
|
{ name = "pytesseract" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
|
{ name = "pytest-cov" },
|
||||||
{ name = "python-docx" },
|
{ name = "python-docx" },
|
||||||
{ name = "python-magic" },
|
{ name = "python-magic" },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
|
|
@ -792,7 +728,6 @@ dev = [
|
||||||
{ name = "sentry-sdk" },
|
{ name = "sentry-sdk" },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
{ name = "striprtf" },
|
{ name = "striprtf" },
|
||||||
{ name = "testcontainers", extra = ["minio", "rabbitmq"] },
|
|
||||||
{ name = "ty" },
|
{ name = "ty" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
{ name = "webauthn" },
|
{ name = "webauthn" },
|
||||||
|
|
@ -926,7 +861,6 @@ dev = [
|
||||||
{ name = "jinja2", specifier = ">=3.1" },
|
{ name = "jinja2", specifier = ">=3.1" },
|
||||||
{ name = "mammoth", specifier = ">=1.8" },
|
{ name = "mammoth", specifier = ">=1.8" },
|
||||||
{ name = "minio", specifier = ">=7.2" },
|
{ name = "minio", specifier = ">=7.2" },
|
||||||
{ name = "mypy", specifier = ">=1.10" },
|
|
||||||
{ name = "opentelemetry-exporter-otlp", specifier = ">=1.24" },
|
{ name = "opentelemetry-exporter-otlp", specifier = ">=1.24" },
|
||||||
{ name = "opentelemetry-instrumentation-asgi", specifier = ">=0.45b0" },
|
{ name = "opentelemetry-instrumentation-asgi", specifier = ">=0.45b0" },
|
||||||
{ name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.45b0" },
|
{ name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.45b0" },
|
||||||
|
|
@ -940,6 +874,7 @@ dev = [
|
||||||
{ name = "pytesseract", specifier = ">=0.3.10" },
|
{ name = "pytesseract", specifier = ">=0.3.10" },
|
||||||
{ name = "pytest", specifier = ">=8" },
|
{ name = "pytest", specifier = ">=8" },
|
||||||
{ name = "pytest-asyncio", specifier = ">=0.23" },
|
{ name = "pytest-asyncio", specifier = ">=0.23" },
|
||||||
|
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
||||||
{ name = "python-docx", specifier = ">=1.1" },
|
{ name = "python-docx", specifier = ">=1.1" },
|
||||||
{ name = "python-magic", specifier = ">=0.4.27" },
|
{ name = "python-magic", specifier = ">=0.4.27" },
|
||||||
{ name = "python-multipart", specifier = ">=0.0.9" },
|
{ name = "python-multipart", specifier = ">=0.0.9" },
|
||||||
|
|
@ -949,7 +884,6 @@ dev = [
|
||||||
{ name = "sentry-sdk", specifier = ">=2" },
|
{ name = "sentry-sdk", specifier = ">=2" },
|
||||||
{ name = "sqlalchemy", specifier = ">=2.0" },
|
{ name = "sqlalchemy", specifier = ">=2.0" },
|
||||||
{ name = "striprtf", specifier = ">=0.0.26" },
|
{ name = "striprtf", specifier = ">=0.0.26" },
|
||||||
{ name = "testcontainers", extras = ["rabbitmq", "postgres", "minio"], specifier = ">=4" },
|
|
||||||
{ name = "ty", specifier = ">=0.0.72" },
|
{ name = "ty", specifier = ">=0.0.72" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29" },
|
||||||
{ name = "webauthn", specifier = ">=2.5" },
|
{ name = "webauthn", specifier = ">=2.5" },
|
||||||
|
|
@ -1008,6 +942,90 @@ prototype = [
|
||||||
]
|
]
|
||||||
s3 = [{ name = "minio", specifier = ">=7.2" }]
|
s3 = [{ name = "minio", specifier = ">=7.2" }]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coverage"
|
||||||
|
version = "7.15.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cryptography"
|
name = "cryptography"
|
||||||
version = "50.0.0"
|
version = "50.0.0"
|
||||||
|
|
@ -1076,20 +1094,6 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "docker"
|
|
||||||
version = "7.2.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
|
||||||
{ name = "requests" },
|
|
||||||
{ name = "urllib3" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/88/7f/731ff914b0255d3d065f45fd4e626d4b8c95dbcbaada049f337a6ac16410/docker-7.2.0.tar.gz", hash = "sha256:cebb93773d334f778e023a7ee352a8d6e13ab1bd3b863a4d4a59dec897df43ac", size = 118731, upload-time = "2026-07-09T14:53:46.39Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/23/529140fe1aab80fc6992f93a706deec709140a6397439139a054e1515c45/docker-7.2.0-py3-none-any.whl", hash = "sha256:a3f45fdeb9165e2d25d9a1d02ddf3bc70fb572cf5ebbf9b58558c22caf29b71f", size = 148775, upload-time = "2026-07-09T14:53:45.224Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "email-validator"
|
name = "email-validator"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
|
|
@ -1441,92 +1445,6 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "librt"
|
|
||||||
version = "0.15.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lxml"
|
name = "lxml"
|
||||||
version = "6.1.1"
|
version = "6.1.1"
|
||||||
|
|
@ -1771,63 +1689,6 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
|
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mypy"
|
|
||||||
version = "2.3.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "ast-serialize" },
|
|
||||||
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
|
||||||
{ name = "mypy-extensions" },
|
|
||||||
{ name = "pathspec" },
|
|
||||||
{ name = "typing-extensions" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mypy-extensions"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nodeenv"
|
name = "nodeenv"
|
||||||
version = "1.10.0"
|
version = "1.10.0"
|
||||||
|
|
@ -2039,24 +1900,6 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/14/1dfc08b743ba995a38dee0ea09beb46a05c7fe8ac53d729095905f7bf11d/pamqp-4.0.1-py3-none-any.whl", hash = "sha256:a547f45128b06e42ce8d7a739b0cfcc40f2c724770622eaaff4a3f587b1cf7d0", size = 32773, upload-time = "2026-07-06T16:37:50.623Z" },
|
{ url = "https://files.pythonhosted.org/packages/71/14/1dfc08b743ba995a38dee0ea09beb46a05c7fe8ac53d729095905f7bf11d/pamqp-4.0.1-py3-none-any.whl", hash = "sha256:a547f45128b06e42ce8d7a739b0cfcc40f2c724770622eaaff4a3f587b1cf7d0", size = 32773, upload-time = "2026-07-06T16:37:50.623Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pathspec"
|
|
||||||
version = "1.1.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pika"
|
|
||||||
version = "1.4.4"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/7f/26/54e0b98a7f60b474cb0a6c05ecf048d4bc8c866e10ab1c82bc83865e7421/pika-1.4.4.tar.gz", hash = "sha256:8cfc8b33a5cb16e733bd60cffca9732c0d1d761ecd80a89f34ed7df2cd38d6d6", size = 154713, upload-time = "2026-08-06T21:33:39.836Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/f3/921170b78779ac3f8b405cb28fce88acca1562ac114072cbf0a115e19bb9/pika-1.4.4-py3-none-any.whl", hash = "sha256:48de960c97a93b55db06b8be4c53eb977c9c8a2754c57cdae9097abcbd70ce04", size = 165275, upload-time = "2026-08-06T21:33:38.449Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pillow"
|
name = "pillow"
|
||||||
version = "12.3.0"
|
version = "12.3.0"
|
||||||
|
|
@ -2493,6 +2336,20 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-cov"
|
||||||
|
version = "7.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "coverage" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-discovery"
|
name = "python-discovery"
|
||||||
version = "1.5.2"
|
version = "1.5.2"
|
||||||
|
|
@ -2545,22 +2402,6 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pywin32"
|
|
||||||
version = "312"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyyaml"
|
name = "pyyaml"
|
||||||
version = "6.0.3"
|
version = "6.0.3"
|
||||||
|
|
@ -2738,30 +2579,6 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" },
|
{ url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "testcontainers"
|
|
||||||
version = "4.15.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "docker" },
|
|
||||||
{ name = "python-dotenv" },
|
|
||||||
{ name = "typing-extensions" },
|
|
||||||
{ name = "urllib3" },
|
|
||||||
{ name = "wrapt" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/4b/13/2cc466bddf26d0085f30a2b2bd56b7f8708b54a54db833eec97c5c69129b/testcontainers-4.15.0.tar.gz", hash = "sha256:085cde086337632e19002719460b7b80bbab2bdd51bb3ea04f77d0de96504706", size = 95340, upload-time = "2026-07-24T23:08:01.731Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/7e/424aac8b355597835deb333e757a0e94b5ccf38ad00f07fe6ed1f4e17c88/testcontainers-4.15.0-py3-none-any.whl", hash = "sha256:8796c14e76604031ad39cf0ed3b8e9806283a1fbf5270965c2b1c594caa31b74", size = 160771, upload-time = "2026-07-24T23:08:00.13Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.optional-dependencies]
|
|
||||||
minio = [
|
|
||||||
{ name = "minio" },
|
|
||||||
]
|
|
||||||
rabbitmq = [
|
|
||||||
{ name = "pika" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ty"
|
name = "ty"
|
||||||
version = "0.0.72"
|
version = "0.0.72"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue