Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,23 @@ API_PORT=8000
AI_INFERENCE_HOST=127.0.0.1
AI_INFERENCE_PORT=8001
REDIS_URL=redis://localhost:6379/0
wsq/i-01b-database-foundation
# Identity and foundation APIs are opt-in; use local HTTPS with this exact origin.
API_IDENTITY_ENABLED=0
PUBLIC_ORIGIN=https://labsafe.example.org
CSRF_KEY_FILE=.local-secrets/csrf-key
DATABASE_URL_FILE=.local-secrets/database-url
DATABASE_SCHEMA=labsafe_dev_local

# Dev/test upload grants, validation acceptance and image metadata only. Requires a private,
# versioned bucket, least-privilege API credentials and same-origin HTTPS:443. No consumer yet.
API_UPLOADS_ENABLED=0
S3_ENDPOINT=http://127.0.0.1:9000
S3_PUBLIC_ENDPOINT=https://labsafe.example.org
S3_BUCKET=labsafe-private
S3_REGION=us-east-1
S3_ACCESS_KEY_FILE=.local-secrets/s3-api-access-key
S3_SECRET_KEY_FILE=.local-secrets/s3-api-secret-key

AI_MODE=mock
AI_TOKEN_FILE=.local-secrets/ai-token
AI_ALLOWED_TENANTS=11111111-1111-4111-8111-111111111111
Expand Down
11 changes: 10 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jobs:
- run: python -m pip check
- run: python -m unittest discover -s tests/business -t . -v
- run: python -m unittest discover -s tests/protocol -t . -v
- run: python -m pytest tests/business tests/domain tests/security -q

- run: ruff check apps packages tests tools/database
- run: ruff format --check apps packages tests tools/database
Expand All @@ -49,7 +50,7 @@ jobs:
- name: AI environment must not install business or model dependencies
run: |

python -c "import importlib.util; names=['celery','redis','sqlalchemy','alembic','pymysql','argon2','torch','paddle','onnxruntime']; assert all(importlib.util.find_spec(n) is None for n in names)"
python -c "import importlib.util; names=['celery','redis','sqlalchemy','alembic','pymysql','argon2','boto3','botocore','torch','paddle','onnxruntime']; assert all(importlib.util.find_spec(n) is None for n in names)"

- run: python -m unittest discover -s tests/ai -t . -v
- run: python -m unittest discover -s tests/protocol -t . -v
Expand All @@ -71,6 +72,13 @@ jobs:
persistence-mysql:
runs-on: ubuntu-latest
services:
redis:
image: redis:7-alpine
ports:
- 6379/tcp
options: >-
--health-cmd="redis-cli ping"
--health-interval=5s --health-timeout=5s --health-retries=20
mysql:
image: mysql:8.0.33
env:
Expand All @@ -96,6 +104,7 @@ jobs:
- run: python tools/database/run_mysql_tests.py --ci
env:
LABSAFE_CI_MYSQL_PORT: ${{ job.services.mysql.ports[3306] }}
LABSAFE_CI_REDIS_PORT: ${{ job.services.redis.ports[6379] }}


web:
Expand Down
130 changes: 119 additions & 11 deletions DEVELOPMENT.md

Large diffs are not rendered by default.

172 changes: 172 additions & 0 deletions apps/api/app/foundations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
"""Strict public organization, template and inspection-draft adapters."""

from typing import Annotated, Literal
from uuid import UUID

from fastapi import APIRouter, Request
from pydantic import AfterValidator, Field

from apps.api.app.identity import Body, CanonicalJSONResponse, VersionCommand, pagination
from packages.application.foundations import FoundationApplication
from packages.domain.security import ServiceError
from packages.persistence.security import SESSION_COOKIE


def canonical_uuid(value):
parsed = str(UUID(value))
if len(value) != 36 or parsed != value.lower():
raise ValueError("Invalid UUID")
return parsed


Identifier = Annotated[str, AfterValidator(canonical_uuid)]
Name = Annotated[str, Field(min_length=1, max_length=200)]
Code = Annotated[str, Field(min_length=1, max_length=64)]


class CollegeCreate(Body):
name: Name
code: Code


class LaboratoryCreate(CollegeCreate):
college_id: Identifier


class LocationCreate(Body):
parent_id: Identifier | None
type: Literal["room", "area", "shelf", "cabinet"]
label: Name


class TemplateItem(Body):
id: Identifier
code: Code
title: Name
capture_hint: Annotated[str, Field(min_length=1, max_length=1000)]
sort_order: Annotated[int, Field(ge=0, le=2147483647)]
required: bool


class TemplateCreate(Body):
name: Name
items: Annotated[list[TemplateItem], Field(min_length=1, max_length=100)]


class InspectionCreate(Body):
laboratory_id: Identifier
template_id: Identifier
location_ids: Annotated[list[Identifier], Field(min_length=1, max_length=100)]


def mount_foundations(app, identity):
app.state.foundations = FoundationApplication(identity) if identity is not None else None
router = APIRouter(prefix="/api/v1")

def context(request, allowed=()):
if any(
key not in allowed or len(request.query_params.getlist(key)) != 1
for key in request.query_params
):
raise ServiceError("VALIDATION_ERROR", 422, "Unexpected or duplicate query parameter")
if app.state.foundations is None:
raise ServiceError("DEPENDENCY_UNAVAILABLE", 503, "Foundation service not configured")
return app.state.foundations

def read(request, kind, resource_id=None, laboratory_id=None, *, scoped=False):
allowed = () if resource_id else ("page", "page_size")
if scoped and resource_id is None:
allowed += ("laboratory_id",)
service = context(request, allowed)
page, size = pagination(request)
return service.read(
kind,
request.cookies.get(SESSION_COOKIE),
request.state.request_id,
resource_id=str(resource_id) if resource_id else None,
laboratory_id=str(laboratory_id) if laboratory_id else None,
page=page,
page_size=size,
)

def write(request, kind, body, *, operation="create", resource_id=None, laboratory_id=None):
status, response = context(request).write(
kind,
operation,
request.cookies.get(SESSION_COOKIE),
request.headers["x-csrf-token"],
request.headers["idempotency-key"],
body.model_dump(),
request.state.request_id,
resource_id=str(resource_id) if resource_id else None,
laboratory_id=str(laboratory_id) if laboratory_id else None,
)
return CanonicalJSONResponse(response, status_code=status)

@router.get("/colleges", operation_id="listColleges")
def colleges(request: Request):
return read(request, "college")

@router.get("/colleges/{resource_id}", operation_id="getCollege")
def college(resource_id: UUID, request: Request):
return read(request, "college", resource_id)

@router.post("/colleges", status_code=201, operation_id="createCollege")
def create_college(body: CollegeCreate, request: Request):
return write(request, "college", body)

@router.get("/laboratories", operation_id="listLaboratories")
def laboratories(request: Request, laboratory_id: UUID | None = None):
return read(request, "laboratory", laboratory_id=laboratory_id, scoped=True)

@router.get("/laboratories/{resource_id}", operation_id="getLaboratory")
def laboratory(resource_id: UUID, request: Request):
return read(request, "laboratory", resource_id)

@router.post("/laboratories", status_code=201, operation_id="createLaboratory")
def create_laboratory(body: LaboratoryCreate, request: Request):
return write(request, "laboratory", body)

@router.get("/laboratories/{laboratory_id}/locations", operation_id="listLocations")
def locations(laboratory_id: UUID, request: Request):
return read(request, "location", laboratory_id=laboratory_id)

@router.post(
"/laboratories/{laboratory_id}/locations", status_code=201, operation_id="createLocation"
)
def create_location(laboratory_id: UUID, body: LocationCreate, request: Request):
return write(request, "location", body, laboratory_id=laboratory_id)

@router.get("/templates", operation_id="listTemplates")
def templates(request: Request):
return read(request, "template")

@router.get("/templates/{resource_id}", operation_id="getTemplate")
def template(resource_id: UUID, request: Request):
return read(request, "template", resource_id)

@router.post("/templates", status_code=201, operation_id="createTemplate")
def create_template(body: TemplateCreate, request: Request):
return write(request, "template", body)

@router.post("/templates/{resource_id}/publish", operation_id="publishTemplate")
def publish_template(resource_id: UUID, body: VersionCommand, request: Request):
return write(request, "template", body, operation="publish", resource_id=resource_id)

@router.post("/templates/{resource_id}/clone", status_code=201, operation_id="cloneTemplate")
def clone_template(resource_id: UUID, body: VersionCommand, request: Request):
return write(request, "template", body, operation="clone", resource_id=resource_id)

@router.get("/inspections", operation_id="listInspections")
def inspections(request: Request, laboratory_id: UUID | None = None):
return read(request, "inspection", laboratory_id=laboratory_id, scoped=True)

@router.get("/inspections/{resource_id}", operation_id="getInspection")
def inspection(resource_id: UUID, request: Request):
return read(request, "inspection", resource_id)

@router.post("/inspections", status_code=201, operation_id="createInspection")
def create_inspection(body: InspectionCreate, request: Request):
return write(request, "inspection", body)

app.include_router(router)
Loading
Loading