From d73b421de9f8f782d429045dae18630e682ece42 Mon Sep 17 00:00:00 2001 From: Abby Bigaouette Date: Tue, 26 May 2026 00:37:05 -0700 Subject: [PATCH 1/2] Build report, expanded MCP procedure tools, hierarchical seed templates - BUILD REPORT: new printable /executions/{id}/report page with closeout photo support (new attachment kind "closeout", PATCH /api/attachments/{id}, template/CSS updates, smoke test). - MCP server: full procedure CRUD (get/update/delete procedure and steps, reorder, dependencies, kit/output management, step kit, publish_version, list_versions, clone_procedure) plus create_risk. Adds mcp>=1.0 dep. - opal migrate: honor --project/--database so migrations target the right project DB instead of whatever the ambient env points to. - Seed: rewrite all 6 procedures into operation/step hierarchy (52 ops, 194 sub-steps with requires_signoff / required_data_schema on measurement and verification steps); add default users (admin id=1 plus two non-admins); embed parent_step_id, kit_items, and output_items in version snapshots so executions render the tree. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 1 + src/opal/__main__.py | 5 + src/opal/api/routes/attachments.py | 44 +- src/opal/db/models/attachment.py | 2 +- src/opal/mcp/server.py | 1755 ++++++++- src/opal/seed.py | 3238 +++++++++++++---- src/opal/web/routes.py | 202 + src/opal/web/static/css/main.css | 41 +- src/opal/web/templates/datasets/detail.html | 2 + src/opal/web/templates/datasets/new.html | 14 +- src/opal/web/templates/executions/detail.html | 30 +- src/opal/web/templates/executions/report.html | 477 +++ .../web/templates/executions/tabs/meta.html | 20 +- tests/unit/test_executions_report.py | 137 + uv.lock | 378 ++ 15 files changed, 5607 insertions(+), 739 deletions(-) create mode 100644 src/opal/web/templates/executions/report.html create mode 100644 tests/unit/test_executions_report.py diff --git a/pyproject.toml b/pyproject.toml index e1cff4a..77df2d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "httpx>=0.28.0", "segno>=1.6.0", "packaging>=24.0", + "mcp>=1.0", ] [project.optional-dependencies] diff --git a/src/opal/__main__.py b/src/opal/__main__.py index fcd7ba0..5b07387 100644 --- a/src/opal/__main__.py +++ b/src/opal/__main__.py @@ -59,6 +59,10 @@ def cmd_migrate(args: argparse.Namespace) -> None: from opal.config import get_active_settings + # Honor --project / --database so migrations can target any project's DB, + # not just whatever the ambient environment points to. + _setup_project(args) + # Find project root by looking for alembic.ini opal_dir = Path(__file__).resolve().parent.parent.parent if not (opal_dir / "alembic.ini").exists(): @@ -234,6 +238,7 @@ def add_project_args(p: argparse.ArgumentParser) -> None: ) migrate_parser.add_argument("--revision", type=str, help="Target revision") migrate_parser.add_argument("--message", "-m", type=str, help="Migration message") + add_project_args(migrate_parser) migrate_parser.set_defaults(func=cmd_migrate) # seed command diff --git a/src/opal/api/routes/attachments.py b/src/opal/api/routes/attachments.py index d0f5c37..1d4870c 100644 --- a/src/opal/api/routes/attachments.py +++ b/src/opal/api/routes/attachments.py @@ -9,7 +9,7 @@ from opal.api.deps import CurrentUserId, DbSession from opal.config import get_active_settings -from opal.core.audit import log_create, log_delete +from opal.core.audit import get_model_dict, log_create, log_delete, log_update from opal.db.models.attachment import Attachment from opal.db.models.execution import ProcedureInstance, StepExecution from opal.db.models.issue import Issue @@ -17,6 +17,9 @@ router = APIRouter(prefix="/attachments", tags=["attachments"]) +ALLOWED_KINDS: set[str | None] = {None, "inline", "reference", "closeout"} + + class AttachmentResponse(BaseModel): """Schema for attachment response.""" @@ -35,6 +38,12 @@ class AttachmentResponse(BaseModel): model_config = {"from_attributes": True} +class AttachmentPatchRequest(BaseModel): + """Partial update payload for an attachment.""" + + kind: str | None = None + + def _attachment_to_response(att: Attachment) -> AttachmentResponse: return AttachmentResponse( id=att.id, @@ -68,6 +77,12 @@ async def upload_attachment( procedure template. `procedure_id` scopes inline images used in step instructions so they're cleaned up when the procedure is deleted. """ + if kind is not None and kind not in ALLOWED_KINDS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid kind '{kind}'. Allowed: {sorted(k for k in ALLOWED_KINDS if k)}", + ) + settings = get_active_settings() # Validate MIME type @@ -207,6 +222,33 @@ async def list_attachments( return [_attachment_to_response(a) for a in attachments] +@router.patch("/{attachment_id}", response_model=AttachmentResponse) +async def patch_attachment( + db: DbSession, + attachment_id: int, + payload: AttachmentPatchRequest, + user_id: CurrentUserId, +) -> AttachmentResponse: + """Update mutable fields on an attachment (currently just `kind`).""" + attachment = db.query(Attachment).filter(Attachment.id == attachment_id).first() + if not attachment: + raise HTTPException(status_code=404, detail="Attachment not found") + + new_kind = payload.kind + if new_kind is not None and new_kind not in ALLOWED_KINDS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid kind '{new_kind}'. Allowed: {sorted(k for k in ALLOWED_KINDS if k)}", + ) + + old_values = get_model_dict(attachment) + attachment.kind = new_kind + log_update(db, attachment, old_values, user_id) + db.commit() + db.refresh(attachment) + return _attachment_to_response(attachment) + + @router.delete("/{attachment_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_attachment( db: DbSession, diff --git a/src/opal/db/models/attachment.py b/src/opal/db/models/attachment.py index db738bb..ef8647a 100644 --- a/src/opal/db/models/attachment.py +++ b/src/opal/db/models/attachment.py @@ -21,7 +21,7 @@ class Attachment(Base, IdMixin, TimestampMixin): String(20), nullable=True, index=True, - comment="'inline' = embedded in markdown content; 'reference' = downloadable doc; null = legacy/unscoped", + comment="'inline' = embedded in markdown content; 'reference' = downloadable doc; 'closeout' = end-item closeout photo surfaced in build reports; null = legacy/unscoped", ) # Optional links - attachment can belong to instance, step, issue, procedure, or neither diff --git a/src/opal/mcp/server.py b/src/opal/mcp/server.py index c49054a..a928a9d 100644 --- a/src/opal/mcp/server.py +++ b/src/opal/mcp/server.py @@ -3,25 +3,34 @@ import json import logging from datetime import UTC, datetime +from decimal import Decimal from typing import Any from mcp.server import Server from mcp.server.stdio import stdio_server from mcp.types import TextContent, Tool +from sqlalchemy import func from opal.config import get_active_project, get_active_settings -from opal.core.designators import generate_issue_number +from opal.core.audit import get_model_dict, log_create, log_delete, log_update +from opal.core.designators import generate_issue_number, generate_risk_number from opal.db.base import SessionLocal from opal.db.models import ( BOMLine, Issue, + Kit, MasterProcedure, Part, PartRequirement, + ProcedureOutput, + ProcedureStep, + ProcedureVersion, Risk, + StepDependency, + StepKit, ) from opal.db.models.issue import IssuePriority, IssueStatus, IssueType -from opal.db.models.procedure import ProcedureStatus, ProcedureStep +from opal.db.models.procedure import ProcedureStatus, ProcedureType, UsageType from opal.db.models.risk import RiskStatus logger = logging.getLogger(__name__) @@ -181,7 +190,12 @@ async def list_tools() -> list[Tool]: ), Tool( name="create_procedure", - description="Create a new procedure (draft status)", + description=( + "Create a new master procedure template in draft status. " + "procedure_type is 'op' (work order, no output) or 'build' " + "(produces an assembly via ProcedureOutput). Status is always " + "'draft' until published via the web UI / publish endpoint." + ), inputSchema={ "type": "object", "properties": { @@ -193,13 +207,30 @@ async def list_tools() -> list[Tool]: "type": "string", "description": "Procedure description (optional)", }, + "procedure_type": { + "type": "string", + "enum": ["op", "build"], + "description": ( + "'op' = work order with no part output (default); " + "'build' = produces an assembly. Outputs are configured " + "separately via the web UI / API." + ), + "default": "op", + }, }, "required": ["name"], }, ), Tool( name="add_procedure_step", - description="Add a step to an existing procedure", + description=( + "Add a step (OP) to a draft procedure. Without parent_step_id, " + "creates a top-level OP numbered 1, 2, 3... (or C1, C2, C3... if " + "is_contingency=true). With parent_step_id, creates a sub-step " + "numbered . that inherits is_contingency from its " + "parent. step_number, level, and order are calculated server-side " + "and cannot be overridden." + ), inputSchema={ "type": "object", "properties": { @@ -215,14 +246,409 @@ async def list_tools() -> list[Tool]: "type": "string", "description": "Step instructions (markdown supported)", }, - "step_number": { - "type": "string", - "description": "Step number (e.g., '1', '2.1', 'C1' for contingency)", + "parent_step_id": { + "type": "integer", + "description": ( + "Parent OP ID. Omit for a top-level OP; set to create " + "a sub-step (level=1) under the given OP." + ), + }, + "is_contingency": { + "type": "boolean", + "description": ( + "Top-level only: mark as a contingency OP (numbered " + "C1, C2...) that runs only when an NC is logged. " + "Ignored for sub-steps — they inherit from their parent." + ), + "default": False, + }, + "requires_signoff": { + "type": "boolean", + "description": "Step requires sign-off to complete (default false)", + "default": False, + }, + "estimated_duration_minutes": { + "type": "integer", + "minimum": 1, + "description": "Estimated duration in minutes (optional)", + }, + "required_data_schema": { + "type": "object", + "description": ( + "JSON schema describing data to capture at this step " + "(measurements, readings, etc). Optional." + ), }, }, "required": ["procedure_id", "title"], }, ), + Tool( + name="get_procedure", + description=( + "Get a procedure with its full hierarchical step tree, kit, " + "outputs, dependencies, and current version pointer." + ), + inputSchema={ + "type": "object", + "properties": { + "procedure_id": {"type": "integer", "description": "The procedure ID"}, + }, + "required": ["procedure_id"], + }, + ), + Tool( + name="update_procedure", + description=( + "Update a procedure's metadata: name, description, status " + "(draft/active/deprecated), or procedure_type (op/build). " + "Note: published versions are immutable; this only edits the " + "master template." + ), + inputSchema={ + "type": "object", + "properties": { + "procedure_id": {"type": "integer"}, + "name": {"type": "string"}, + "description": {"type": "string"}, + "status": {"type": "string", "enum": ["draft", "active", "deprecated"]}, + "procedure_type": {"type": "string", "enum": ["op", "build"]}, + }, + "required": ["procedure_id"], + }, + ), + Tool( + name="delete_procedure", + description="Soft-delete a procedure (sets deleted_at). Audit-logged.", + inputSchema={ + "type": "object", + "properties": { + "procedure_id": {"type": "integer"}, + }, + "required": ["procedure_id"], + }, + ), + Tool( + name="update_step", + description=( + "Update a step's title, instructions, is_contingency, " + "requires_signoff, estimated_duration_minutes, or " + "required_data_schema. Step_number, level, parent, and order " + "are not editable here — use reorder_steps for ordering." + ), + inputSchema={ + "type": "object", + "properties": { + "procedure_id": {"type": "integer"}, + "step_id": {"type": "integer"}, + "title": {"type": "string"}, + "instructions": {"type": "string"}, + "is_contingency": {"type": "boolean"}, + "requires_signoff": {"type": "boolean"}, + "estimated_duration_minutes": {"type": "integer", "minimum": 1}, + "required_data_schema": {"type": "object"}, + }, + "required": ["procedure_id", "step_id"], + }, + ), + Tool( + name="delete_step", + description=( + "Hard-delete a step (and its sub-steps via cascade). Renumbers " + "remaining steps so step_numbers stay contiguous (1, 2, 3..., " + "C1, C2..., .N)." + ), + inputSchema={ + "type": "object", + "properties": { + "procedure_id": {"type": "integer"}, + "step_id": {"type": "integer"}, + }, + "required": ["procedure_id", "step_id"], + }, + ), + Tool( + name="reorder_steps", + description=( + "Reorder every step in the procedure. step_ids must be the " + "exact set of all current step IDs (including sub-steps) in " + "the new global order. step_number labels are recomputed to " + "match the new ordering." + ), + inputSchema={ + "type": "object", + "properties": { + "procedure_id": {"type": "integer"}, + "step_ids": { + "type": "array", + "items": {"type": "integer"}, + "description": "All step IDs of the procedure, in the new order.", + }, + }, + "required": ["procedure_id", "step_ids"], + }, + ), + Tool( + name="list_step_dependencies", + description=( + "List all op-level prerequisite edges in a procedure as " + "{step_id, depends_on_step_id} pairs. Dependencies only apply " + "to top-level OPs (sub-steps inherit their parent's gating)." + ), + inputSchema={ + "type": "object", + "properties": { + "procedure_id": {"type": "integer"}, + }, + "required": ["procedure_id"], + }, + ), + Tool( + name="set_step_dependencies", + description=( + "Replace the full prerequisite list for a top-level OP. " + "Validates same-procedure scope, op-level only (no sub-steps " + "on either side), no self-loops, and no cycles. Pass an empty " + "depends_on to clear all prereqs for the step." + ), + inputSchema={ + "type": "object", + "properties": { + "procedure_id": {"type": "integer"}, + "step_id": {"type": "integer", "description": "The dependent (gated) OP"}, + "depends_on": { + "type": "array", + "items": {"type": "integer"}, + "description": "Step IDs that must complete before step_id can start.", + }, + }, + "required": ["procedure_id", "step_id", "depends_on"], + }, + ), + Tool( + name="get_kit", + description=( + "Get the procedure-level Kit (Bill of Materials of parts that " + "will be consumed by an execution of this procedure)." + ), + inputSchema={ + "type": "object", + "properties": {"procedure_id": {"type": "integer"}}, + "required": ["procedure_id"], + }, + ), + Tool( + name="add_kit_item", + description=( + "Add a part to the procedure-level Kit. Fails if the part is " + "already on the kit (use update_kit_item to change quantity)." + ), + inputSchema={ + "type": "object", + "properties": { + "procedure_id": {"type": "integer"}, + "part_id": {"type": "integer"}, + "quantity_required": {"type": "number", "exclusiveMinimum": 0}, + }, + "required": ["procedure_id", "part_id", "quantity_required"], + }, + ), + Tool( + name="update_kit_item", + description="Update a Kit item's required quantity.", + inputSchema={ + "type": "object", + "properties": { + "procedure_id": {"type": "integer"}, + "kit_id": {"type": "integer"}, + "quantity_required": {"type": "number", "exclusiveMinimum": 0}, + }, + "required": ["procedure_id", "kit_id", "quantity_required"], + }, + ), + Tool( + name="remove_kit_item", + description="Remove a part from the procedure-level Kit (by part_id).", + inputSchema={ + "type": "object", + "properties": { + "procedure_id": {"type": "integer"}, + "part_id": {"type": "integer"}, + }, + "required": ["procedure_id", "part_id"], + }, + ), + Tool( + name="get_outputs", + description=( + "Get the output parts a build-type procedure produces. " + "Returns [] for op-type procedures." + ), + inputSchema={ + "type": "object", + "properties": {"procedure_id": {"type": "integer"}}, + "required": ["procedure_id"], + }, + ), + Tool( + name="add_output", + description=( + "Add an output part (assembly produced) to a build-type " + "procedure. Note: the canonical add-output handler also " + "auto-populates the Kit from the part's BOMLine children; " + "this tool does the same to stay consistent." + ), + inputSchema={ + "type": "object", + "properties": { + "procedure_id": {"type": "integer"}, + "part_id": {"type": "integer"}, + "quantity_produced": { + "type": "number", + "exclusiveMinimum": 0, + "default": 1, + }, + }, + "required": ["procedure_id", "part_id"], + }, + ), + Tool( + name="update_output", + description="Update the quantity_produced for an output part (by part_id).", + inputSchema={ + "type": "object", + "properties": { + "procedure_id": {"type": "integer"}, + "part_id": {"type": "integer"}, + "quantity_produced": {"type": "number", "exclusiveMinimum": 0}, + }, + "required": ["procedure_id", "part_id", "quantity_produced"], + }, + ), + Tool( + name="remove_output", + description="Remove an output part from a procedure (by part_id).", + inputSchema={ + "type": "object", + "properties": { + "procedure_id": {"type": "integer"}, + "part_id": {"type": "integer"}, + }, + "required": ["procedure_id", "part_id"], + }, + ), + Tool( + name="get_step_kit", + description=( + "Get the parts required at a specific step (StepKit). Each " + "item has a usage_type: 'consume' (inventory decremented at " + "step completion) or 'tooling' (GSE/fixtures, returned)." + ), + inputSchema={ + "type": "object", + "properties": { + "procedure_id": {"type": "integer"}, + "step_id": {"type": "integer"}, + }, + "required": ["procedure_id", "step_id"], + }, + ), + Tool( + name="add_step_kit_item", + description="Add a part to a step's kit.", + inputSchema={ + "type": "object", + "properties": { + "procedure_id": {"type": "integer"}, + "step_id": {"type": "integer"}, + "part_id": {"type": "integer"}, + "quantity_required": {"type": "number", "exclusiveMinimum": 0}, + "usage_type": { + "type": "string", + "enum": ["consume", "tooling"], + "default": "consume", + }, + "notes": {"type": "string"}, + }, + "required": ["procedure_id", "step_id", "part_id", "quantity_required"], + }, + ), + Tool( + name="update_step_kit_item", + description="Update a step kit item's quantity, usage_type, or notes.", + inputSchema={ + "type": "object", + "properties": { + "procedure_id": {"type": "integer"}, + "step_id": {"type": "integer"}, + "step_kit_id": {"type": "integer"}, + "quantity_required": {"type": "number", "exclusiveMinimum": 0}, + "usage_type": {"type": "string", "enum": ["consume", "tooling"]}, + "notes": {"type": "string"}, + }, + "required": ["procedure_id", "step_id", "step_kit_id"], + }, + ), + Tool( + name="remove_step_kit_item", + description="Remove a part from a step's kit (by part_id).", + inputSchema={ + "type": "object", + "properties": { + "procedure_id": {"type": "integer"}, + "step_id": {"type": "integer"}, + "part_id": {"type": "integer"}, + }, + "required": ["procedure_id", "step_id", "part_id"], + }, + ), + Tool( + name="publish_version", + description=( + "Publish the current draft as a new immutable ProcedureVersion " + "snapshot. Bumps current_version_id and flips status to " + "'active'. Fails if the procedure has no steps. Snapshot " + "includes steps (with hierarchy), step kits, dependencies " + "(emitted as prereq `order` values for execution lookup), " + "procedure kit, and outputs." + ), + inputSchema={ + "type": "object", + "properties": {"procedure_id": {"type": "integer"}}, + "required": ["procedure_id"], + }, + ), + Tool( + name="list_versions", + description="List all published versions of a procedure (newest first).", + inputSchema={ + "type": "object", + "properties": {"procedure_id": {"type": "integer"}}, + "required": ["procedure_id"], + }, + ), + Tool( + name="clone_procedure", + description=( + "Clone a procedure into a new draft. Copies all steps " + "(hierarchy preserved). Step kits, procedure kit, and outputs " + "are copied conditionally. The clone starts at version 0 in " + "draft status." + ), + inputSchema={ + "type": "object", + "properties": { + "procedure_id": {"type": "integer"}, + "new_name": { + "type": "string", + "description": "Optional new name (default: 'Copy of ')", + }, + "copy_kit": {"type": "boolean", "default": True}, + "copy_outputs": {"type": "boolean", "default": True}, + }, + "required": ["procedure_id"], + }, + ), # Issues Tool( name="list_issues", @@ -495,10 +921,56 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: # Procedures elif name == "list_procedures": return await _list_procedures(db, arguments) + elif name == "get_procedure": + return await _get_procedure(db, arguments) elif name == "create_procedure": return await _create_procedure(db, arguments) + elif name == "update_procedure": + return await _update_procedure(db, arguments) + elif name == "delete_procedure": + return await _delete_procedure(db, arguments) elif name == "add_procedure_step": return await _add_procedure_step(db, arguments) + elif name == "update_step": + return await _update_step(db, arguments) + elif name == "delete_step": + return await _delete_step(db, arguments) + elif name == "reorder_steps": + return await _reorder_steps(db, arguments) + elif name == "list_step_dependencies": + return await _list_step_dependencies(db, arguments) + elif name == "set_step_dependencies": + return await _set_step_dependencies(db, arguments) + elif name == "get_kit": + return await _get_kit(db, arguments) + elif name == "add_kit_item": + return await _add_kit_item(db, arguments) + elif name == "update_kit_item": + return await _update_kit_item(db, arguments) + elif name == "remove_kit_item": + return await _remove_kit_item(db, arguments) + elif name == "get_outputs": + return await _get_outputs(db, arguments) + elif name == "add_output": + return await _add_output(db, arguments) + elif name == "update_output": + return await _update_output(db, arguments) + elif name == "remove_output": + return await _remove_output(db, arguments) + elif name == "get_step_kit": + return await _get_step_kit(db, arguments) + elif name == "add_step_kit_item": + return await _add_step_kit_item(db, arguments) + elif name == "update_step_kit_item": + return await _update_step_kit_item(db, arguments) + elif name == "remove_step_kit_item": + return await _remove_step_kit_item(db, arguments) + elif name == "publish_version": + return await _publish_version(db, arguments) + elif name == "list_versions": + return await _list_versions(db, arguments) + elif name == "clone_procedure": + return await _clone_procedure(db, arguments) # Issues elif name == "list_issues": @@ -692,6 +1164,8 @@ async def _create_part(db, args: dict) -> list[TextContent]: parent_id=parent_id, ) db.add(part) + db.flush() + log_create(db, part) db.commit() db.refresh(part) @@ -845,13 +1319,25 @@ async def _list_procedures(db, args: dict) -> list[TextContent]: async def _create_procedure(db, args: dict) -> list[TextContent]: - """Create a new procedure.""" + """Create a new master procedure template (draft status).""" + raw_type = args.get("procedure_type", "op") + try: + procedure_type = ProcedureType(raw_type) + except ValueError: + return json_response( + {"error": f"Invalid procedure_type {raw_type!r}; expected 'op' or 'build'"} + ) + procedure = MasterProcedure( name=args["name"], description=args.get("description"), - status=ProcedureStatus.DRAFT, + procedure_type=procedure_type.value, + status=ProcedureStatus.DRAFT.value, ) db.add(procedure) + db.flush() + + log_create(db, procedure) db.commit() db.refresh(procedure) @@ -862,14 +1348,43 @@ async def _create_procedure(db, args: dict) -> list[TextContent]: "procedure": { "id": procedure.id, "name": procedure.name, - "status": "draft", + "description": procedure.description, + "procedure_type": procedure_type.value, + "status": ProcedureStatus.DRAFT.value, }, } ) +def _calculate_step_number(db, procedure_id: int, parent_step_id: int | None, is_contingency: bool): + """Calculate the next step_number — mirrors the canonical API helper. + + Top-level normal ops: 1, 2, 3... + Top-level contingency ops: C1, C2, C3... + Sub-steps: . + """ + if parent_step_id: + parent = db.query(ProcedureStep).filter(ProcedureStep.id == parent_step_id).first() + if not parent: + return None + sub_count = ( + db.query(func.count(ProcedureStep.id)) + .filter(ProcedureStep.parent_step_id == parent_step_id) + .scalar() + ) + return f"{parent.step_number}.{sub_count + 1}" + + sibling_query = db.query(func.count(ProcedureStep.id)).filter( + ProcedureStep.procedure_id == procedure_id, + ProcedureStep.parent_step_id.is_(None), + ProcedureStep.is_contingency.is_(is_contingency), + ) + sibling_count = sibling_query.scalar() + return f"C{sibling_count + 1}" if is_contingency else str(sibling_count + 1) + + async def _add_procedure_step(db, args: dict) -> list[TextContent]: - """Add a step to a procedure.""" + """Add an OP (top-level) or sub-step to a procedure.""" procedure = ( db.query(MasterProcedure) .filter( @@ -882,29 +1397,78 @@ async def _add_procedure_step(db, args: dict) -> list[TextContent]: if not procedure: return json_response({"error": f"Procedure {args['procedure_id']} not found"}) - # Determine step order - existing_steps = len(procedure.steps) - step_number = args.get("step_number", str(existing_steps + 1)) + parent_step_id = args.get("parent_step_id") + if parent_step_id: + parent = ( + db.query(ProcedureStep) + .filter( + ProcedureStep.id == parent_step_id, + ProcedureStep.procedure_id == procedure.id, + ) + .first() + ) + if not parent: + return json_response( + {"error": f"Parent step {parent_step_id} not found in procedure {procedure.id}"} + ) + # Sub-steps inherit contingency status from parent and are always level=1. + is_contingency = parent.is_contingency + level = 1 + else: + is_contingency = bool(args.get("is_contingency", False)) + level = 0 + + step_number = _calculate_step_number(db, procedure.id, parent_step_id, is_contingency) + if step_number is None: + return json_response({"error": f"Parent step {parent_step_id} not found"}) + + max_order = ( + db.query(func.max(ProcedureStep.order)) + .filter(ProcedureStep.procedure_id == procedure.id) + .scalar() + ) + next_order = (max_order or 0) + 1 step = ProcedureStep( procedure_id=procedure.id, + parent_step_id=parent_step_id, + order=next_order, + step_number=step_number, + level=level, title=args["title"], instructions=args.get("instructions"), - step_number=step_number, - order=existing_steps + 1, + required_data_schema=args.get("required_data_schema"), + is_contingency=is_contingency, + requires_signoff=bool(args.get("requires_signoff", False)), + estimated_duration_minutes=args.get("estimated_duration_minutes"), ) db.add(step) + db.flush() + + log_create(db, step) db.commit() db.refresh(step) return json_response( { "success": True, - "message": f"Added step '{step.title}' to procedure '{procedure.name}'", + "message": ( + f"Added {'sub-step' if level else 'OP'} {step.step_number} " + f"'{step.title}' to procedure '{procedure.name}'" + ), "step": { "id": step.id, + "procedure_id": step.procedure_id, + "parent_step_id": step.parent_step_id, "step_number": step.step_number, + "level": step.level, + "order": step.order, "title": step.title, + "instructions": step.instructions, + "is_contingency": step.is_contingency, + "requires_signoff": step.requires_signoff, + "estimated_duration_minutes": step.estimated_duration_minutes, + "required_data_schema": step.required_data_schema, }, } ) @@ -954,6 +1518,8 @@ async def _create_issue(db, args: dict) -> list[TextContent]: priority=IssuePriority(priority), ) db.add(issue) + db.flush() + log_create(db, issue) db.commit() db.refresh(issue) @@ -1005,6 +1571,7 @@ async def _list_risks(db, args: dict) -> list[TextContent]: async def _create_risk(db, args: dict) -> list[TextContent]: """Create a new risk.""" risk = Risk( + risk_number=generate_risk_number(db), title=args["title"], description=args.get("description"), probability=args["probability"], @@ -1013,15 +1580,18 @@ async def _create_risk(db, args: dict) -> list[TextContent]: status=RiskStatus.IDENTIFIED, ) db.add(risk) + db.flush() + log_create(db, risk) db.commit() db.refresh(risk) return json_response( { "success": True, - "message": f"Created risk '{risk.title}' with ID {risk.id}", + "message": f"Created risk '{risk.title}' ({risk.risk_number}) with ID {risk.id}", "risk": { "id": risk.id, + "risk_number": risk.risk_number, "title": risk.title, "probability": risk.probability, "impact": risk.impact, @@ -1238,6 +1808,8 @@ async def _assign_requirement(db, args: dict) -> list[TextContent]: notes=args.get("notes"), ) db.add(pr) + db.flush() + log_create(db, pr) db.commit() db.refresh(pr) @@ -1262,11 +1834,13 @@ async def _verify_requirement(db, args: dict) -> list[TextContent]: if not pr: return json_response({"error": f"Part requirement {args['part_requirement_id']} not found"}) + old_values = get_model_dict(pr) pr.status = "verified" pr.verified_at = datetime.now(UTC) if args.get("notes"): pr.notes = args["notes"] + log_update(db, pr, old_values) db.commit() db.refresh(pr) @@ -1376,6 +1950,8 @@ async def _add_component(db, args: dict) -> list[TextContent]: reference_designator=args.get("reference_designator"), ) db.add(line) + db.flush() + log_create(db, line) db.commit() db.refresh(line) @@ -1403,6 +1979,7 @@ async def _remove_component(db, args: dict) -> list[TextContent]: component_name = line.component.name assembly_name = line.assembly.name + log_delete(db, line) db.delete(line) db.commit() @@ -1414,6 +1991,1148 @@ async def _remove_component(db, args: dict) -> list[TextContent]: ) +# ============ PROCEDURE: READ / UPDATE / DELETE / RENUMBER ============ + + +def _serialize_step(step: ProcedureStep) -> dict: + return { + "id": step.id, + "procedure_id": step.procedure_id, + "parent_step_id": step.parent_step_id, + "order": step.order, + "step_number": step.step_number, + "level": step.level, + "title": step.title, + "instructions": step.instructions, + "required_data_schema": step.required_data_schema, + "is_contingency": step.is_contingency, + "requires_signoff": step.requires_signoff, + "estimated_duration_minutes": step.estimated_duration_minutes, + "workcenter_id": step.workcenter_id, + } + + +def _build_step_tree(steps: list[ProcedureStep]) -> list[dict]: + """Build the hierarchical step tree, mirroring the canonical helper.""" + children_map: dict[int | None, list[ProcedureStep]] = {} + for s in steps: + children_map.setdefault(s.parent_step_id, []).append(s) + for kids in children_map.values(): + kids.sort(key=lambda s: s.order) + + def build(node: ProcedureStep) -> dict: + d = _serialize_step(node) + d["sub_steps"] = [build(c) for c in children_map.get(node.id, [])] + return d + + return [build(s) for s in children_map.get(None, [])] + + +def _renumber_procedure_steps(steps: list[ProcedureStep]) -> None: + """Recompute step_numbers from current `order` values. + + Mirrors src/opal/api/routes/procedures.py:_renumber_procedure_steps. + Top-level normal -> 1, 2, 3...; top-level contingency -> C1, C2...; + sub-steps -> .. + """ + top_level = sorted([s for s in steps if s.parent_step_id is None], key=lambda s: s.order) + normal_idx = 0 + contingency_idx = 0 + for op in top_level: + if op.is_contingency: + contingency_idx += 1 + op.step_number = f"C{contingency_idx}" + else: + normal_idx += 1 + op.step_number = str(normal_idx) + children: dict[int, list[ProcedureStep]] = {} + for s in steps: + if s.parent_step_id is not None: + children.setdefault(s.parent_step_id, []).append(s) + for parent_id, kids in children.items(): + parent = next((s for s in steps if s.id == parent_id), None) + if parent is None: + continue + kids.sort(key=lambda s: s.order) + for i, kid in enumerate(kids, start=1): + kid.step_number = f"{parent.step_number}.{i}" + + +def _load_procedure(db, procedure_id: int) -> MasterProcedure | None: + return ( + db.query(MasterProcedure) + .filter(MasterProcedure.id == procedure_id, MasterProcedure.deleted_at.is_(None)) + .first() + ) + + +async def _get_procedure(db, args: dict) -> list[TextContent]: + """Return a procedure with its full hierarchical step tree, kit, outputs, and deps.""" + procedure = _load_procedure(db, args["procedure_id"]) + if not procedure: + return json_response({"error": f"Procedure {args['procedure_id']} not found"}) + + steps = ( + db.query(ProcedureStep) + .filter(ProcedureStep.procedure_id == procedure.id) + .order_by(ProcedureStep.order) + .all() + ) + deps = ( + db.query(StepDependency) + .join(ProcedureStep, StepDependency.step_id == ProcedureStep.id) + .filter(ProcedureStep.procedure_id == procedure.id) + .all() + ) + kit_items = db.query(Kit).filter(Kit.procedure_id == procedure.id).all() + outputs = db.query(ProcedureOutput).filter(ProcedureOutput.procedure_id == procedure.id).all() + + return json_response( + { + "id": procedure.id, + "name": procedure.name, + "description": procedure.description, + "procedure_type": procedure.procedure_type.value + if hasattr(procedure.procedure_type, "value") + else procedure.procedure_type, + "status": procedure.status.value + if hasattr(procedure.status, "value") + else procedure.status, + "current_version_id": procedure.current_version_id, + "step_count": len(steps), + "steps": _build_step_tree(steps), + "dependencies": [ + {"step_id": d.step_id, "depends_on_step_id": d.depends_on_step_id} for d in deps + ], + "kit": [ + { + "id": k.id, + "part_id": k.part_id, + "part_name": k.part.name, + "quantity_required": float(k.quantity_required), + } + for k in kit_items + ], + "outputs": [ + { + "id": o.id, + "part_id": o.part_id, + "part_name": o.part.name, + "quantity_produced": float(o.quantity_produced), + } + for o in outputs + ], + } + ) + + +async def _update_procedure(db, args: dict) -> list[TextContent]: + procedure = _load_procedure(db, args["procedure_id"]) + if not procedure: + return json_response({"error": f"Procedure {args['procedure_id']} not found"}) + + old_values = get_model_dict(procedure) + if "name" in args: + procedure.name = args["name"] + if "description" in args: + procedure.description = args["description"] + if "status" in args: + try: + procedure.status = ProcedureStatus(args["status"]).value + except ValueError: + return json_response({"error": f"Invalid status {args['status']!r}"}) + if "procedure_type" in args: + try: + procedure.procedure_type = ProcedureType(args["procedure_type"]).value + except ValueError: + return json_response({"error": f"Invalid procedure_type {args['procedure_type']!r}"}) + + log_update(db, procedure, old_values) + db.commit() + db.refresh(procedure) + + return json_response( + { + "success": True, + "message": f"Updated procedure {procedure.id}", + "procedure": { + "id": procedure.id, + "name": procedure.name, + "description": procedure.description, + "procedure_type": procedure.procedure_type.value + if hasattr(procedure.procedure_type, "value") + else procedure.procedure_type, + "status": procedure.status.value + if hasattr(procedure.status, "value") + else procedure.status, + }, + } + ) + + +async def _delete_procedure(db, args: dict) -> list[TextContent]: + procedure = _load_procedure(db, args["procedure_id"]) + if not procedure: + return json_response({"error": f"Procedure {args['procedure_id']} not found"}) + + procedure.deleted_at = datetime.now(UTC) + log_delete(db, procedure) + db.commit() + return json_response({"success": True, "message": f"Soft-deleted procedure {procedure.id}"}) + + +async def _update_step(db, args: dict) -> list[TextContent]: + step = ( + db.query(ProcedureStep) + .filter( + ProcedureStep.id == args["step_id"], + ProcedureStep.procedure_id == args["procedure_id"], + ) + .first() + ) + if not step: + return json_response( + {"error": (f"Step {args['step_id']} not found in procedure {args['procedure_id']}")} + ) + + old_values = get_model_dict(step) + if "title" in args: + step.title = args["title"] + if "instructions" in args: + step.instructions = args["instructions"] + if "is_contingency" in args: + step.is_contingency = bool(args["is_contingency"]) + if "requires_signoff" in args: + step.requires_signoff = bool(args["requires_signoff"]) + if "estimated_duration_minutes" in args: + step.estimated_duration_minutes = args["estimated_duration_minutes"] + if "required_data_schema" in args: + step.required_data_schema = args["required_data_schema"] + + log_update(db, step, old_values) + db.commit() + db.refresh(step) + + return json_response( + {"success": True, "message": f"Updated step {step.id}", "step": _serialize_step(step)} + ) + + +async def _delete_step(db, args: dict) -> list[TextContent]: + step = ( + db.query(ProcedureStep) + .filter( + ProcedureStep.id == args["step_id"], + ProcedureStep.procedure_id == args["procedure_id"], + ) + .first() + ) + if not step: + return json_response({"error": f"Step {args['step_id']} not found"}) + + deleted_order = step.order + log_delete(db, step) + db.delete(step) + db.flush() + + # Pull remaining steps after the deleted-cascade flush and renumber. + remaining = ( + db.query(ProcedureStep).filter(ProcedureStep.procedure_id == args["procedure_id"]).all() + ) + for s in remaining: + if s.order > deleted_order: + s.order -= 1 + _renumber_procedure_steps(remaining) + db.commit() + + return json_response( + { + "success": True, + "message": f"Deleted step {args['step_id']} and renumbered remaining steps", + "remaining_step_count": len(remaining), + } + ) + + +async def _reorder_steps(db, args: dict) -> list[TextContent]: + procedure = _load_procedure(db, args["procedure_id"]) + if not procedure: + return json_response({"error": f"Procedure {args['procedure_id']} not found"}) + + steps = db.query(ProcedureStep).filter(ProcedureStep.procedure_id == procedure.id).all() + step_map = {s.id: s for s in steps} + requested = list(args["step_ids"]) + if set(requested) != set(step_map.keys()): + return json_response( + { + "error": ( + "step_ids must be the exact set of all step IDs in the procedure " + f"(got {len(requested)} ids, procedure has {len(step_map)} steps)" + ) + } + ) + + for i, sid in enumerate(requested, start=1): + step_map[sid].order = i + _renumber_procedure_steps(steps) + db.commit() + + ordered = ( + db.query(ProcedureStep) + .filter(ProcedureStep.procedure_id == procedure.id) + .order_by(ProcedureStep.order) + .all() + ) + return json_response( + { + "success": True, + "message": f"Reordered {len(ordered)} steps", + "steps": [_serialize_step(s) for s in ordered], + } + ) + + +# ============ STEP DEPENDENCIES ============ + + +async def _list_step_dependencies(db, args: dict) -> list[TextContent]: + procedure = _load_procedure(db, args["procedure_id"]) + if not procedure: + return json_response({"error": f"Procedure {args['procedure_id']} not found"}) + + rows = ( + db.query(StepDependency) + .join(ProcedureStep, StepDependency.step_id == ProcedureStep.id) + .filter(ProcedureStep.procedure_id == procedure.id) + .all() + ) + return json_response( + { + "procedure_id": procedure.id, + "count": len(rows), + "dependencies": [ + {"step_id": d.step_id, "depends_on_step_id": d.depends_on_step_id} for d in rows + ], + } + ) + + +async def _set_step_dependencies(db, args: dict) -> list[TextContent]: + """Replace the prerequisite list for a top-level OP. Mirrors the canonical + set_step_dependencies handler — same-procedure scope, op-level only, no + self-loops, no cycles.""" + procedure = _load_procedure(db, args["procedure_id"]) + if not procedure: + return json_response({"error": f"Procedure {args['procedure_id']} not found"}) + + target_id: int = args["step_id"] + target = ( + db.query(ProcedureStep) + .filter(ProcedureStep.id == target_id, ProcedureStep.procedure_id == procedure.id) + .first() + ) + if not target: + return json_response({"error": f"Step {target_id} not found in procedure {procedure.id}"}) + if target.parent_step_id is not None: + return json_response({"error": "Dependencies are only allowed on top-level operations"}) + + requested = list(dict.fromkeys(args["depends_on"])) + if target_id in requested: + return json_response({"error": "A step cannot depend on itself"}) + + if requested: + prereqs = db.query(ProcedureStep).filter(ProcedureStep.id.in_(requested)).all() + prereq_map = {p.id: p for p in prereqs} + for pid in requested: + p = prereq_map.get(pid) + if p is None or p.procedure_id != procedure.id: + return json_response({"error": f"Step {pid} is not in this procedure"}) + if p.parent_step_id is not None: + return json_response( + {"error": f"Step {pid} is a sub-step; only OPs can be prerequisites"} + ) + + existing = db.query(StepDependency).all() + adj: dict[int, set[int]] = {} + for d in existing: + if d.step_id == target_id: + continue + adj.setdefault(d.depends_on_step_id, set()).add(d.step_id) + for pid in requested: + adj.setdefault(pid, set()).add(target_id) + + def reachable_from(start: int) -> set[int]: + seen = {start} + stack = [start] + while stack: + cur = stack.pop() + for nxt in adj.get(cur, ()): + if nxt not in seen: + seen.add(nxt) + stack.append(nxt) + return seen + + downstream = reachable_from(target_id) + cycle = downstream & set(requested) + if cycle: + return json_response( + { + "error": ( + f"Cycle: step {target_id} already gates {sorted(cycle)}; cannot depend on them." + ) + } + ) + + current = db.query(StepDependency).filter(StepDependency.step_id == target_id).all() + for d in current: + log_delete(db, d) + db.delete(d) + db.flush() + for pid in requested: + new_dep = StepDependency(step_id=target_id, depends_on_step_id=pid) + db.add(new_dep) + db.flush() + log_create(db, new_dep) + db.commit() + return json_response( + { + "success": True, + "message": ( + f"Set {len(requested)} prerequisite(s) for step {target_id}" + if requested + else f"Cleared prerequisites for step {target_id}" + ), + "step_id": target_id, + "depends_on": requested, + } + ) + + +# ============ KIT (procedure-level BOM) ============ + + +async def _get_kit(db, args: dict) -> list[TextContent]: + procedure = _load_procedure(db, args["procedure_id"]) + if not procedure: + return json_response({"error": f"Procedure {args['procedure_id']} not found"}) + + items = ( + db.query(Kit).join(Part).filter(Kit.procedure_id == procedure.id).order_by(Part.name).all() + ) + return json_response( + { + "procedure_id": procedure.id, + "count": len(items), + "kit": [ + { + "id": k.id, + "part_id": k.part_id, + "part_name": k.part.name, + "part_external_pn": k.part.external_pn, + "quantity_required": float(k.quantity_required), + } + for k in items + ], + } + ) + + +async def _add_kit_item(db, args: dict) -> list[TextContent]: + procedure = _load_procedure(db, args["procedure_id"]) + if not procedure: + return json_response({"error": f"Procedure {args['procedure_id']} not found"}) + + part = db.query(Part).filter(Part.id == args["part_id"], Part.deleted_at.is_(None)).first() + if not part: + return json_response({"error": f"Part {args['part_id']} not found"}) + + existing = ( + db.query(Kit) + .filter(Kit.procedure_id == procedure.id, Kit.part_id == args["part_id"]) + .first() + ) + if existing: + return json_response( + {"error": f"Part {args['part_id']} already in kit (use update_kit_item)"} + ) + + item = Kit( + procedure_id=procedure.id, + part_id=args["part_id"], + quantity_required=Decimal(str(args["quantity_required"])), + ) + db.add(item) + db.flush() + log_create(db, item) + db.commit() + db.refresh(item) + return json_response( + { + "success": True, + "message": f"Added '{part.name}' to procedure {procedure.id} kit", + "kit_item": { + "id": item.id, + "part_id": item.part_id, + "quantity_required": float(item.quantity_required), + }, + } + ) + + +async def _update_kit_item(db, args: dict) -> list[TextContent]: + item = ( + db.query(Kit) + .filter(Kit.id == args["kit_id"], Kit.procedure_id == args["procedure_id"]) + .first() + ) + if not item: + return json_response({"error": f"Kit item {args['kit_id']} not found"}) + + old_values = get_model_dict(item) + item.quantity_required = Decimal(str(args["quantity_required"])) + log_update(db, item, old_values) + db.commit() + db.refresh(item) + return json_response( + { + "success": True, + "message": f"Updated kit item {item.id} quantity to {item.quantity_required}", + "kit_item": { + "id": item.id, + "part_id": item.part_id, + "quantity_required": float(item.quantity_required), + }, + } + ) + + +async def _remove_kit_item(db, args: dict) -> list[TextContent]: + item = ( + db.query(Kit) + .filter(Kit.procedure_id == args["procedure_id"], Kit.part_id == args["part_id"]) + .first() + ) + if not item: + return json_response( + { + "error": ( + f"Kit item for part {args['part_id']} not found in " + f"procedure {args['procedure_id']}" + ) + } + ) + + log_delete(db, item) + db.delete(item) + db.commit() + return json_response({"success": True, "message": f"Removed part {args['part_id']} from kit"}) + + +# ============ PROCEDURE OUTPUTS (build-type) ============ + + +async def _get_outputs(db, args: dict) -> list[TextContent]: + procedure = _load_procedure(db, args["procedure_id"]) + if not procedure: + return json_response({"error": f"Procedure {args['procedure_id']} not found"}) + + outputs = db.query(ProcedureOutput).filter(ProcedureOutput.procedure_id == procedure.id).all() + return json_response( + { + "procedure_id": procedure.id, + "procedure_type": procedure.procedure_type.value + if hasattr(procedure.procedure_type, "value") + else procedure.procedure_type, + "count": len(outputs), + "outputs": [ + { + "id": o.id, + "part_id": o.part_id, + "part_name": o.part.name, + "part_external_pn": o.part.external_pn, + "quantity_produced": float(o.quantity_produced), + } + for o in outputs + ], + } + ) + + +async def _add_output(db, args: dict) -> list[TextContent]: + procedure = _load_procedure(db, args["procedure_id"]) + if not procedure: + return json_response({"error": f"Procedure {args['procedure_id']} not found"}) + + part = db.query(Part).filter(Part.id == args["part_id"], Part.deleted_at.is_(None)).first() + if not part: + return json_response({"error": f"Part {args['part_id']} not found"}) + + existing = ( + db.query(ProcedureOutput) + .filter( + ProcedureOutput.procedure_id == procedure.id, + ProcedureOutput.part_id == args["part_id"], + ) + .first() + ) + if existing: + return json_response( + {"error": f"Part {args['part_id']} already in outputs (use update_output)"} + ) + + qty = Decimal(str(args.get("quantity_produced", 1))) + output = ProcedureOutput( + procedure_id=procedure.id, part_id=args["part_id"], quantity_produced=qty + ) + db.add(output) + db.flush() + log_create(db, output) + + # Mirror canonical add_output: auto-populate the procedure kit from the + # output part's direct BOM children (single-level). + bom_lines = db.query(BOMLine).filter(BOMLine.assembly_id == args["part_id"]).all() + auto_added = 0 + auto_bumped = 0 + for bom_line in bom_lines: + kit_qty = Decimal(str(bom_line.quantity)) * qty + existing_kit = ( + db.query(Kit) + .filter(Kit.procedure_id == procedure.id, Kit.part_id == bom_line.component_id) + .first() + ) + if existing_kit: + old_values = get_model_dict(existing_kit) + existing_kit.quantity_required += kit_qty + log_update(db, existing_kit, old_values) + auto_bumped += 1 + else: + new_kit = Kit( + procedure_id=procedure.id, + part_id=bom_line.component_id, + quantity_required=kit_qty, + ) + db.add(new_kit) + db.flush() + log_create(db, new_kit) + auto_added += 1 + + db.commit() + db.refresh(output) + return json_response( + { + "success": True, + "message": ( + f"Added output '{part.name}' (qty {qty}) to procedure " + f"{procedure.id}; auto-added {auto_added} kit item(s), bumped {auto_bumped}" + ), + "output": { + "id": output.id, + "part_id": output.part_id, + "quantity_produced": float(output.quantity_produced), + }, + "kit_auto_added": auto_added, + "kit_auto_bumped": auto_bumped, + } + ) + + +async def _update_output(db, args: dict) -> list[TextContent]: + output = ( + db.query(ProcedureOutput) + .filter( + ProcedureOutput.procedure_id == args["procedure_id"], + ProcedureOutput.part_id == args["part_id"], + ) + .first() + ) + if not output: + return json_response( + { + "error": ( + f"Output for part {args['part_id']} not found in procedure " + f"{args['procedure_id']}" + ) + } + ) + + old_values = get_model_dict(output) + output.quantity_produced = Decimal(str(args["quantity_produced"])) + log_update(db, output, old_values) + db.commit() + db.refresh(output) + return json_response( + { + "success": True, + "message": f"Updated output quantity to {output.quantity_produced}", + "output": { + "id": output.id, + "part_id": output.part_id, + "quantity_produced": float(output.quantity_produced), + }, + } + ) + + +async def _remove_output(db, args: dict) -> list[TextContent]: + output = ( + db.query(ProcedureOutput) + .filter( + ProcedureOutput.procedure_id == args["procedure_id"], + ProcedureOutput.part_id == args["part_id"], + ) + .first() + ) + if not output: + return json_response( + { + "error": ( + f"Output for part {args['part_id']} not found in procedure " + f"{args['procedure_id']}" + ) + } + ) + + log_delete(db, output) + db.delete(output) + db.commit() + return json_response({"success": True, "message": f"Removed output part {args['part_id']}"}) + + +# ============ STEP KIT (step-level BOM) ============ + + +async def _get_step_kit(db, args: dict) -> list[TextContent]: + step = ( + db.query(ProcedureStep) + .filter( + ProcedureStep.id == args["step_id"], + ProcedureStep.procedure_id == args["procedure_id"], + ) + .first() + ) + if not step: + return json_response({"error": f"Step {args['step_id']} not found"}) + + return json_response( + { + "procedure_id": step.procedure_id, + "step_id": step.id, + "step_number": step.step_number, + "count": len(step.step_kits), + "items": [ + { + "id": sk.id, + "part_id": sk.part_id, + "part_name": sk.part.name, + "part_external_pn": sk.part.external_pn, + "quantity_required": float(sk.quantity_required), + "usage_type": sk.usage_type.value + if hasattr(sk.usage_type, "value") + else sk.usage_type, + "notes": sk.notes, + } + for sk in step.step_kits + ], + } + ) + + +async def _add_step_kit_item(db, args: dict) -> list[TextContent]: + step = ( + db.query(ProcedureStep) + .filter( + ProcedureStep.id == args["step_id"], + ProcedureStep.procedure_id == args["procedure_id"], + ) + .first() + ) + if not step: + return json_response({"error": f"Step {args['step_id']} not found"}) + + part = db.query(Part).filter(Part.id == args["part_id"], Part.deleted_at.is_(None)).first() + if not part: + return json_response({"error": f"Part {args['part_id']} not found"}) + + existing = ( + db.query(StepKit) + .filter(StepKit.step_id == step.id, StepKit.part_id == args["part_id"]) + .first() + ) + if existing: + return json_response( + { + "error": ( + f"Part {args['part_id']} already in step {step.id} kit " + "(use update_step_kit_item)" + ) + } + ) + + try: + usage = UsageType(args.get("usage_type", "consume")) + except ValueError: + return json_response({"error": f"Invalid usage_type {args.get('usage_type')!r}"}) + + sk = StepKit( + step_id=step.id, + part_id=args["part_id"], + quantity_required=Decimal(str(args["quantity_required"])), + usage_type=usage, + notes=args.get("notes"), + ) + db.add(sk) + db.flush() + log_create(db, sk) + db.commit() + db.refresh(sk) + return json_response( + { + "success": True, + "message": ( + f"Added '{part.name}' ({usage.value}, qty " + f"{sk.quantity_required}) to step {step.step_number}" + ), + "step_kit_item": { + "id": sk.id, + "step_id": sk.step_id, + "part_id": sk.part_id, + "quantity_required": float(sk.quantity_required), + "usage_type": usage.value, + "notes": sk.notes, + }, + } + ) + + +async def _update_step_kit_item(db, args: dict) -> list[TextContent]: + sk = ( + db.query(StepKit) + .join(ProcedureStep) + .filter( + StepKit.id == args["step_kit_id"], + StepKit.step_id == args["step_id"], + ProcedureStep.procedure_id == args["procedure_id"], + ) + .first() + ) + if not sk: + return json_response({"error": f"Step kit item {args['step_kit_id']} not found"}) + + old_values = get_model_dict(sk) + if "quantity_required" in args: + sk.quantity_required = Decimal(str(args["quantity_required"])) + if "usage_type" in args: + try: + sk.usage_type = UsageType(args["usage_type"]) + except ValueError: + return json_response({"error": f"Invalid usage_type {args['usage_type']!r}"}) + if "notes" in args: + sk.notes = args["notes"] + + log_update(db, sk, old_values) + db.commit() + db.refresh(sk) + return json_response( + { + "success": True, + "message": f"Updated step kit item {sk.id}", + "step_kit_item": { + "id": sk.id, + "part_id": sk.part_id, + "quantity_required": float(sk.quantity_required), + "usage_type": sk.usage_type.value + if hasattr(sk.usage_type, "value") + else sk.usage_type, + "notes": sk.notes, + }, + } + ) + + +async def _remove_step_kit_item(db, args: dict) -> list[TextContent]: + sk = ( + db.query(StepKit) + .join(ProcedureStep) + .filter( + StepKit.step_id == args["step_id"], + StepKit.part_id == args["part_id"], + ProcedureStep.procedure_id == args["procedure_id"], + ) + .first() + ) + if not sk: + return json_response( + { + "error": ( + f"Step kit item for part {args['part_id']} not found on step {args['step_id']}" + ) + } + ) + + log_delete(db, sk) + db.delete(sk) + db.commit() + return json_response( + {"success": True, "message": f"Removed part {args['part_id']} from step kit"} + ) + + +# ============ PUBLISH + VERSIONS + CLONE ============ + + +async def _publish_version(db, args: dict) -> list[TextContent]: + procedure = _load_procedure(db, args["procedure_id"]) + if not procedure: + return json_response({"error": f"Procedure {args['procedure_id']} not found"}) + + steps = ( + db.query(ProcedureStep) + .filter(ProcedureStep.procedure_id == procedure.id) + .order_by(ProcedureStep.order) + .all() + ) + if not steps: + return json_response({"error": "Cannot publish procedure with no steps"}) + + max_version = ( + db.query(func.max(ProcedureVersion.version_number)) + .filter(ProcedureVersion.procedure_id == procedure.id) + .scalar() + ) + next_version = (max_version or 0) + 1 + + step_ids = [s.id for s in steps] + all_step_kits = db.query(StepKit).filter(StepKit.step_id.in_(step_ids)).all() + step_kit_map: dict[int, list[StepKit]] = {} + for sk in all_step_kits: + step_kit_map.setdefault(sk.step_id, []).append(sk) + + all_deps = db.query(StepDependency).filter(StepDependency.step_id.in_(step_ids)).all() + step_id_to_order = {s.id: s.order for s in steps} + depends_on_map: dict[int, list[int]] = {} + for d in all_deps: + prereq_order = step_id_to_order.get(d.depends_on_step_id) + if prereq_order is not None: + depends_on_map.setdefault(d.step_id, []).append(prereq_order) + + def step_to_dict(step: ProcedureStep) -> dict: + return { + "id": step.id, + "order": step.order, + "step_number": step.step_number, + "level": step.level, + "parent_step_id": step.parent_step_id, + "title": step.title, + "instructions": step.instructions, + "required_data_schema": step.required_data_schema, + "is_contingency": step.is_contingency, + "requires_signoff": step.requires_signoff, + "estimated_duration_minutes": step.estimated_duration_minutes, + "workcenter_id": step.workcenter_id, + "depends_on": sorted(depends_on_map.get(step.id, [])), + "step_kit": [ + { + "part_id": sk.part_id, + "part_name": sk.part.name, + "quantity_required": float(sk.quantity_required), + "usage_type": sk.usage_type.value + if hasattr(sk.usage_type, "value") + else sk.usage_type, + "notes": sk.notes, + } + for sk in step_kit_map.get(step.id, []) + ], + } + + kit_items = db.query(Kit).filter(Kit.procedure_id == procedure.id).all() + output_items = ( + db.query(ProcedureOutput).filter(ProcedureOutput.procedure_id == procedure.id).all() + ) + + content = { + "procedure_name": procedure.name, + "procedure_description": procedure.description, + "steps": [step_to_dict(s) for s in steps], + "kit_items": [ + {"part_id": k.part_id, "quantity_required": float(k.quantity_required)} + for k in kit_items + ], + "output_items": [ + {"part_id": o.part_id, "quantity_produced": float(o.quantity_produced)} + for o in output_items + ], + } + + version = ProcedureVersion( + procedure_id=procedure.id, + version_number=next_version, + content=content, + created_by_id=None, + ) + db.add(version) + db.flush() + + procedure.current_version_id = version.id + procedure.status = ProcedureStatus.ACTIVE.value + + log_create(db, version) + db.commit() + db.refresh(version) + + return json_response( + { + "success": True, + "message": ( + f"Published procedure {procedure.id} as v{next_version} (status -> active)" + ), + "version": { + "id": version.id, + "procedure_id": version.procedure_id, + "version_number": version.version_number, + "step_count": len(steps), + }, + } + ) + + +async def _list_versions(db, args: dict) -> list[TextContent]: + procedure = _load_procedure(db, args["procedure_id"]) + if not procedure: + return json_response({"error": f"Procedure {args['procedure_id']} not found"}) + + versions = ( + db.query(ProcedureVersion) + .filter(ProcedureVersion.procedure_id == procedure.id) + .order_by(ProcedureVersion.version_number.desc()) + .all() + ) + return json_response( + { + "procedure_id": procedure.id, + "current_version_id": procedure.current_version_id, + "count": len(versions), + "versions": [ + { + "id": v.id, + "version_number": v.version_number, + "created_at": v.created_at.isoformat(), + "created_by_id": v.created_by_id, + "is_current": v.id == procedure.current_version_id, + } + for v in versions + ], + } + ) + + +async def _clone_procedure(db, args: dict) -> list[TextContent]: + source = _load_procedure(db, args["procedure_id"]) + if not source: + return json_response({"error": f"Procedure {args['procedure_id']} not found"}) + + clone_name = args.get("new_name") or f"Copy of {source.name}" + new_procedure = MasterProcedure( + name=clone_name, + description=source.description, + procedure_type=source.procedure_type.value + if hasattr(source.procedure_type, "value") + else source.procedure_type, + status=ProcedureStatus.DRAFT.value, + current_version_id=None, + ) + db.add(new_procedure) + db.flush() + + step_id_map: dict[int, int] = {} + source_steps = ( + db.query(ProcedureStep) + .filter(ProcedureStep.procedure_id == source.id) + .order_by(ProcedureStep.order) + .all() + ) + + for s in source_steps: + new_step = ProcedureStep( + procedure_id=new_procedure.id, + order=s.order, + step_number=s.step_number, + level=s.level, + parent_step_id=None, + title=s.title, + instructions=s.instructions, + required_data_schema=s.required_data_schema, + is_contingency=s.is_contingency, + requires_signoff=s.requires_signoff, + estimated_duration_minutes=s.estimated_duration_minutes, + workcenter_id=s.workcenter_id, + ) + db.add(new_step) + db.flush() + step_id_map[s.id] = new_step.id + + for s in source_steps: + if s.parent_step_id and s.parent_step_id in step_id_map: + new_step = db.query(ProcedureStep).filter(ProcedureStep.id == step_id_map[s.id]).first() + if new_step: + new_step.parent_step_id = step_id_map[s.parent_step_id] + + copy_kit = bool(args.get("copy_kit", True)) + copy_outputs = bool(args.get("copy_outputs", True)) + + if copy_kit: + for s in source_steps: + for sk in db.query(StepKit).filter(StepKit.step_id == s.id).all(): + db.add( + StepKit( + step_id=step_id_map[s.id], + part_id=sk.part_id, + quantity_required=sk.quantity_required, + usage_type=sk.usage_type, + notes=sk.notes, + ) + ) + for k in db.query(Kit).filter(Kit.procedure_id == source.id).all(): + db.add( + Kit( + procedure_id=new_procedure.id, + part_id=k.part_id, + quantity_required=k.quantity_required, + ) + ) + + if copy_outputs: + for o in db.query(ProcedureOutput).filter(ProcedureOutput.procedure_id == source.id).all(): + db.add( + ProcedureOutput( + procedure_id=new_procedure.id, + part_id=o.part_id, + quantity_produced=o.quantity_produced, + ) + ) + + log_create(db, new_procedure) + db.commit() + db.refresh(new_procedure) + + return json_response( + { + "success": True, + "message": (f"Cloned procedure {source.id} -> {new_procedure.id} ('{clone_name}')"), + "procedure": { + "id": new_procedure.id, + "name": new_procedure.name, + "procedure_type": new_procedure.procedure_type.value + if hasattr(new_procedure.procedure_type, "value") + else new_procedure.procedure_type, + "status": "draft", + "step_count": len(source_steps), + "copied_kit": copy_kit, + "copied_outputs": copy_outputs, + }, + } + ) + + # ============ SERVER ENTRY POINT ============ diff --git a/src/opal/seed.py b/src/opal/seed.py index aa8b5a4..b813ac5 100644 --- a/src/opal/seed.py +++ b/src/opal/seed.py @@ -3,6 +3,7 @@ from datetime import UTC, datetime, timedelta from decimal import Decimal from pathlib import Path +from typing import Any from sqlalchemy.orm import Session @@ -30,6 +31,7 @@ StepExecution, Supplier, TestTemplate, + User, Workcenter, ) from opal.db.models.execution import InstanceStatus, StepStatus @@ -43,6 +45,7 @@ def seed_database(db: Session) -> None: """Populate database with Project Kestrel seed data.""" _write_project_yaml() + _seed_users(db) workcenters = _seed_workcenters(db) suppliers = _seed_suppliers(db) parts = _seed_parts(db) @@ -139,6 +142,13 @@ def seed_database(db: Session) -> None: """ +def _seed_users(db: Session) -> None: + db.add(User(name="Build Lead", email="build@kestrel.local", is_admin=True)) + db.add(User(name="Test Engineer", email="test@kestrel.local", is_admin=False)) + db.add(User(name="QA Inspector", email="qa@kestrel.local", is_admin=False)) + db.flush() + + def _write_project_yaml() -> None: """Write the Project Kestrel opal.project.yaml next to the running project.""" from opal.project import find_project_config @@ -1017,6 +1027,55 @@ def _add(key: str, part_key: str, qty: float, location: str, **kw: object) -> No # --------------------------------------------------------------------------- +def _add_op_tree( + db: Session, + procedure_id: int, + wc: dict[str, Workcenter], + ops: list[dict[str, Any]], +) -> None: + """Insert a list of top-level operations and their sub-steps. + + Each op dict supports: title, instructions, duration, workcenter, sub_steps. + Each sub-step dict supports: title, instructions, duration, workcenter, + requires_signoff, schema. Step numbers and parent_step_id are auto-computed. + """ + order = 0 + for op_idx, op in enumerate(ops, start=1): + order += 1 + parent = ProcedureStep( + procedure_id=procedure_id, + order=order, + step_number=str(op_idx), + level=0, + title=op["title"], + instructions=op.get("instructions"), + estimated_duration_minutes=op.get("duration"), + workcenter_id=wc[op["workcenter"]].id if op.get("workcenter") else None, + requires_signoff=op.get("requires_signoff", False), + required_data_schema=op.get("schema"), + ) + db.add(parent) + db.flush() + for sub_idx, sub in enumerate(op.get("sub_steps", []), start=1): + order += 1 + db.add( + ProcedureStep( + procedure_id=procedure_id, + parent_step_id=parent.id, + order=order, + step_number=f"{op_idx}.{sub_idx}", + level=1, + title=sub["title"], + instructions=sub.get("instructions"), + estimated_duration_minutes=sub.get("duration"), + workcenter_id=wc[sub["workcenter"]].id if sub.get("workcenter") else None, + requires_signoff=sub.get("requires_signoff", False), + required_data_schema=sub.get("schema"), + ) + ) + db.flush() + + def _seed_procedures( db: Session, p: dict[str, Part], @@ -1061,121 +1120,323 @@ def _seed_procedures( ) db.flush() - _eng_build_steps = [ - ( - "1", - "Inspect Components", - "Visually inspect chamber, injector, nozzle, and igniter for defects. Verify dimensions per drawing.", - 15, - "CLEAN", - False, - ), - ( - "2", - "Clean All Surfaces", - "Solvent-clean all mating surfaces with isopropyl alcohol. Blow dry with clean N2.", - 10, - "CLEAN", - False, - ), - ( - "3", - "Install Injector O-Ring", - "Lubricate -116 O-ring with Krytox grease. Seat into injector face groove.", - 5, - "CLEAN", - False, - ), - ( - "4", - "Mate Injector to Chamber", - "Align index pin. Lower injector onto chamber flange.", - 5, - "CLEAN", - False, - ), - ( - "5", - "Torque Injector Bolts", - 'Install 8x 1/4"-20 SHCS with lock washers. Torque in star pattern to 120 in-lb.', - 15, - "CLEAN", - True, - ), - ( - "6", - "Install Nozzle O-Ring", - "Lubricate -116 O-ring. Seat into nozzle throat groove.", - 5, - "CLEAN", - False, - ), - ( - "7", - "Install Nozzle", - "Thread nozzle into chamber aft end. Hand-tight plus 1/4 turn.", - 5, - "CLEAN", - False, - ), - ( - "8", - "Install Nozzle Retainer", - "Install 6x 10-32 SHCS in retainer ring. Torque to 40 in-lb.", - 10, - "CLEAN", - True, - ), - ( - "9", - "Install Igniter", - "Thread igniter into injector boss. Verify e-match leads routed clear of hot gas path.", - 10, - "CLEAN", - False, - ), - ( - "10", - "Leak Check — Low Pressure", - "Cap nozzle exit. Pressurize to 50 PSI with N2. Soap-bubble all joints.", - 15, - "CLEAN", - False, - ), - ( - "11", - "Final Inspection", - "Verify all fasteners torqued and marked. Photograph assembly from 4 angles.", - 10, - "CLEAN", - True, - ), - ( - "12", - "Record Assembly Data", - "Log serial numbers of all components, lot numbers of O-rings and fasteners.", - 5, - "CLEAN", - False, - ), + _eng_build_ops: list[dict[str, Any]] = [ + { + "title": "Component Prep and Inspection", + "instructions": ( + "Inspect, clean, and stage all chamber, injector, nozzle, and igniter hardware " + "before assembly begins." + ), + "duration": 5, + "workcenter": "CLEAN", + "sub_steps": [ + { + "title": "Visual Component Inspection", + "instructions": ( + "Inspect chamber bore, injector face, nozzle throat, and igniter housing " + "for nicks, machining swarf, or surface contamination." + ), + "duration": 15, + "workcenter": "CLEAN", + }, + { + "title": "Verify Dimensions Against Drawing", + "instructions": ( + "Check chamber ID, injector face flatness, and nozzle throat diameter " + "with calipers. Confirm all dimensions are within tolerance per drawing." + ), + "duration": 10, + "workcenter": "CLEAN", + "requires_signoff": True, + }, + { + "title": "Solvent-Clean All Mating Surfaces", + "instructions": ( + "Wipe chamber flange, injector face, nozzle threads, and O-ring grooves " + "with isopropyl alcohol on a lint-free wipe. Blow dry with clean N2." + ), + "duration": 10, + "workcenter": "CLEAN", + }, + { + "title": "Stage Hardware Kit", + "instructions": ( + "Verify kit contents against BOM: 8x 1/4-20 SHCS, 8x lock washers, " + "8x hex nuts, 6x 10-32 SHCS, 2x -116 O-rings. Record O-ring and " + "fastener lot numbers on traveler." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + ], + }, + { + "title": "Injector to Chamber Mating", + "instructions": "Loose-install the injector onto the chamber. No torque yet.", + "duration": 5, + "workcenter": "CLEAN", + "sub_steps": [ + { + "title": "Install Injector O-Ring", + "instructions": ( + "Lubricate -116 O-ring with Krytox GPL-205 grease. Seat into injector " + "face groove. Verify O-ring sits flat with no twists or rolls." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + { + "title": "Align Index Pin", + "instructions": ( + "Rotate injector so the index dowel pin aligns with the chamber socket " + "at 0 degrees clock position." + ), + "duration": 3, + "workcenter": "CLEAN", + }, + { + "title": "Lower Injector onto Chamber", + "instructions": ( + "Slowly lower the injector flat onto the chamber flange. Verify the " + "O-ring engages evenly without pinching or extruding." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + ], + }, + { + "title": "Injector Bolt Torquing", + "instructions": "Install and final-torque the injector fasteners.", + "duration": 5, + "workcenter": "CLEAN", + "sub_steps": [ + { + "title": "Install Injector Fasteners", + "instructions": ( + "Install 8x 1/4-20 SHCS with lock washers and hex nuts through the " + "injector flange. Tighten finger-tight in star pattern." + ), + "duration": 10, + "workcenter": "CLEAN", + }, + { + "title": "Torque to Spec in Star Pattern", + "instructions": ( + "Torque 8x 1/4-20 SHCS to 120 in-lb in two passes: 60 in-lb first pass, " + "120 in-lb second pass. Follow star pattern." + ), + "duration": 15, + "workcenter": "CLEAN", + "requires_signoff": True, + "schema": { + "fields": [ + { + "name": f"bolt{i}_torque", + "type": "number", + "label": f"Bolt {i} final torque (in-lb)", + "required": True, + } + for i in range(1, 9) + ] + }, + }, + { + "title": "Apply Witness Marks", + "instructions": ( + "Apply yellow torque-seal across each bolt head and flange. Photograph " + "flange from two angles." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + ], + }, + { + "title": "Nozzle Installation", + "instructions": "Install nozzle and torque the retainer ring.", + "duration": 5, + "workcenter": "CLEAN", + "sub_steps": [ + { + "title": "Install Nozzle O-Ring", + "instructions": ( + "Lubricate -116 O-ring with Krytox grease. Seat into nozzle throat " + "groove. Verify no twists." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + { + "title": "Thread Nozzle into Chamber", + "instructions": ( + "Thread nozzle into chamber aft end. Hand-tight plus 1/4 turn. " + "Verify nozzle seats fully against the O-ring." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + { + "title": "Install Nozzle Retainer Ring", + "instructions": ( + "Install retainer ring over nozzle. Insert 6x 10-32 SHCS and torque to " + "40 in-lb in alternating pattern." + ), + "duration": 10, + "workcenter": "CLEAN", + "requires_signoff": True, + "schema": { + "fields": [ + { + "name": f"retainer_bolt{i}_torque", + "type": "number", + "label": f"Retainer bolt {i} torque (in-lb)", + "required": True, + } + for i in range(1, 7) + ] + }, + }, + ], + }, + { + "title": "Igniter Installation", + "instructions": "Thread igniter into injector boss and verify continuity.", + "duration": 5, + "workcenter": "CLEAN", + "sub_steps": [ + { + "title": "Thread Igniter into Injector Boss", + "instructions": ( + "Apply thread sealant to igniter NPT threads. Thread into injector boss " + "hand-tight plus 1.5 turns. Torque to 25 ft-lb." + ), + "duration": 8, + "workcenter": "CLEAN", + }, + { + "title": "Route E-Match Leads", + "instructions": ( + "Route igniter leads up the chamber exterior, clear of the hot-gas " + "path. Tape leads to chamber wall with Kapton tape." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + { + "title": "Igniter Continuity Check", + "instructions": ( + "Measure resistance across e-match terminals with multimeter. Acceptable " + "range 1.0 to 3.0 ohms." + ), + "duration": 3, + "workcenter": "CLEAN", + "schema": { + "fields": [ + { + "name": "igniter_ohms", + "type": "number", + "label": "Igniter resistance (ohms)", + "required": True, + } + ] + }, + }, + ], + }, + { + "title": "Pneumatic Leak Check", + "instructions": "Pressure-test all sealed joints with GN2 before closeout.", + "duration": 5, + "workcenter": "CLEAN", + "sub_steps": [ + { + "title": "Cap Nozzle Exit", + "instructions": ( + "Install machined test cap on nozzle exit with -012 O-ring. Verify cap " + "seats and is hand-tight." + ), + "duration": 3, + "workcenter": "CLEAN", + }, + { + "title": "Pressurize to 50 PSI", + "instructions": ( + "Connect GN2 supply via pressure test fixture (KST-G-0005). Slowly " + "pressurize chamber to 50 PSI. Hold 60 seconds." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + { + "title": "Soap-Bubble All Joints", + "instructions": ( + "Apply Snoop leak detector to injector flange, nozzle threads, igniter " + "boss, and cap seal. No bubbles permitted." + ), + "duration": 10, + "workcenter": "CLEAN", + "requires_signoff": True, + }, + { + "title": "Depressurize and Remove Cap", + "instructions": ( + "Slowly vent chamber to 0 PSI. Disconnect GN2 supply. Remove nozzle " + "exit cap." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + ], + }, + { + "title": "Final Inspection and Documentation", + "instructions": "Close out the build traveler and transfer engine to bonded storage.", + "duration": 5, + "workcenter": "CLEAN", + "sub_steps": [ + { + "title": "Final Visual Inspection", + "instructions": ( + "Confirm all fasteners are torque-sealed, no FOD inside chamber, no " + "swarf on injector face, no damage to igniter leads." + ), + "duration": 10, + "workcenter": "CLEAN", + "requires_signoff": True, + }, + { + "title": "Photograph Assembly", + "instructions": ( + "Take 4 photographs: fore (injector side), aft (nozzle side), left, " + "right. Include SN placard in each frame." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + { + "title": "Record Assembly Data", + "instructions": ( + "Log SNs of chamber, injector, nozzle, igniter. Record O-ring lot, " + "fastener lot, and thread sealant batch numbers." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + { + "title": "Build Traveler Sign-Off", + "instructions": ( + "Build lead and QA inspector both sign off that the engine is complete " + "and ready for hydrostatic proof test." + ), + "duration": 5, + "workcenter": "CLEAN", + "requires_signoff": True, + }, + ], + }, ] - for order, (sn, title, instructions, dur, wc_code, signoff) in enumerate( - _eng_build_steps, start=1 - ): - db.add( - ProcedureStep( - procedure_id=eng_build.id, - order=order, - step_number=sn, - level=0, - title=title, - instructions=instructions, - estimated_duration_minutes=dur, - workcenter_id=wc[wc_code].id, - requires_signoff=signoff, - ) - ) - db.flush() + _add_op_tree(db, eng_build.id, wc, _eng_build_ops) # ── 2. Hydrostatic Proof Test ─────────────────────────────── hydro = MasterProcedure( @@ -1188,146 +1449,305 @@ def _seed_procedures( db.flush() procs["hydro"] = hydro - _hydro_steps = [ - ( - "1", - "Setup Test Fixture", - 'Connect pressure test fixture to test article via 1/4" fitting. Fill with distilled water, bleed air.', - 20, - "PAD", - False, - None, - ), - ( - "2", - "Verify Instrumentation", - "Confirm pressure gauge reads 0 ±2 PSI. Verify data acquisition running.", - 5, - "PAD", - False, - None, - ), - ( - "3", - "Pressurize to 100 PSI", - "Slowly pressurize to 100 PSI. Hold 60 seconds. Inspect for leaks.", - 5, - "PAD", - False, - { - "fields": [ - { - "name": "pressure_100_psi", - "type": "number", - "label": "Pressure at hold (PSI)", - "required": True, - } - ] - }, - ), - ( - "4", - "Pressurize to 300 PSI", - "Increase to 300 PSI. Hold 60 seconds. Inspect.", - 5, - "PAD", - False, - { - "fields": [ - { - "name": "pressure_300_psi", - "type": "number", - "label": "Pressure at hold (PSI)", - "required": True, - } - ] - }, - ), - ( - "5", - "Pressurize to 500 PSI (MEOP)", - "Increase to 500 PSI. Hold 2 minutes. Inspect thoroughly.", - 5, - "PAD", - False, - { - "fields": [ - { - "name": "pressure_meop", - "type": "number", - "label": "Pressure at hold (PSI)", - "required": True, - } - ] - }, - ), - ( - "6", - "Pressurize to 675 PSI (Proof)", - "Increase to 675 PSI (1.35x MEOP). Hold 5 minutes. No leaks or yielding.", - 10, - "PAD", - True, - { - "fields": [ - { - "name": "proof_pressure", - "type": "number", - "label": "Peak proof pressure (PSI)", - "required": True, + _hydro_ops: list[dict[str, Any]] = [ + { + "title": "Test Setup", + "instructions": ( + "Plumb the test article to the pressure fixture, fill with distilled water, " + "and verify all instrumentation is reading nominal." + ), + "duration": 5, + "workcenter": "PAD", + "sub_steps": [ + { + "title": "Connect Test Fixture", + "instructions": ( + "Connect pressure test fixture (KST-G-0005) to test article inlet via " + "1/4 inch SS tubing. Torque AN-4 fitting to 80 in-lb." + ), + "duration": 10, + "workcenter": "PAD", + }, + { + "title": "Fill with Distilled Water", + "instructions": ( + "Fill test article with distilled water through fill port until water " + "weeps from highest vent. Close vent." + ), + "duration": 8, + "workcenter": "PAD", + }, + { + "title": "Bleed Air from Lines", + "instructions": ( + "Open bleed valve and slowly stroke hand pump until water flows clear " + "with no entrained air. Close bleed valve." + ), + "duration": 5, + "workcenter": "PAD", + }, + { + "title": "Verify Instrumentation", + "instructions": ( + "Confirm pressure gauge reads 0 to 2 PSI. Verify data-acquisition " + "system is logging. Verify pressure relief valve set point at 750 PSI." + ), + "duration": 5, + "workcenter": "PAD", + }, + ], + }, + { + "title": "Pressure Step — 100 PSI", + "instructions": "First low-pressure hold. Verify gross leak integrity.", + "duration": 5, + "workcenter": "PAD", + "sub_steps": [ + { + "title": "Pressurize to 100 PSI", + "instructions": ( + "Slowly pressurize with hand pump at 10 PSI/sec. Stop at 100 PSI and " + "record reading." + ), + "duration": 5, + "workcenter": "PAD", + "schema": { + "fields": [ + { + "name": "pressure_100_psi", + "type": "number", + "label": "Pressure at hold (PSI)", + "required": True, + } + ] }, - { - "name": "hold_duration_s", - "type": "number", - "label": "Hold duration (seconds)", - "required": True, + }, + { + "title": "Hold and Inspect for Leaks", + "instructions": ( + "Hold 60 seconds. Visually inspect all joints and weld lines. No " + "weeping or pressure decay permitted." + ), + "duration": 3, + "workcenter": "PAD", + }, + ], + }, + { + "title": "Pressure Step — 300 PSI", + "instructions": "Intermediate pressure hold.", + "duration": 5, + "workcenter": "PAD", + "sub_steps": [ + { + "title": "Pressurize to 300 PSI", + "instructions": "Slowly pressurize to 300 PSI. Record reading.", + "duration": 5, + "workcenter": "PAD", + "schema": { + "fields": [ + { + "name": "pressure_300_psi", + "type": "number", + "label": "Pressure at hold (PSI)", + "required": True, + } + ] }, - ] - }, - ), - ( - "7", - "Depressurize and Inspect", - "Slowly depressurize to 0. Inspect for permanent deformation — measure OD at 3 stations.", - 10, - "PAD", - False, - { - "fields": [ - {"name": "od_station_1", "type": "number", "label": "OD Station 1 (in)"}, - {"name": "od_station_2", "type": "number", "label": "OD Station 2 (in)"}, - {"name": "od_station_3", "type": "number", "label": "OD Station 3 (in)"}, - ] - }, - ), - ( - "8", - "Record Results", - "Log pass/fail. Drain test article. Dry with N2.", - 5, - "PAD", - True, - None, - ), + }, + { + "title": "Hold and Inspect", + "instructions": "Hold 60 seconds. Inspect for weeping or audible hiss.", + "duration": 3, + "workcenter": "PAD", + }, + ], + }, + { + "title": "MEOP Hold — 500 PSI", + "instructions": "Maximum Expected Operating Pressure hold.", + "duration": 5, + "workcenter": "PAD", + "sub_steps": [ + { + "title": "Pressurize to MEOP", + "instructions": "Slowly pressurize to 500 PSI. Record reading.", + "duration": 5, + "workcenter": "PAD", + "schema": { + "fields": [ + { + "name": "pressure_meop", + "type": "number", + "label": "Pressure at hold (PSI)", + "required": True, + } + ] + }, + }, + { + "title": "Hold 2 Minutes", + "instructions": ( + "Hold 120 seconds. Monitor pressure gauge for decay. Decay shall not " + "exceed 2 PSI." + ), + "duration": 3, + "workcenter": "PAD", + }, + { + "title": "Thorough Joint Inspection", + "instructions": ( + "While at MEOP, inspect every weld seam, fitting, and seal with a " + "flashlight. Mark any suspect areas with chalk." + ), + "duration": 5, + "workcenter": "PAD", + }, + ], + }, + { + "title": "Proof Pressure Hold — 675 PSI", + "instructions": "Proof test at 1.35x MEOP. Verify no yielding or weeping.", + "duration": 5, + "workcenter": "PAD", + "sub_steps": [ + { + "title": "Pressurize to Proof", + "instructions": ( + "Slowly pressurize to 675 PSI (1.35x MEOP). Approach proof pressure at " + "5 PSI/sec for the final 50 PSI." + ), + "duration": 8, + "workcenter": "PAD", + "requires_signoff": True, + "schema": { + "fields": [ + { + "name": "proof_pressure", + "type": "number", + "label": "Peak proof pressure (PSI)", + "required": True, + }, + { + "name": "hold_duration_s", + "type": "number", + "label": "Hold duration (seconds)", + "required": True, + }, + ] + }, + }, + { + "title": "Hold 5 Minutes", + "instructions": ( + "Hold 300 seconds. Pressure decay shall not exceed 5 PSI. Log decay rate." + ), + "duration": 6, + "workcenter": "PAD", + }, + { + "title": "Verify No Yielding or Weeping", + "instructions": ( + "Final inspection at proof pressure. Any weeping, audible hiss, or " + "visible deformation is a failure." + ), + "duration": 5, + "workcenter": "PAD", + "requires_signoff": True, + }, + ], + }, + { + "title": "Depressurization and Dimensional Check", + "instructions": "Vent back to zero and verify no permanent deformation.", + "duration": 5, + "workcenter": "PAD", + "sub_steps": [ + { + "title": "Slow Depressurize to 0 PSI", + "instructions": ( + "Open bleed valve and vent slowly at 25 PSI/sec to atmosphere. " + "Disconnect supply once stable at 0." + ), + "duration": 5, + "workcenter": "PAD", + }, + { + "title": "Measure OD at 3 Stations", + "instructions": ( + "Measure outside diameter with pi-tape at three stations along the " + "test article. Compare to baseline measurements taken pre-test." + ), + "duration": 8, + "workcenter": "PAD", + "schema": { + "fields": [ + { + "name": "od_station_1", + "type": "number", + "label": "OD Station 1 (in)", + }, + { + "name": "od_station_2", + "type": "number", + "label": "OD Station 2 (in)", + }, + { + "name": "od_station_3", + "type": "number", + "label": "OD Station 3 (in)", + }, + ] + }, + }, + { + "title": "Visual Inspection for Cracks or Bulging", + "instructions": ( + "Inspect entire test article with bright light. Any visible cracks, " + "bulging, or surface distortion is a failure." + ), + "duration": 5, + "workcenter": "PAD", + }, + ], + }, + { + "title": "Test Closeout", + "instructions": "Drain, dry, and record results.", + "duration": 5, + "workcenter": "PAD", + "sub_steps": [ + { + "title": "Drain Test Article", + "instructions": ( + "Open drain valve and tilt to fully empty water. Catch in graduated " + "container to verify fill volume." + ), + "duration": 5, + "workcenter": "PAD", + }, + { + "title": "Dry with N2 Purge", + "instructions": ( + "Purge interior with dry GN2 for 5 minutes. Verify no residual moisture." + ), + "duration": 8, + "workcenter": "PAD", + }, + { + "title": "Record Pass or Fail", + "instructions": ( + "Test director records pass/fail in traveler. Attach pressure trace " + "and OD measurements to test report." + ), + "duration": 5, + "workcenter": "PAD", + "requires_signoff": True, + }, + ], + }, ] - order = 0 - for sn, title, instr, dur, wc_code, signoff, schema in _hydro_steps: - order += 1 - db.add( - ProcedureStep( - procedure_id=hydro.id, - order=order, - step_number=sn, - level=0, - title=title, - instructions=instr, - estimated_duration_minutes=dur, - workcenter_id=wc[wc_code].id, - requires_signoff=signoff, - required_data_schema=schema, - ) - ) - db.flush() + _add_op_tree(db, hydro.id, wc, _hydro_ops) # ── 3. Hot Fire Test ──────────────────────────────────────── hotfire = MasterProcedure( @@ -1340,188 +1760,538 @@ def _seed_procedures( db.flush() procs["hotfire"] = hotfire - _hotfire_steps = [ - ( - "1", - "Pre-Test Briefing", - "Review test plan, abort criteria, and roles. Confirm range is clear.", - 15, - "PAD", - False, - None, - ), - ( - "2", - "Install Engine on Stand", - "Mount engine assembly to thrust stand adapter plate. Torque mount bolts.", - 20, - "PAD", - False, - None, - ), - ( - "3", - "Connect Propellant Lines", - "Connect LOX and fuel feed lines to engine inlets. Torque AN fittings.", - 15, - "PAD", - False, - None, - ), - ( - "4", - "Connect Instrumentation", - "Attach thrust load cell cable, chamber pressure transducer, thermocouples.", - 10, - "PAD", - False, - None, - ), - ( - "5", - "Leak Check — Pneumatic", - "Pressurize propellant lines to 50 PSI with N2. Verify zero leaks.", - 10, - "PAD", - True, - None, - ), - ( - "6", - "Load Propellants", - "Fill LOX tank (2.5 gal). Fill ethanol tank (3.0 gal). Verify levels.", - 20, - "PAD", - True, - { - "fields": [ - {"name": "lox_fill_level", "type": "number", "label": "LOX fill level (gal)"}, - {"name": "fuel_fill_level", "type": "number", "label": "Fuel fill level (gal)"}, - ] - }, - ), - ( - "7", - "Pressurize Tanks", - "Open N2 supply. Regulate to 450 PSI. Verify both tank pressures.", - 5, - "PAD", - False, - { - "fields": [ - {"name": "lox_tank_psi", "type": "number", "label": "LOX tank pressure (PSI)"}, - { - "name": "fuel_tank_psi", - "type": "number", - "label": "Fuel tank pressure (PSI)", + _hotfire_ops: list[dict[str, Any]] = [ + { + "title": "Pre-Test Planning", + "instructions": "Confirm plan, range, and weather before any propellant handling.", + "duration": 5, + "workcenter": "PAD", + "sub_steps": [ + { + "title": "Review Test Plan and Abort Criteria", + "instructions": ( + "Walk through test card with all crew. Confirm each station knows " + "their role and the abort triggers." + ), + "duration": 15, + "workcenter": "PAD", + }, + { + "title": "Range Clear Confirmation", + "instructions": ( + "Range safety officer sweeps test area and confirms all non-essential " + "personnel are clear of the 500 ft exclusion zone." + ), + "duration": 10, + "workcenter": "PAD", + "requires_signoff": True, + }, + { + "title": "Weather Check", + "instructions": ( + "Verify winds under 20 kts, no lightning within 25 nm, visibility " + "greater than 3 nm. Document conditions." + ), + "duration": 5, + "workcenter": "PAD", + }, + ], + }, + { + "title": "Engine Stand Installation", + "instructions": "Mount engine to the thrust stand and verify alignment.", + "duration": 5, + "workcenter": "PAD", + "sub_steps": [ + { + "title": "Mount Engine to Adapter Plate", + "instructions": ( + "Lift engine onto thrust stand adapter plate. Engage 4x 3/8-16 mount " + "bolts finger-tight." + ), + "duration": 15, + "workcenter": "PAD", + }, + { + "title": "Torque Mount Bolts", + "instructions": ( + "Torque 4x 3/8-16 mount bolts to 240 in-lb in star pattern. Apply " + "Loctite 242." + ), + "duration": 10, + "workcenter": "PAD", + "requires_signoff": True, + "schema": { + "fields": [ + { + "name": f"bolt{i}_torque", + "type": "number", + "label": f"Mount bolt {i} torque (in-lb)", + "required": True, + } + for i in range(1, 5) + ] }, - ] - }, - ), - ( - "8", - "Arm Ignition System", - "Turn igniter arm key. Verify continuity LED.", - 2, - "PAD", - True, - None, - ), - ("9", "Final Poll — Go/No-Go", "Poll all stations. Record go/no-go.", 5, "PAD", True, None), - ( - "10", - "Countdown and Fire", - "5-4-3-2-1-IGNITION. Open valves on command. Run 5 seconds.", - 1, - "PAD", - False, - None, - ), - ( - "11", - "Engine Shutdown", - "Close main valves. Vent tanks. Safe ignition system.", - 2, - "PAD", - False, - None, - ), - ( - "12", - "Record Test Data", - "Download thrust curve, chamber pressure trace, temperatures.", - 10, - "PAD", - False, - { - "fields": [ - { - "name": "peak_chamber_psi", - "type": "number", - "label": "Peak Pc (PSI)", - "required": True, + }, + { + "title": "Verify Engine Alignment", + "instructions": ( + "Check that nozzle axis is parallel to thrust stand load axis within " + "0.5 degrees. Adjust shims if needed." + ), + "duration": 10, + "workcenter": "PAD", + }, + ], + }, + { + "title": "Propellant Plumbing", + "instructions": "Connect propellant and pressurant feedlines to the engine.", + "duration": 5, + "workcenter": "PAD", + "sub_steps": [ + { + "title": "Connect LOX Feed Line", + "instructions": ( + "Connect 1/2 inch SS LOX line to engine LOX inlet. Torque AN-8 fitting " + "to 180 in-lb." + ), + "duration": 8, + "workcenter": "PAD", + }, + { + "title": "Connect Fuel Feed Line", + "instructions": ( + "Connect 1/2 inch SS ethanol line to engine fuel inlet. Torque AN-8 " + "fitting to 180 in-lb." + ), + "duration": 8, + "workcenter": "PAD", + }, + { + "title": "Connect Pressurant Lines", + "instructions": ( + "Connect 1/4 inch GN2 pressurant lines to LOX and fuel tank ullage " + "ports. Torque AN-4 to 80 in-lb." + ), + "duration": 8, + "workcenter": "PAD", + }, + ], + }, + { + "title": "Instrumentation Hookup", + "instructions": "Connect all transducers and verify DAQ is capturing.", + "duration": 5, + "workcenter": "PAD", + "sub_steps": [ + { + "title": "Connect Thrust Load Cell", + "instructions": "Attach load cell cable to DAQ channel 1. Verify excitation voltage.", + "duration": 5, + "workcenter": "PAD", + }, + { + "title": "Connect Chamber Pressure Transducer", + "instructions": ( + "Install Pc transducer in injector boss tap. Torque 1/8 NPT to 80 " + "in-lb with thread sealant. Connect to DAQ channel 2." + ), + "duration": 8, + "workcenter": "PAD", + }, + { + "title": "Connect Thermocouples", + "instructions": ( + "Install Type K thermocouples on chamber wall, nozzle throat, and " + "injector body. Route leads to DAQ channels 3 to 5." + ), + "duration": 10, + "workcenter": "PAD", + }, + { + "title": "Verify Data Acquisition Capture", + "instructions": ( + "Run DAQ self-test. Tap each transducer and verify signal appears on " + "the trace. Confirm sample rate at 1 kHz." + ), + "duration": 5, + "workcenter": "PAD", + "requires_signoff": True, + }, + ], + }, + { + "title": "Pneumatic Leak Check", + "instructions": "Pressure-test propellant lines with GN2 before loading.", + "duration": 5, + "workcenter": "PAD", + "sub_steps": [ + { + "title": "Cap Engine Inlets", + "instructions": "Install test caps on engine LOX and fuel inlets.", + "duration": 3, + "workcenter": "PAD", + }, + { + "title": "Pressurize Lines to 50 PSI", + "instructions": ( + "Apply GN2 to both propellant lines at 50 PSI. Hold 60 seconds." + ), + "duration": 5, + "workcenter": "PAD", + }, + { + "title": "Soap-Bubble All Joints", + "instructions": ( + "Apply Snoop to every AN fitting, NPT joint, and valve body. No " + "bubbles permitted." + ), + "duration": 10, + "workcenter": "PAD", + "requires_signoff": True, + }, + { + "title": "Depressurize and Remove Caps", + "instructions": "Vent lines to 0 PSI. Remove test caps from engine inlets.", + "duration": 5, + "workcenter": "PAD", + }, + ], + }, + { + "title": "Propellant Loading", + "instructions": "Load LOX and ethanol. Loading crew only on pad.", + "duration": 5, + "workcenter": "PAD", + "sub_steps": [ + { + "title": "Clear Pad Area", + "instructions": ( + "All personnel except loading crew (2 max) clear to 100 ft. Loading " + "crew in PPE: face shield, cryo gloves, leather apron." + ), + "duration": 5, + "workcenter": "PAD", + }, + { + "title": "Fill LOX Tank", + "instructions": ( + "Slowly fill LOX tank from dewar through fill port. Target 2.5 gal. " + "Vent ullage to atmosphere during fill." + ), + "duration": 15, + "workcenter": "PAD", + "schema": { + "fields": [ + { + "name": "lox_fill_level", + "type": "number", + "label": "LOX fill level (gal)", + "required": True, + } + ] }, - { - "name": "peak_thrust_lbf", - "type": "number", - "label": "Peak thrust (lbf)", - "required": True, + }, + { + "title": "Fill Ethanol Tank", + "instructions": "Fill ethanol tank from supply jug. Target 3.0 gal.", + "duration": 10, + "workcenter": "PAD", + "schema": { + "fields": [ + { + "name": "fuel_fill_level", + "type": "number", + "label": "Fuel fill level (gal)", + "required": True, + } + ] }, - { - "name": "burn_time_s", - "type": "number", - "label": "Burn time (seconds)", - "required": True, + }, + { + "title": "Verify Fill Levels and Crack Vent", + "instructions": ( + "Confirm both tank sight glasses show target volume. Crack vent valves " + "to relieve pressure during fill cooldown." + ), + "duration": 5, + "workcenter": "PAD", + "requires_signoff": True, + }, + ], + }, + { + "title": "Tank Pressurization", + "instructions": "Bring tanks to 450 PSI pressurant.", + "duration": 5, + "workcenter": "PAD", + "sub_steps": [ + { + "title": "Connect N2 Supply", + "instructions": "Open main N2 K-bottle valve. Verify supply pressure on regulator inlet.", + "duration": 3, + "workcenter": "PAD", + }, + { + "title": "Regulate to 450 PSI", + "instructions": ( + "Adjust ground regulator to deliver 450 PSI. Slowly open isolation " + "valves to tank ullage ports." + ), + "duration": 5, + "workcenter": "PAD", + }, + { + "title": "Verify Tank Pressures", + "instructions": "Read LOX and fuel tank ullage pressure. Both must be 440 to 460 PSI.", + "duration": 3, + "workcenter": "PAD", + "schema": { + "fields": [ + { + "name": "lox_tank_psi", + "type": "number", + "label": "LOX tank pressure (PSI)", + "required": True, + }, + { + "name": "fuel_tank_psi", + "type": "number", + "label": "Fuel tank pressure (PSI)", + "required": True, + }, + ] }, - ] - }, - ), - ( - "13", - "Post-Fire Inspection", - "Inspect nozzle throat, injector face, chamber walls for erosion or damage.", - 15, - "PAD", - False, - None, - ), - ( - "14", - "Disconnect and Remove", - "Disconnect all lines and instrumentation. Remove engine from stand.", - 20, - "PAD", - False, - None, - ), - ( - "15", - "Debrief and Report", - "Review data. Compare Pc and thrust to predictions. Note anomalies.", - 30, - "PAD", - False, - None, - ), + }, + ], + }, + { + "title": "Arm and Go/No-Go", + "instructions": "All personnel to bunker. Final arming and poll.", + "duration": 5, + "workcenter": "PAD", + "sub_steps": [ + { + "title": "Clear All Personnel to Bunker", + "instructions": "Loading crew clears pad to bunker. Confirm all stations report clear.", + "duration": 3, + "workcenter": "PAD", + "requires_signoff": True, + }, + { + "title": "Arm Ignition System", + "instructions": ( + "Turn igniter arm key from SAFE to ARMED. Verify ARMED indicator LED " + "is lit and continuity is shown." + ), + "duration": 2, + "workcenter": "PAD", + "requires_signoff": True, + }, + { + "title": "Final Station Poll Go/No-Go", + "instructions": ( + "Test director polls each station by callsign: PROPS, DAQ, RANGE, " + "MED. Record GO from all before proceeding." + ), + "duration": 5, + "workcenter": "PAD", + "requires_signoff": True, + }, + ], + }, + { + "title": "Fire Sequence", + "instructions": "Execute ignition and main-stage burn.", + "duration": 5, + "workcenter": "PAD", + "sub_steps": [ + { + "title": "Countdown from 5", + "instructions": "Test director calls 5-4-3-2-1 over comms. DAQ trigger armed at T-2.", + "duration": 1, + "workcenter": "PAD", + }, + { + "title": "Igniter Command", + "instructions": ( + "At T-0 fire igniter. Verify pre-burner light through pad camera " + "within 200 ms." + ), + "duration": 1, + "workcenter": "PAD", + }, + { + "title": "Main Valve Open Command", + "instructions": ( + "T+0.5s: open main LOX and fuel valves simultaneously. Run 5 seconds. " + "Monitor chamber pressure trace live." + ), + "duration": 1, + "workcenter": "PAD", + }, + { + "title": "Engine Shutdown", + "instructions": "T+5.5s: close both main valves. Engine should extinguish within 100 ms.", + "duration": 1, + "workcenter": "PAD", + }, + ], + }, + { + "title": "Safe-State", + "instructions": "Vent system and disarm before any pad approach.", + "duration": 5, + "workcenter": "PAD", + "sub_steps": [ + { + "title": "Close Main Valves", + "instructions": "Confirm LOX and fuel main valves indicate CLOSED on control panel.", + "duration": 2, + "workcenter": "PAD", + }, + { + "title": "Vent Tank Ullage", + "instructions": ( + "Open tank vent valves and bleed ullage pressure to 0 PSI. Monitor " + "tank pressure gauges." + ), + "duration": 5, + "workcenter": "PAD", + }, + { + "title": "Safe Ignition System", + "instructions": "Turn igniter key to SAFE and remove. Confirm ARMED LED off.", + "duration": 2, + "workcenter": "PAD", + "requires_signoff": True, + }, + { + "title": "Approach and Inspect for Residual Fire", + "instructions": ( + "Wait 5 minutes after shutdown before approach. RSO leads team to " + "pad with extinguisher. Verify no residual fire or smoldering." + ), + "duration": 8, + "workcenter": "PAD", + "requires_signoff": True, + }, + ], + }, + { + "title": "Data Capture and Post-Fire Inspection", + "instructions": "Pull DAQ files and inspect engine hardware.", + "duration": 5, + "workcenter": "PAD", + "sub_steps": [ + { + "title": "Download DAQ Files", + "instructions": ( + "Download thrust, Pc, and temperature traces from DAQ. Verify file " + "integrity. Record peak values." + ), + "duration": 10, + "workcenter": "PAD", + "schema": { + "fields": [ + { + "name": "peak_chamber_psi", + "type": "number", + "label": "Peak Pc (PSI)", + "required": True, + }, + { + "name": "peak_thrust_lbf", + "type": "number", + "label": "Peak thrust (lbf)", + "required": True, + }, + { + "name": "burn_time_s", + "type": "number", + "label": "Burn time (seconds)", + "required": True, + }, + ] + }, + }, + { + "title": "Inspect Nozzle Throat", + "instructions": ( + "Visual inspection of nozzle throat. Check for erosion, cracks, or " + "ablative loss. Photograph." + ), + "duration": 5, + "workcenter": "PAD", + }, + { + "title": "Inspect Injector Face", + "instructions": ( + "Borescope injector face. Check for orifice erosion, soot patterns, " + "or hot spots. Photograph." + ), + "duration": 5, + "workcenter": "PAD", + }, + { + "title": "Inspect Chamber Walls", + "instructions": ( + "Borescope chamber walls. Check for erosion, hot streaks, or " + "discoloration. Photograph." + ), + "duration": 5, + "workcenter": "PAD", + }, + ], + }, + { + "title": "Stand Removal and Debrief", + "instructions": "Disconnect, remove engine, and review data.", + "duration": 5, + "workcenter": "PAD", + "sub_steps": [ + { + "title": "Disconnect Lines and Instrumentation", + "instructions": ( + "Disconnect all propellant lines, pressurant lines, and instrumentation " + "cables. Cap engine ports." + ), + "duration": 15, + "workcenter": "PAD", + }, + { + "title": "Remove Engine from Stand", + "instructions": ( + "Break 4x mount bolts. Lift engine off adapter plate. Transfer to " + "transport cart." + ), + "duration": 10, + "workcenter": "PAD", + }, + { + "title": "Data Review vs Predictions", + "instructions": ( + "Compare measured Pc, thrust, and burn time against predicted values. " + "Note any deviations greater than 10%." + ), + "duration": 20, + "workcenter": "PAD", + }, + { + "title": "Test Debrief and Sign-Off", + "instructions": ( + "Whole team debrief: lessons learned, anomalies, action items. Test " + "director signs off on test report." + ), + "duration": 15, + "workcenter": "PAD", + "requires_signoff": True, + }, + ], + }, ] - order = 0 - for sn, title, instr, dur, wc_code, signoff, schema in _hotfire_steps: - order += 1 - db.add( - ProcedureStep( - procedure_id=hotfire.id, - order=order, - step_number=sn, - level=0, - title=title, - instructions=instr, - estimated_duration_minutes=dur, - workcenter_id=wc[wc_code].id, - requires_signoff=signoff, - required_data_schema=schema, - ) - ) - db.flush() + _add_op_tree(db, hotfire.id, wc, _hotfire_ops) # ── 4. Avionics Integration ───────────────────────────────── avi = MasterProcedure( @@ -1553,80 +2323,328 @@ def _seed_procedures( ) db.flush() - _avi_steps = [ - ( - "1", - "Prepare Avionics Sled", - "Clean sled. Install standoffs for FC, pyro board, GPS.", - 10, - "LAB", - ), - ( - "2", - "Mount Flight Computer", - "Secure Teensy 4.1 board to standoffs. Verify USB port accessible.", - 5, - "LAB", - ), - ( - "3", - "Mount Sensors", - "Install BNO055 IMU, MS5611 altimeter, MAX-M10S GPS. Secure connectors.", - 15, - "LAB", - ), - ( - "4", - "Mount Pyro Board", - "Install pyro channel board. Connect optoisolator ribbon cable to FC.", - 10, - "LAB", - ), - ( - "5", - "Mount Radio", - "Install RFM95W module. Route antenna wire to external SMA bulkhead.", - 10, - "LAB", - ), - ( - "6", - "Build Wiring Harness", - "Route and terminate all wires per harness drawing. Lace with waxed cord.", - 30, - "LAB", - ), - ( - "7", - "Power-On Test", - "Connect LiPo. Verify FC boots, sensors respond, radio transmits test packet.", - 15, - "LAB", - ), - ( - "8", - "Final Inspection", - "Verify all connectors seated. Check wire routing for chafe points. Photograph.", - 10, - "LAB", - ), + _avi_ops: list[dict[str, Any]] = [ + { + "title": "Sled Preparation", + "instructions": "Clean sled and install all standoffs before any components touch the board.", + "duration": 5, + "workcenter": "LAB", + "sub_steps": [ + { + "title": "Clean Sled", + "instructions": ( + "Wipe avionics sled with isopropyl alcohol on a lint-free cloth. " + "Blow off with clean N2." + ), + "duration": 5, + "workcenter": "LAB", + }, + { + "title": "Install Standoffs", + "instructions": ( + "Install M3 brass standoffs at flight computer, pyro board, GPS, and " + "radio mounting locations per assembly drawing." + ), + "duration": 10, + "workcenter": "LAB", + }, + { + "title": "Verify Mounting Hole Pattern", + "instructions": ( + "Test-fit each board on its standoffs. Confirm no hole misalignment " + "before applying torque." + ), + "duration": 5, + "workcenter": "LAB", + }, + ], + }, + { + "title": "Flight Computer Mount", + "instructions": "Mount Teensy 4.1 and verify USB access.", + "duration": 5, + "workcenter": "LAB", + "sub_steps": [ + { + "title": "Position FC on Standoffs", + "instructions": "Place Teensy 4.1 onto its standoffs with USB port facing the access window.", + "duration": 3, + "workcenter": "LAB", + }, + { + "title": "Secure with M3 Screws", + "instructions": "Secure with 4x M3x6 screws. Torque to 4 in-lb. Do not over-tighten.", + "duration": 5, + "workcenter": "LAB", + }, + { + "title": "Verify USB Port Accessibility", + "instructions": ( + "Confirm USB-C cable seats and disconnects without binding through the " + "access window." + ), + "duration": 2, + "workcenter": "LAB", + }, + { + "title": "Bench Continuity Check", + "instructions": "Connect FC to bench supply at 5V. Verify board enumerates over USB.", + "duration": 5, + "workcenter": "LAB", + }, + ], + }, + { + "title": "Sensor Installation", + "instructions": "Mount IMU, altimeter, GPS and connect data buses.", + "duration": 5, + "workcenter": "LAB", + "sub_steps": [ + { + "title": "Install BNO055 IMU", + "instructions": ( + "Mount BNO055 breakout on standoffs with X-axis aligned to vehicle " + "roll axis per drawing. Secure with M3 screws." + ), + "duration": 8, + "workcenter": "LAB", + }, + { + "title": "Install MS5611 Altimeter", + "instructions": ( + "Mount MS5611 breakout. Verify barometric port is unblocked and faces " + "the bay vent." + ), + "duration": 5, + "workcenter": "LAB", + }, + { + "title": "Install MAX-M10S GPS", + "instructions": ( + "Mount GPS module with antenna patch facing the airframe outer wall " + "for sky visibility." + ), + "duration": 5, + "workcenter": "LAB", + }, + { + "title": "Connect Sensor I2C and UART Cables", + "instructions": ( + "Connect IMU and altimeter to I2C bus pins. Connect GPS UART to FC " + "Serial1. Verify pin assignments against wiring diagram." + ), + "duration": 10, + "workcenter": "LAB", + }, + ], + }, + { + "title": "Pyro Board Installation", + "instructions": "Install and verify the pyro channel board.", + "duration": 5, + "workcenter": "LAB", + "sub_steps": [ + { + "title": "Install Pyro Channel Board on Standoffs", + "instructions": "Mount pyro board with screw terminals facing the bulkhead penetration.", + "duration": 5, + "workcenter": "LAB", + }, + { + "title": "Connect Optoisolator Ribbon to FC", + "instructions": ( + "Connect 10-conductor ribbon from pyro board to FC pyro control " + "header. Verify ribbon orientation by red stripe on pin 1." + ), + "duration": 5, + "workcenter": "LAB", + }, + { + "title": "Continuity Check Both Channels", + "instructions": ( + "With pyro board unpowered, verify continuity between FC pyro pins " + "and screw terminals using multimeter." + ), + "duration": 5, + "workcenter": "LAB", + "schema": { + "fields": [ + { + "name": "drogue_channel_ohms", + "type": "number", + "label": "Drogue channel continuity (ohms)", + "required": True, + }, + { + "name": "main_channel_ohms", + "type": "number", + "label": "Main channel continuity (ohms)", + "required": True, + }, + ] + }, + }, + ], + }, + { + "title": "Radio and Antenna", + "instructions": "Install telemetry radio and route antenna feed.", + "duration": 5, + "workcenter": "LAB", + "sub_steps": [ + { + "title": "Install RFM95W Module", + "instructions": "Mount RFM95W on its standoffs. Connect SPI ribbon to FC.", + "duration": 8, + "workcenter": "LAB", + }, + { + "title": "Route Antenna Coax", + "instructions": ( + "Route 50 ohm coax from RFM95W u.FL to airframe bulkhead. Avoid sharp " + "bends (minimum 1 inch radius)." + ), + "duration": 5, + "workcenter": "LAB", + }, + { + "title": "Install SMA Bulkhead Connector", + "instructions": "Install SMA bulkhead through coupler. Torque SMA to 5 in-lb.", + "duration": 5, + "workcenter": "LAB", + }, + ], + }, + { + "title": "Wiring Harness Build", + "instructions": "Build the harness on the sled per harness drawing.", + "duration": 5, + "workcenter": "LAB", + "sub_steps": [ + { + "title": "Cut and Strip Wires per Drawing", + "instructions": ( + "Cut each wire to length and strip 5 mm per harness drawing. Label " + "both ends with shrink-wrap tags." + ), + "duration": 20, + "workcenter": "LAB", + }, + { + "title": "Terminate Connectors", + "instructions": ( + "Crimp Molex SL connectors on each terminated wire. Pull-test each " + "crimp at 5 lbf." + ), + "duration": 25, + "workcenter": "LAB", + }, + { + "title": "Lace Harness with Waxed Cord", + "instructions": "Lace harness bundle with waxed cord every 25 mm. Tie off neatly.", + "duration": 15, + "workcenter": "LAB", + }, + { + "title": "Continuity Check Each Wire", + "instructions": ( + "Beep out every wire end-to-end with multimeter. Confirm no shorts " + "between adjacent pins." + ), + "duration": 15, + "workcenter": "LAB", + "requires_signoff": True, + }, + ], + }, + { + "title": "Power-On Test and Closeout", + "instructions": "Power up and verify all subsystems before final inspection.", + "duration": 5, + "workcenter": "LAB", + "sub_steps": [ + { + "title": "Install LiPo Battery", + "instructions": ( + "Install 2S 1500 mAh LiPo in battery holder. Confirm polarity. " + "Connect with XT30." + ), + "duration": 3, + "workcenter": "LAB", + }, + { + "title": "Verify Boot Sequence", + "instructions": ( + "Power FC. Watch USB serial for boot banner. Verify firmware version " + "matches build manifest." + ), + "duration": 5, + "workcenter": "LAB", + "requires_signoff": True, + }, + { + "title": "Verify Sensor Responses", + "instructions": ( + "Issue self-test command. Confirm IMU returns valid quaternion, GPS " + "acquires fix within 90 s, altimeter reads ground pressure." + ), + "duration": 8, + "workcenter": "LAB", + "schema": { + "fields": [ + { + "name": "imu_status", + "type": "string", + "label": "IMU self-test result", + "required": True, + }, + { + "name": "gps_fix_count", + "type": "number", + "label": "GPS satellites locked", + "required": True, + }, + { + "name": "altimeter_ground_psi", + "type": "number", + "label": "Ground pressure (mbar)", + "required": True, + }, + ] + }, + }, + { + "title": "Verify Radio TX Test Packet", + "instructions": ( + "Trigger test packet TX. Confirm ground station receives at RSSI " + "greater than -90 dBm at 10 m range." + ), + "duration": 5, + "workcenter": "LAB", + }, + { + "title": "Photograph Completed Assembly", + "instructions": ( + "Take 4 photos: top, bottom, both ends. Include build SN placard " + "in each frame." + ), + "duration": 5, + "workcenter": "LAB", + }, + { + "title": "Final Sign-Off", + "instructions": ( + "Verify connector seating, wire routing, and chafe protection. " + "Avionics lead signs off." + ), + "duration": 10, + "workcenter": "LAB", + "requires_signoff": True, + }, + ], + }, ] - order = 0 - for sn, title, instr, dur, wc_code in _avi_steps: - order += 1 - db.add( - ProcedureStep( - procedure_id=avi.id, - order=order, - step_number=sn, - level=0, - title=title, - instructions=instr, - estimated_duration_minutes=dur, - workcenter_id=wc[wc_code].id, - ) - ) - db.flush() + _add_op_tree(db, avi.id, wc, _avi_ops) # ── 5. Recovery System Pack ───────────────────────────────── rec = MasterProcedure( @@ -1639,204 +2657,1019 @@ def _seed_procedures( db.flush() procs["recovery"] = rec - _rec_steps = [ - ( - "1", - "Inspect Parachutes", - "Unfold main and drogue. Inspect canopy for tears, shroud lines for fraying.", - 10, - "CLEAN", - True, - None, - ), - ( - "2", - "Fold Drogue", - "Z-fold drogue canopy. Bundle shroud lines. Wrap with deployment bag.", - 10, - "CLEAN", - False, - None, - ), - ( - "3", - "Fold Main", - "Accordion-fold main canopy per packing card. Bundle lines. Insert in deployment bag.", - 15, - "CLEAN", - False, - None, - ), - ( - "4", - "Install Ejection Charges", - "Load 2.5g black powder in drogue charge well. Load 4.0g in main charge well. Install e-matches.", - 10, - "CLEAN", - True, - { - "fields": [ - { - "name": "drogue_charge_g", - "type": "number", - "label": "Drogue charge (grams)", - "required": True, + _rec_ops: list[dict[str, Any]] = [ + { + "title": "Parachute Inspection", + "instructions": "Inspect both canopies and lines before any folding.", + "duration": 5, + "workcenter": "CLEAN", + "sub_steps": [ + { + "title": "Unfold Drogue", + "instructions": "Lay drogue (KST-F-0034) flat on clean inspection table. Spread canopy fully.", + "duration": 5, + "workcenter": "CLEAN", + }, + { + "title": "Unfold Main", + "instructions": "Lay main canopy (KST-F-0033) flat on clean inspection table adjacent to drogue.", + "duration": 5, + "workcenter": "CLEAN", + }, + { + "title": "Inspect Canopies for Tears or Burn-Through", + "instructions": ( + "Inspect both canopies under bright light. No tears, burn marks, " + "fabric weakness, or seam separation." + ), + "duration": 10, + "workcenter": "CLEAN", + "requires_signoff": True, + }, + { + "title": "Inspect Shroud Lines for Fraying", + "instructions": ( + "Run gloved hand along every shroud line. No fraying, knots, or " + "abrasion. Verify all lines uniform length." + ), + "duration": 8, + "workcenter": "CLEAN", + "requires_signoff": True, + }, + ], + }, + { + "title": "Drogue Pack", + "instructions": "Z-fold and bag the drogue.", + "duration": 5, + "workcenter": "CLEAN", + "sub_steps": [ + { + "title": "Z-Fold Drogue Canopy", + "instructions": ( + "Z-fold drogue canopy into 6 inch wide bundle per packing card. " + "Maintain symmetry." + ), + "duration": 8, + "workcenter": "CLEAN", + }, + { + "title": "Bundle Shroud Lines", + "instructions": ( + "Daisy-chain shroud lines into a loose bundle on top of folded canopy. " + "Do not knot." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + { + "title": "Insert in Deployment Bag and Close", + "instructions": ( + "Insert bundle into deployment bag. Pull rubber band closure. Verify " + "bridle exits cleanly." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + ], + }, + { + "title": "Main Pack", + "instructions": "Accordion-fold and bag the main chute.", + "duration": 5, + "workcenter": "CLEAN", + "sub_steps": [ + { + "title": "Accordion-Fold Main Canopy", + "instructions": ( + "Accordion-fold main canopy per packing card into 8 inch wide bundle. " + "Compress to deployment bag width." + ), + "duration": 12, + "workcenter": "CLEAN", + }, + { + "title": "Bundle Main Shroud Lines", + "instructions": "Daisy-chain shroud lines on top of folded canopy. Avoid twists.", + "duration": 6, + "workcenter": "CLEAN", + }, + { + "title": "Insert in Deployment Bag and Close", + "instructions": ( + "Slide bundle into deployment bag. Close with bungee. Confirm pilot " + "chute attached." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + ], + }, + { + "title": "Ejection Charge Loading", + "instructions": "Load black powder charges. PYRO CREW ONLY in the area.", + "duration": 5, + "workcenter": "CLEAN", + "sub_steps": [ + { + "title": "Clear Area to Pyro Crew Only", + "instructions": ( + "All non-pyro personnel clear the room. Pyro crew dons PPE: safety " + "glasses, anti-static wrist strap." + ), + "duration": 3, + "workcenter": "CLEAN", + }, + { + "title": "Measure and Load Drogue Charge", + "instructions": ( + "Weigh 2.5 g FFFFg black powder on anti-static scale. Load into drogue " + "charge well. Record actual mass." + ), + "duration": 5, + "workcenter": "CLEAN", + "schema": { + "fields": [ + { + "name": "drogue_charge_g", + "type": "number", + "label": "Drogue charge (grams)", + "required": True, + } + ] }, - { - "name": "main_charge_g", - "type": "number", - "label": "Main charge (grams)", - "required": True, + }, + { + "title": "Measure and Load Main Charge", + "instructions": ( + "Weigh 4.0 g FFFFg black powder. Load into main charge well. Record " + "actual mass." + ), + "duration": 5, + "workcenter": "CLEAN", + "schema": { + "fields": [ + { + "name": "main_charge_g", + "type": "number", + "label": "Main charge (grams)", + "required": True, + } + ] }, - ] - }, - ), - ( - "5", - "Continuity Check", - "Verify continuity on both pyro channels. Record resistance.", - 5, - "CLEAN", - True, - { - "fields": [ - { - "name": "drogue_ohms", - "type": "number", - "label": "Drogue circuit (Ω)", - "required": True, + }, + { + "title": "Install E-Matches in Charge Wells", + "instructions": ( + "Bed e-matches (KST-D-0038) in each charge well. Tape leads outboard " + "along bay wall." + ), + "duration": 5, + "workcenter": "CLEAN", + "requires_signoff": True, + }, + ], + }, + { + "title": "Continuity Verification", + "instructions": "Confirm both pyro channels show in-spec resistance.", + "duration": 5, + "workcenter": "CLEAN", + "sub_steps": [ + { + "title": "Connect Multimeter to Drogue Circuit", + "instructions": "Connect multimeter across drogue pyro terminals. Set to 200 ohm range.", + "duration": 2, + "workcenter": "CLEAN", + }, + { + "title": "Record Drogue Circuit Resistance", + "instructions": "Read and record drogue circuit. Acceptable range 1.0 to 3.5 ohms.", + "duration": 2, + "workcenter": "CLEAN", + "schema": { + "fields": [ + { + "name": "drogue_ohms", + "type": "number", + "label": "Drogue circuit (ohms)", + "required": True, + } + ] }, - { - "name": "main_ohms", - "type": "number", - "label": "Main circuit (Ω)", - "required": True, + }, + { + "title": "Record Main Circuit Resistance", + "instructions": "Connect across main pyro terminals. Read and record. Same acceptable range.", + "duration": 2, + "workcenter": "CLEAN", + "schema": { + "fields": [ + { + "name": "main_ohms", + "type": "number", + "label": "Main circuit (ohms)", + "required": True, + } + ] }, - ] - }, - ), - ( - "6", - "Install Shear Pins", - "Install 3x nylon shear pins per separation joint. Verify coupler alignment.", - 5, - "CLEAN", - False, - None, - ), + }, + { + "title": "Verify Both Channels in Spec", + "instructions": ( + "Pyro lead confirms both circuits are in spec and signs off before " + "closeout." + ), + "duration": 3, + "workcenter": "CLEAN", + "requires_signoff": True, + }, + ], + }, + { + "title": "Shear Pin Installation", + "instructions": "Install separation-joint shear pins and verify coupler engagement.", + "duration": 5, + "workcenter": "CLEAN", + "sub_steps": [ + { + "title": "Install Drogue Joint Shear Pins", + "instructions": ( + "Install 3x nylon shear pins (KST-F-0037) at 120 degree spacing through " + "drogue separation joint. Pins flush with airframe." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + { + "title": "Install Main Joint Shear Pins", + "instructions": ( + "Install 3x nylon shear pins at 120 degree spacing through main " + "separation joint. Pins flush with airframe." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + { + "title": "Verify Coupler Alignment", + "instructions": ( + "Visually verify both couplers are fully engaged with no gaps. Run " + "straight edge across each joint." + ), + "duration": 5, + "workcenter": "CLEAN", + "requires_signoff": True, + }, + ], + }, ] - order = 0 - for sn, title, instr, dur, wc_code, signoff, schema in _rec_steps: - order += 1 - db.add( - ProcedureStep( - procedure_id=rec.id, - order=order, - step_number=sn, - level=0, - title=title, - instructions=instr, - estimated_duration_minutes=dur, - workcenter_id=wc[wc_code].id, - requires_signoff=signoff, - required_data_schema=schema, - ) - ) - db.flush() + _add_op_tree(db, rec.id, wc, _rec_ops) # ── 6. Final Vehicle Integration ──────────────────────────── fvi = MasterProcedure( name="Final Vehicle Integration", - description="Stack all subsystems: propulsion module, avionics bay, recovery bay, nose cone. Install fins and rail buttons.", - procedure_type=ProcedureType.OP, + description=( + "Stack all flight subsystems into complete vehicle. Uses operation/step " + "hierarchy: each operation groups related steps for loose install, final " + "install, alignment, safety wire, and closeout." + ), + procedure_type=ProcedureType.BUILD, status=ProcedureStatus.DRAFT, ) db.add(fvi) db.flush() procs["fvi"] = fvi - _fvi_steps = [ - ( - "1", - "Stage Components", - "Lay out all subassemblies on clean bench. Cross-check inventory.", - 15, - "CLEAN", - ), - ( - "2", - "Install Aft Bulkhead", - "Insert aft bulkhead into airframe tube. Align feedthrough ports. Secure with retaining ring.", - 10, - "CLEAN", - ), - ( - "3", - "Install Engine", - "Slide engine assembly into aft section. Mate to bulkhead flange. Torque mount bolts.", - 15, - "CLEAN", - ), - ( - "4", - "Install Tanks", - "Stack LOX and fuel tanks on thrust structure. Connect feedlines.", - 20, - "CLEAN", - ), - ( - "5", - "Install Forward Bulkhead", - "Seat forward bulkhead. Route recovery harness and vent lines through.", - 10, - "CLEAN", - ), - ( - "6", - "Mate Avionics Bay", - "Slide avionics sled into coupler section. Connect pyro leads and antenna.", - 15, - "CLEAN", - ), - ( - "7", - "Install Recovery Bay", - "Pack parachutes into recovery section. Connect shock cord to U-bolts.", - 10, - "CLEAN", - ), - ("8", "Install Nose Cone", "Seat nose cone on coupler. Install shear pins.", 5, "CLEAN"), - ( - "9", - "Install Fins and Rail Buttons", - "Epoxy 3x fins at 120° spacing. Install 2x rail buttons at CG and CP stations.", - 20, - "SHOP", - ), - ( - "10", - "Final Mass and CG", - "Weigh vehicle. Measure CG location. Verify static margin ≥ 1.5 calibers.", - 10, - "CLEAN", - ), + _fvi_ops: list[dict[str, Any]] = [ + { + "title": "Preparation and Staging", + "instructions": ( + "Set up clean work area. Gather and verify all subassemblies, hardware, " + "and tooling required for integration." + ), + "duration": 5, + "workcenter": "CLEAN", + "sub_steps": [ + { + "title": "Clean Work Surface", + "instructions": ( + "Wipe down integration bench with isopropyl alcohol. Lay out clean " + "ESD-safe mat." + ), + "duration": 10, + "workcenter": "CLEAN", + }, + { + "title": "Stage Subassemblies", + "instructions": ( + "Place the following on bench: engine assembly (KST-F-0001), LOX tank " + "(KST-F-0006), fuel tank (KST-F-0007), avionics sled, recovery bay, " + "forward bulkhead (KST-F-0021), aft bulkhead (KST-F-0022), airframe " + "tube (KST-F-0018), nose cone (KST-F-0019), fin set (KST-F-0020), " + "coupler tubes (KST-F-0023)." + ), + "duration": 15, + "workcenter": "CLEAN", + }, + { + "title": "Verify Subassembly Serial Numbers", + "instructions": ( + "Record serial numbers of all flight subassemblies. Cross-reference " + "against traveler. Confirm all items have passed incoming inspection " + "or prior build procedures." + ), + "duration": 10, + "workcenter": "CLEAN", + "requires_signoff": True, + }, + { + "title": "Inventory Hardware Kit", + "instructions": ( + "Verify kit contents against BOM: 1/4 inch-20 SHCS (qty 16), 10-32 " + "SHCS (qty 12), 1/4 inch-20 hex nuts (qty 16), lock washers (qty 16), " + "rail buttons (qty 2), shear pins (qty 6), U-bolts (qty 4), O-rings " + "-012 (qty 4), O-rings -016 (qty 2). Mark checklist." + ), + "duration": 10, + "workcenter": "CLEAN", + }, + ], + }, + { + "title": "Aft Section — Loose Install", + "instructions": ( + "Loose-fit aft bulkhead, engine assembly, and thrust structure into the aft " + "end of the airframe. All fasteners finger-tight only." + ), + "duration": 5, + "workcenter": "CLEAN", + "sub_steps": [ + { + "title": "Loose-Install Aft Bulkhead", + "instructions": ( + "Insert aft bulkhead (KST-F-0022) into airframe tube. Align " + "feedthrough ports to 0 degree clock position. Install retaining " + "ring finger-tight." + ), + "duration": 10, + "workcenter": "CLEAN", + }, + { + "title": "Loose-Install Engine Assembly", + "instructions": ( + "Slide engine assembly into aft section. Engage mount flange bolts " + "(4x 1/4 inch-20 SHCS with lock washers) through bulkhead, " + "finger-tight only." + ), + "duration": 10, + "workcenter": "CLEAN", + }, + { + "title": "Check Clearances", + "instructions": ( + "Verify igniter leads, nozzle exit, and propellant feed ports are " + "not fouled. Minimum 0.125 inch clearance to airframe ID." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + ], + }, + { + "title": "Aft Section — Final Install and Torque", + "instructions": "Final-torque all aft section fasteners. Apply witness marks.", + "duration": 5, + "workcenter": "CLEAN", + "sub_steps": [ + { + "title": "Torque Aft Bulkhead Retaining Ring", + "instructions": ( + "Torque retaining ring to 60 in-lb using spanner wrench. Verify even " + "contact around full circumference." + ), + "duration": 10, + "workcenter": "CLEAN", + "requires_signoff": True, + }, + { + "title": "Apply Witness Marks", + "instructions": ( + "Apply torque-seal (yellow) to all aft section fastener heads. " + "Photograph from two angles." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + { + "title": "Torque Engine Mount Bolts", + "instructions": ( + "Torque 4x 1/4 inch-20 engine mount bolts in star pattern to 120 " + "in-lb. Apply Loctite 242 per drawing." + ), + "duration": 15, + "workcenter": "CLEAN", + "requires_signoff": True, + "schema": { + "fields": [ + { + "name": f"bolt{i}_torque", + "type": "number", + "label": f"Bolt {i} torque (in-lb)", + "required": True, + } + for i in range(1, 5) + ] + }, + }, + ], + }, + { + "title": "Propellant Tank Installation", + "instructions": ( + "Install LOX and fuel tanks onto thrust structure. Connect feedlines and " + "vent lines." + ), + "duration": 5, + "workcenter": "CLEAN", + "sub_steps": [ + { + "title": "Install LOX Tank", + "instructions": ( + "Lower LOX tank (KST-F-0006) onto thrust structure standoffs. Orient " + "fill port to 0 degree clock position. Install 4x 10-32 SHCS " + "finger-tight." + ), + "duration": 10, + "workcenter": "CLEAN", + }, + { + "title": "Install Fuel Tank", + "instructions": ( + "Stack fuel tank (KST-F-0007) above LOX tank on spacer ring. Orient " + "fill port to 180 degree clock position. Install 4x 10-32 SHCS " + "finger-tight." + ), + "duration": 10, + "workcenter": "CLEAN", + }, + { + "title": "Torque Tank Mount Bolts", + "instructions": ( + "Torque all 8x 10-32 tank mount SHCS to 40 in-lb in alternating pattern." + ), + "duration": 10, + "workcenter": "CLEAN", + "requires_signoff": True, + }, + { + "title": "Connect Propellant Feedlines", + "instructions": ( + "Connect 1/2 inch SS feedlines from tank outlets to engine inlets. " + "Install AN fittings (KST-F-0015). Torque AN-8 fittings to 180 in-lb." + ), + "duration": 15, + "workcenter": "CLEAN", + "requires_signoff": True, + "schema": { + "fields": [ + { + "name": "lox_fitting_torque", + "type": "number", + "label": "LOX feed fitting torque (in-lb)", + "required": True, + }, + { + "name": "fuel_fitting_torque", + "type": "number", + "label": "Fuel feed fitting torque (in-lb)", + "required": True, + }, + ] + }, + }, + { + "title": "Connect Vent Lines", + "instructions": ( + "Route 1/4 inch vent lines from tank ullage ports to forward " + "bulkhead feedthroughs. Install AN-4 fittings. Torque to 80 in-lb." + ), + "duration": 10, + "workcenter": "CLEAN", + }, + ], + }, + { + "title": "Pneumatic Leak Check", + "instructions": ( + "Pressure-test all propellant and pneumatic connections with GN2 before " + "closing out the aft section." + ), + "duration": 5, + "workcenter": "CLEAN", + "sub_steps": [ + { + "title": "Cap Open Ports", + "instructions": ( + "Install test caps on engine inlet ports and vent line " + "terminations. Verify all caps are seated." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + { + "title": "Pressurize to 50 PSI", + "instructions": ( + "Connect GN2 supply via test fixture (KST-G-0005). Slowly pressurize " + "propellant circuit to 50 PSI. Hold 60 seconds." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + { + "title": "Soap-Bubble Inspect All Joints", + "instructions": ( + "Apply Snoop leak detector to every fitting, O-ring face, and " + "feedthrough. No bubbles permitted." + ), + "duration": 15, + "workcenter": "CLEAN", + "requires_signoff": True, + }, + { + "title": "Depressurize and Remove Caps", + "instructions": ( + "Slowly vent to 0 PSI. Remove all test caps. Disconnect GN2 supply." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + ], + }, + { + "title": "Forward Bulkhead and Pressurant Routing", + "instructions": ( + "Install forward bulkhead. Route recovery harness, vent lines, and " + "pressurant feed through bulkhead penetrations." + ), + "duration": 5, + "workcenter": "CLEAN", + "sub_steps": [ + { + "title": "Install Forward Bulkhead O-Rings", + "instructions": ( + "Lubricate 2x -016 O-rings with Krytox grease. Seat into forward " + "bulkhead (KST-F-0021) grooves." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + { + "title": "Seat Forward Bulkhead", + "instructions": ( + "Insert forward bulkhead into airframe tube. Align wire feedthrough " + "to 90 degree clock position. Press until O-rings engage." + ), + "duration": 10, + "workcenter": "CLEAN", + }, + { + "title": "Route Vent Lines Through Bulkhead", + "instructions": ( + "Pass LOX and fuel vent lines through bulkhead AN fittings. Tighten " + "to 80 in-lb." + ), + "duration": 10, + "workcenter": "CLEAN", + }, + { + "title": "Install Bulkhead Retaining Ring", + "instructions": "Install and torque forward bulkhead retaining ring to 60 in-lb.", + "duration": 5, + "workcenter": "CLEAN", + "requires_signoff": True, + }, + { + "title": "Route Recovery Harness", + "instructions": ( + "Thread shock cord (KST-F-0035) through center feedthrough. Leave " + "24 inches of slack on recovery bay side." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + ], + }, + { + "title": "Avionics Bay Integration", + "instructions": "Install avionics sled into coupler section. Make all electrical connections.", + "duration": 5, + "workcenter": "CLEAN", + "sub_steps": [ + { + "title": "Slide Avionics Sled into Coupler", + "instructions": ( + "Insert avionics sled assembly into coupler tube (KST-F-0023). " + "Align mounting rails. Secure with 4x 10-32 SHCS." + ), + "duration": 10, + "workcenter": "CLEAN", + }, + { + "title": "Connect Pyro Leads", + "instructions": ( + "Attach drogue pyro channel leads to forward separation joint " + "e-match terminals. Attach main pyro channel leads to aft separation " + "joint e-match terminals. Verify correct polarity per wiring diagram." + ), + "duration": 10, + "workcenter": "CLEAN", + }, + { + "title": "Connect Telemetry Antenna", + "instructions": ( + "Route antenna coax from RFM95W to SMA bulkhead on coupler. Torque " + "SMA connector to 5 in-lb." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + { + "title": "Connect Umbilical", + "instructions": ( + "Route external umbilical connector (arming plug, charge cable) " + "through coupler access port. Verify connector seats fully." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + { + "title": "Power-On Verification", + "instructions": ( + "Connect battery (KST-F-0030). Verify flight computer boots, GPS " + "acquires, IMU initializes, telemetry transmits test packet to ground " + "station. Verify both pyro channels show continuity." + ), + "duration": 10, + "workcenter": "CLEAN", + "requires_signoff": True, + }, + { + "title": "Power Off and Safe", + "instructions": ( + "Power down flight computer. Disconnect battery. Install arming plug " + "safety cap." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + ], + }, + { + "title": "Recovery Bay Pack", + "instructions": "Pack parachutes into recovery section. Install ejection charges. Connect shock cords.", + "duration": 5, + "workcenter": "CLEAN", + "sub_steps": [ + { + "title": "Attach Shock Cord to Forward U-Bolts", + "instructions": ( + "Tie shock cord to 2x U-bolts (KST-F-0036) on forward bulkhead using " + "double-bowline knot. Apply 50 lbf pull test to each attachment." + ), + "duration": 10, + "workcenter": "CLEAN", + }, + { + "title": "Pack Drogue Parachute", + "instructions": ( + "Place folded drogue (KST-F-0034) in deployment bag. Attach " + "deployment bag to shock cord with lark's head knot. Position drogue " + "at aft end of recovery bay." + ), + "duration": 10, + "workcenter": "CLEAN", + }, + { + "title": "Pack Main Parachute", + "instructions": ( + "Place folded main chute (KST-F-0033) in deployment bag. Attach to " + "shock cord forward of drogue. Tuck deployment bag and shroud lines " + "neatly." + ), + "duration": 10, + "workcenter": "CLEAN", + }, + { + "title": "Install Ejection Charges", + "instructions": ( + "Load 2.5g FFFFg black powder into drogue charge well. Load 4.0g " + "into main charge well. Install e-matches (KST-D-0038) into each " + "well. Tape leads along shock cord." + ), + "duration": 10, + "workcenter": "CLEAN", + "requires_signoff": True, + }, + { + "title": "Verify Charge Continuity", + "instructions": ( + "Using multimeter, verify continuity on both e-match circuits " + "before final close-out." + ), + "duration": 5, + "workcenter": "CLEAN", + "requires_signoff": True, + }, + ], + }, + { + "title": "Section Mating and Shear Pins", + "instructions": "Mate all airframe sections. Install shear pins at separation joints.", + "duration": 5, + "workcenter": "CLEAN", + "sub_steps": [ + { + "title": "Install Aft Separation Joint Shear Pins", + "instructions": ( + "Install 3x nylon shear pins (KST-F-0037) at 120 degree spacing " + "through airframe and coupler at aft separation joint. Pins should " + "sit flush." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + { + "title": "Install Forward Separation Joint Shear Pins", + "instructions": ( + "Install 3x nylon shear pins at 120 degree spacing through recovery " + "tube and coupler at forward separation joint." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + { + "title": "Install Nose Cone", + "instructions": ( + "Seat nose cone (KST-F-0019) onto recovery tube shoulder. Secure " + "with 2x nylon shear pins." + ), + "duration": 5, + "workcenter": "CLEAN", + }, + { + "title": "Verify All Sections Seated", + "instructions": ( + "Visually confirm all coupler joints are fully engaged. No gaps at " + "any joint. Run a straight edge along airframe to check alignment." + ), + "duration": 5, + "workcenter": "CLEAN", + "requires_signoff": True, + }, + { + "title": "Mate Avionics Bay to Propulsion Section", + "instructions": ( + "Slide coupler section into propulsion airframe tube. Align antenna " + "port to 270 degrees clock position. Engage coupler 4 inches into " + "tube." + ), + "duration": 10, + "workcenter": "CLEAN", + }, + { + "title": "Mate Recovery Bay to Avionics Section", + "instructions": ( + "Slide recovery bay airframe onto upper coupler. Engage coupler 4 " + "inches into recovery tube." + ), + "duration": 10, + "workcenter": "CLEAN", + }, + ], + }, + { + "title": "Fin and Rail Button Installation", + "instructions": "Bond fins to airframe. Install rail buttons at CG and CP stations.", + "duration": 5, + "workcenter": "SHOP", + "sub_steps": [ + { + "title": "Mark Fin Locations", + "instructions": ( + "Using fin alignment jig, mark 3x fin root locations at 120 degree " + "spacing on aft airframe tube. Mark leading and trailing edge " + "references." + ), + "duration": 10, + "workcenter": "SHOP", + }, + { + "title": "Surface Prep for Bonding", + "instructions": ( + "Sand fin root edges and airframe bonding areas with 220-grit. " + "Clean with acetone. Allow to dry 10 minutes." + ), + "duration": 15, + "workcenter": "SHOP", + }, + { + "title": "Bond Fins", + "instructions": ( + "Mix 30-minute epoxy per manufacturer instructions. Apply fillet to " + "fin root. Press fin onto airframe in jig. Repeat for all 3 fins. " + "Allow cure per epoxy data sheet (minimum 12 hours)." + ), + "duration": 20, + "workcenter": "SHOP", + "requires_signoff": True, + }, + { + "title": "Apply Fin Fillets", + "instructions": ( + "After initial cure, apply secondary epoxy fillets on both sides of " + "each fin root. Smooth with gloved finger. Allow full cure." + ), + "duration": 15, + "workcenter": "SHOP", + }, + { + "title": "Install Rail Buttons", + "instructions": ( + "Install 2x rail buttons (KST-F-0024) with 8-32 SHCS. Lower button " + "at predicted CG station. Upper button at predicted CP station. " + "Torque to 15 in-lb." + ), + "duration": 10, + "workcenter": "SHOP", + }, + ], + }, + { + "title": "Safety Wire and Lockout", + "instructions": "Install safety wire on all critical fasteners. Verify all locking features are in place.", + "duration": 5, + "workcenter": "CLEAN", + "sub_steps": [ + { + "title": "Safety Wire AN Propellant Fittings", + "instructions": ( + "Install safety wire on LOX and fuel AN feed fittings. Wire to " + "adjacent structure attach point." + ), + "duration": 10, + "workcenter": "CLEAN", + }, + { + "title": "Verify All Lock Washers Compressed", + "instructions": ( + "Inspect every lock washer in aft section. All must show visible " + "compression (flat, no spring gap)." + ), + "duration": 10, + "workcenter": "CLEAN", + }, + { + "title": "Photograph Safety Wire", + "instructions": "Take close-up photos of all safety wire runs. Include in build traveler.", + "duration": 5, + "workcenter": "CLEAN", + "requires_signoff": True, + }, + { + "title": "Safety Wire Engine Mount Bolts", + "instructions": ( + "Install 0.032 inch MS20995C stainless safety wire on engine mount " + "bolt pairs in positive-lock direction. Verify 6 to 8 twists per inch." + ), + "duration": 15, + "workcenter": "CLEAN", + }, + ], + }, + { + "title": "Final Mass Properties and Inspection", + "instructions": ( + "Weigh completed vehicle. Measure CG. Verify stability margin. Perform " + "final visual inspection." + ), + "duration": 5, + "workcenter": "CLEAN", + "sub_steps": [ + { + "title": "Weigh Vehicle", + "instructions": ( + "Place completed vehicle on calibrated scale. Record dry mass. " + "Compare to predicted mass, must be within plus or minus 5%." + ), + "duration": 5, + "workcenter": "CLEAN", + "schema": { + "fields": [ + { + "name": "dry_mass_lb", + "type": "number", + "label": "Dry mass (lb)", + "required": True, + } + ] + }, + }, + { + "title": "Measure CG Location", + "instructions": "Balance vehicle on knife-edge. Mark CG location. Measure from nose tip.", + "duration": 10, + "workcenter": "CLEAN", + "schema": { + "fields": [ + { + "name": "cg_from_nose_in", + "type": "number", + "label": "CG from nose tip (in)", + "required": True, + } + ] + }, + }, + { + "title": "Calculate Static Margin", + "instructions": ( + "Static margin = (CP - CG) / body diameter. Must be at least 1.5 " + "calibers for flight acceptance." + ), + "duration": 5, + "workcenter": "CLEAN", + "requires_signoff": True, + }, + { + "title": "Final Visual Inspection", + "instructions": ( + "Inspect entire vehicle exterior: no dents, cracks, loose fins, " + "protruding fasteners, or FOD. Verify all access ports closed. Check " + "paint/markings per drawing." + ), + "duration": 10, + "workcenter": "CLEAN", + "requires_signoff": True, + }, + { + "title": "Photograph Completed Vehicle", + "instructions": ( + "Take 6 photographs: fore, aft, left, right, top (fins), detail of " + "nose cone. Include scale reference and SN placard in frame." + ), + "duration": 10, + "workcenter": "CLEAN", + }, + ], + }, + { + "title": "Closeout and Documentation", + "instructions": "Complete all build records. Transfer vehicle to storage or launch prep.", + "duration": 5, + "workcenter": "CLEAN", + "sub_steps": [ + { + "title": "Complete Build Traveler", + "instructions": ( + "Fill in all remaining fields on build traveler. Attach all data " + "sheets, photos, and test records. Verify no open items." + ), + "duration": 10, + "workcenter": "CLEAN", + }, + { + "title": "Log All Serial Numbers", + "instructions": ( + "Record serial/lot numbers for all installed components, O-rings, " + "fastener lots, and epoxy batch. Enter into OPAL inventory system." + ), + "duration": 10, + "workcenter": "CLEAN", + }, + { + "title": "Vehicle Acceptance Sign-Off", + "instructions": ( + "Build lead and QA inspector sign off that vehicle is complete, all " + "steps passed, and vehicle is ready for launch operations." + ), + "duration": 5, + "workcenter": "CLEAN", + "requires_signoff": True, + }, + { + "title": "Transfer to Storage", + "instructions": ( + "Install protective nose cone cover and fin guards. Place vehicle " + "in padded transport case. Move to bonded storage area. Update OPAL " + "location." + ), + "duration": 10, + "workcenter": "CLEAN", + }, + ], + }, ] - order = 0 - for sn, title, instr, dur, wc_code in _fvi_steps: - order += 1 - db.add( - ProcedureStep( - procedure_id=fvi.id, - order=order, - step_number=sn, - level=0, - title=title, - instructions=instr, - estimated_duration_minutes=dur, - workcenter_id=wc[wc_code].id, - ) - ) - db.flush() + _add_op_tree(db, fvi.id, wc, _fvi_ops) return procs @@ -1865,20 +3698,37 @@ def _seed_versions_and_executions( ) content = { + "procedure_name": proc.name, + "procedure_description": proc.description, "steps": [ { + "id": s.id, "order": s.order, "step_number": s.step_number, "level": s.level, + "parent_step_id": s.parent_step_id, "title": s.title, "instructions": s.instructions, + "required_data_schema": s.required_data_schema, + "is_contingency": s.is_contingency, "requires_signoff": s.requires_signoff, "estimated_duration_minutes": s.estimated_duration_minutes, - "is_contingency": s.is_contingency, - "required_data_schema": s.required_data_schema, + "workcenter_id": s.workcenter_id, + "depends_on": [], + "step_kit": [], } for s in steps - ] + ], + "kit_items": [ + {"part_id": k.part_id, "quantity_required": float(k.quantity_required)} + for k in db.query(Kit).filter(Kit.procedure_id == proc.id).all() + ], + "output_items": [ + {"part_id": o.part_id, "quantity_produced": float(o.quantity_produced)} + for o in db.query(ProcedureOutput) + .filter(ProcedureOutput.procedure_id == proc.id) + .all() + ], } version = ProcedureVersion( @@ -1918,10 +3768,9 @@ def _seed_versions_and_executions( - timedelta(days=10, hours=3) + timedelta(minutes=s["order"] * 8 + 7), ) - # Add captured data for pressure steps - if s["required_data_schema"] and s["step_number"] == "6": + if s["required_data_schema"] and s["step_number"] == "5.1": se.data_captured = {"proof_pressure": 677, "hold_duration_s": 305} - elif s["required_data_schema"] and s["step_number"] == "7": + elif s["required_data_schema"] and s["step_number"] == "6.2": se.data_captured = { "od_station_1": 6.001, "od_station_2": 6.000, @@ -1941,9 +3790,10 @@ def _seed_versions_and_executions( db.add(inst) db.flush() - # First 5 steps completed, step 6 in progress + # Pre-test planning, engine install, and plumbing complete (orders 1-12). + # On instrumentation hookup (order 13). for s in content["steps"]: - if s["order"] <= 5: + if s["order"] <= 12: se = StepExecution( instance_id=inst.id, step_number=s["order"], @@ -1952,13 +3802,13 @@ def _seed_versions_and_executions( status=StepStatus.SIGNED_OFF if s["requires_signoff"] else StepStatus.COMPLETED, - started_at=now - timedelta(hours=2) + timedelta(minutes=s["order"] * 12), + started_at=now - timedelta(hours=2) + timedelta(minutes=s["order"] * 5), completed_at=now - timedelta(hours=2) - + timedelta(minutes=s["order"] * 12 + 10), + + timedelta(minutes=s["order"] * 5 + 4), ) db.add(se) - elif s["order"] == 6: + elif s["order"] == 13: se = StepExecution( instance_id=inst.id, step_number=s["order"], diff --git a/src/opal/web/routes.py b/src/opal/web/routes.py index 13bdc0a..7b35a31 100644 --- a/src/opal/web/routes.py +++ b/src/opal/web/routes.py @@ -2075,6 +2075,208 @@ def _pick_default_order() -> int | None: return templates.TemplateResponse("executions/detail.html", context) +@router.get("/executions/{instance_id}/report", response_class=HTMLResponse) +async def executions_report(request: Request, db: DbSession, instance_id: int) -> HTMLResponse: + """Standalone, printable build report for a completed work order.""" + import base64 + import io + from datetime import UTC, datetime + + import segno + + from opal.db.models.attachment import Attachment + from opal.db.models.inventory import InventoryProduction + from opal.db.models.procedure import ProcedureOutput + + instance = db.query(ProcedureInstance).filter(ProcedureInstance.id == instance_id).first() + if not instance: + return templates.TemplateResponse( + "errors/404.html", + {"request": request, "message": f"Execution {instance_id} not found"}, + status_code=404, + ) + + inst_status = instance.status.value if hasattr(instance.status, "value") else instance.status + if inst_status != "completed": + return HTMLResponse( + f"

Build report unavailable

" + f"

Work order is currently {inst_status.upper()}. " + f"Reports are only generated for COMPLETED work orders.

" + f'

Back to execution

', + status_code=400, + ) + + version = db.query(ProcedureVersion).filter(ProcedureVersion.id == instance.version_id).first() + procedure = ( + db.query(MasterProcedure).filter(MasterProcedure.id == instance.procedure_id).first() + ) + + # QR back to the execution detail page. + url = f"{request.base_url}executions/{instance_id}" + qr = segno.make(url) + qr_buf = io.BytesIO() + qr.save(qr_buf, kind="svg", scale=3, border=1) + qr_data_uri = "data:image/svg+xml;base64," + base64.b64encode(qr_buf.getvalue()).decode() + + # End items: ProcedureOutput defines what this procedure produces; productions + # are the actual produced units (serial + OPAL number). + output_items = ( + db.query(ProcedureOutput) + .filter(ProcedureOutput.procedure_id == instance.procedure_id) + .all() + ) + productions = ( + db.query(InventoryProduction) + .filter(InventoryProduction.procedure_instance_id == instance_id) + .all() + ) + + # Map step_number -> version step (for human-readable data labels). + version_steps = version.content.get("steps", []) if version else [] + version_steps_by_order = {s["order"]: s for s in version_steps} + + # Sorted step executions for the operations table + data collects. + step_execs = sorted(instance.step_executions, key=lambda se: se.step_number) + + def _se_status(se) -> str: + return se.status.value if hasattr(se.status, "value") else se.status + + ops_summary = [] + for se in step_execs: + vs = version_steps_by_order.get(se.step_number) + title = se.title or (vs.get("title") if vs else None) or "(untitled)" + ops_summary.append( + { + "step_number": se.step_number_str or str(se.step_number), + "title": title, + "status": _se_status(se), + "started_at": se.started_at, + "completed_at": se.completed_at, + "operator": se.completed_by_user.name if se.completed_by_user else None, + "signoff": se.signed_off_by_user.name if se.signed_off_by_user else None, + "signoff_at": se.signed_off_at, + } + ) + + # Data collects: per step, resolve field name -> human label from the schema. + data_collects = [] + for se in step_execs: + if not se.data_captured: + continue + vs = version_steps_by_order.get(se.step_number) + schema = se.required_data_schema or (vs.get("required_data_schema") if vs else None) or {} + field_defs = {f["name"]: f for f in schema.get("fields", []) if "name" in f} + + rows = [] + for field_name, value in se.data_captured.items(): + field_def = field_defs.get(field_name, {}) + label = field_def.get("label") or field_name + unit = field_def.get("unit") + if isinstance(value, bool): + display = "YES" if value else "NO" + elif value is None or value == "": + display = "-" + elif isinstance(value, list): + if value: + display = f"{len(value)} image(s) (" + ", ".join(f"#{v}" for v in value) + ")" + else: + display = "-" + else: + display = str(value) + rows.append({"label": label, "value": display, "unit": unit}) + + if rows: + data_collects.append( + { + "step_number": se.step_number_str or str(se.step_number), + "step_title": se.title or (vs.get("title") if vs else None) or "(untitled)", + "rows": rows, + } + ) + + # Datasets with chart-enabled fields whose points are tied to this WO. + step_exec_ids = {se.id for se in step_execs} + chart_datasets: list[dict] = [] + if step_exec_ids: + points = ( + db.query(DataPoint) + .filter(DataPoint.step_execution_id.in_(step_exec_ids)) + .order_by(DataPoint.recorded_at.asc()) + .all() + ) + points_by_ds: dict[int, list[DataPoint]] = {} + for p in points: + points_by_ds.setdefault(p.dataset_id, []).append(p) + + if points_by_ds: + datasets = ( + db.query(Dataset) + .filter(Dataset.id.in_(points_by_ds.keys()), Dataset.deleted_at.is_(None)) + .all() + ) + for ds in datasets: + fields = (ds.schema or {}).get("fields", []) or [] + chart_fields = [ + f + for f in fields + if f.get("chart") is True and f.get("type") == "number" and f.get("name") + ] + if not chart_fields: + continue + points_for_ds = points_by_ds.get(ds.id, []) + points_json = [ + {"recorded_at": p.recorded_at.isoformat(), "values": p.values} + for p in points_for_ds + ] + chart_datasets.append( + { + "dataset": ds, + "chart_fields": chart_fields, + "all_fields": fields, + "points": points_for_ds, + "points_json": points_json, + } + ) + + # Linked issues on this WO. + issues = ( + db.query(Issue) + .filter(Issue.procedure_instance_id == instance.id, Issue.deleted_at.is_(None)) + .order_by(Issue.created_at.asc()) + .all() + ) + + # Closeout photos only. + closeout_attachments = ( + db.query(Attachment) + .filter( + Attachment.procedure_instance_id == instance.id, + Attachment.kind == "closeout", + ) + .order_by(Attachment.created_at.asc()) + .all() + ) + + return templates.TemplateResponse( + "executions/report.html", + { + "request": request, + "instance": instance, + "version": version, + "procedure": procedure, + "qr_data_uri": qr_data_uri, + "output_items": output_items, + "productions": productions, + "ops_summary": ops_summary, + "data_collects": data_collects, + "chart_datasets": chart_datasets, + "issues": issues, + "closeout_attachments": closeout_attachments, + "generated_at": datetime.now(UTC), + }, + ) + + # ============ ISSUES ============ diff --git a/src/opal/web/static/css/main.css b/src/opal/web/static/css/main.css index a718fde..e8bc5ac 100644 --- a/src/opal/web/static/css/main.css +++ b/src/opal/web/static/css/main.css @@ -1967,23 +1967,32 @@ textarea.md-image-target.md-drop-target { color: var(--text-muted); } -/* Light theme */ +/* Light theme — Catppuccin Latte (https://catppuccin.com/palette). + * Page bg uses Mantle so cards (bg-secondary = Base) read as lighter/elevated, + * preserving the existing OPAL pattern where cards "rise" out of the page. */ :root[data-theme="light"] { - --bg-primary: #f5f5f5; - --bg-secondary: #ffffff; - --bg-tertiary: #e8e8e8; - --border-color: #ccc; - --border-light: #ddd; - - --text-primary: #1a1a1a; - --text-secondary: #555; - --text-muted: #999; - - --accent-blue: #2563eb; - --accent-green: #16a34a; - --accent-yellow: #d97706; - --accent-red: #dc2626; - --accent-orange: #c2410c; + --bg-primary: #e6e9ef; /* Mantle — page background */ + --bg-secondary: #eff1f5; /* Base — cards, panels */ + --bg-tertiary: #dce0e8; /* Crust — insets, table headers, buttons */ + --border-color: #bcc0cc; /* Surface1 — visible borders */ + --border-light: #ccd0da; /* Surface0 — subtle borders */ + + --text-primary: #4c4f69; /* Text */ + --text-secondary: #5c5f77; /* Subtext1 */ + --text-muted: #8c8fa1; /* Overlay1 */ + + --accent-blue: #1e66f5; /* Blue */ + --accent-green: #40a02b; /* Green */ + --accent-yellow: #df8e1d; /* Yellow */ + --accent-red: #d20f39; /* Red */ + --accent-orange: #fe640b; /* Peach */ + + /* Diff tints retinted for the Latte accents so they still look like + * the right hue against a light background. */ + --diff-added-bg: rgba(64, 160, 43, 0.12); + --diff-removed-bg: rgba(210, 15, 57, 0.10); + --diff-modified-bg: rgba(223, 142, 29, 0.14); + --diff-changed-bg: rgba(223, 142, 29, 0.20); } :root[data-theme="light"] ::selection { diff --git a/src/opal/web/templates/datasets/detail.html b/src/opal/web/templates/datasets/detail.html index dc0e90d..d340726 100644 --- a/src/opal/web/templates/datasets/detail.html +++ b/src/opal/web/templates/datasets/detail.html @@ -34,6 +34,7 @@ {{ ok.th("NAME") }} {{ ok.th("TYPE") }} {{ ok.th("UNIT") }} + {{ ok.th("CHART", width="60px") }} @@ -42,6 +43,7 @@ {{ field.name }} {{ ok.mono(field.type) }} {{ ok.mono(field.unit if field.unit else '-') }} + {{ ok.mono("YES" if field.chart else "-") }} {% endfor %} diff --git a/src/opal/web/templates/datasets/new.html b/src/opal/web/templates/datasets/new.html index b205456..12ace9b 100644 --- a/src/opal/web/templates/datasets/new.html +++ b/src/opal/web/templates/datasets/new.html @@ -26,7 +26,7 @@
-
+
+ {{ ok.btn("X", attrs='type="button" onclick="removeField(this)" style="padding: 0 var(--space-sm);"') }}
@@ -50,7 +54,7 @@ function addField() { const container = document.getElementById('schema-fields'); const fieldHtml = ` -
+
+
`; @@ -75,9 +83,11 @@ const name = row.querySelector('.field-name').value.trim(); const type = row.querySelector('.field-type').value; const unit = row.querySelector('.field-unit').value.trim(); + const chartBox = row.querySelector('.field-chart'); if (name) { const field = { name, type }; if (unit) field.unit = unit; + if (chartBox && chartBox.checked && type === 'number') field.chart = true; fields.push(field); } }); diff --git a/src/opal/web/templates/executions/detail.html b/src/opal/web/templates/executions/detail.html index 6c4669d..5c60a71 100644 --- a/src/opal/web/templates/executions/detail.html +++ b/src/opal/web/templates/executions/detail.html @@ -799,10 +799,13 @@ if (!container || !countEl) return; countEl.textContent = attachments.length; if (attachments.length === 0) { container.innerHTML = 'No attachments'; return; } - let html = ''; + let html = '
FILENAMESIZETYPEUPLOADED
'; for (const att of attachments) { const sizeKb = (att.size_bytes / 1024).toFixed(1); - html += ``; + const isCloseout = att.kind === 'closeout'; + const closeoutLabel = isCloseout ? 'YES' : '-'; + const closeoutAction = isCloseout ? 'unset' : 'set'; + html += ``; } html += '
FILENAMESIZETYPECLOSEOUTUPLOADED
${escapeHtml(att.original_filename)}${sizeKb} KB${escapeHtml(att.mime_type)}${att.created_at.replace('T', ' ').substring(0, 19)}Z
${escapeHtml(att.original_filename)}${sizeKb} KB${escapeHtml(att.mime_type)}${att.created_at.replace('T', ' ').substring(0, 19)}Z
'; container.innerHTML = html; @@ -811,19 +814,40 @@ async function uploadAttachment() { const fileInput = document.getElementById('attachment-file'); if (!fileInput.files.length) { alert('Please select a file'); return; } + const closeoutBox = document.getElementById('attachment-closeout'); const formData = new FormData(); formData.append('file', fileInput.files[0]); formData.append('procedure_instance_id', instanceId); + if (closeoutBox && closeoutBox.checked) { + formData.append('kind', 'closeout'); + } try { const headers = {}; const userId = localStorage.getItem('opal_user_id'); if (userId) headers['X-User-Id'] = userId; const response = await fetch('/api/attachments/upload', { method: 'POST', headers, body: formData }); - if (response.ok) { fileInput.value = ''; loadAttachments(); } + if (response.ok) { + fileInput.value = ''; + if (closeoutBox) closeoutBox.checked = false; + loadAttachments(); + } else { const error = await response.json(); alert('Error: ' + (error.detail || 'Failed to upload file')); } } catch (e) { alert('Network error: ' + e.message); } } +async function toggleCloseout(attachmentId, action) { + const newKind = action === 'set' ? 'closeout' : null; + try { + const response = await fetch(`/api/attachments/${attachmentId}`, { + method: 'PATCH', + headers: getHeaders(), + body: JSON.stringify({ kind: newKind }), + }); + if (response.ok) { loadAttachments(); } + else { const error = await response.json(); alert('Error: ' + (error.detail || 'Failed to update attachment')); } + } catch (e) { alert('Network error: ' + e.message); } +} + async function deleteAttachment(attachmentId) { if (!confirm('Delete this attachment?')) return; try { diff --git a/src/opal/web/templates/executions/report.html b/src/opal/web/templates/executions/report.html new file mode 100644 index 0000000..370c41c --- /dev/null +++ b/src/opal/web/templates/executions/report.html @@ -0,0 +1,477 @@ + + + + + BUILD REPORT - {{ instance.work_order_number or ('Execution #' ~ instance.id) }} + + + + +
+
+

BUILD REPORT

+ + + + + + + + + + + + + + + + + + + + + + + +
Work Order:{{ instance.work_order_number or ('#' ~ instance.id) }}Status:{{ instance.status.value.upper() if instance.status.value is defined else instance.status }}
Procedure:{{ procedure.name if procedure else '-' }}{% if version %} v{{ version.version_number }}{% endif %}Started:{{ instance.started_at.strftime('%Y-%m-%dT%H:%M:%SZ') if instance.started_at else '-' }}
Operator:{{ instance.started_by_user.name if instance.started_by_user else '-' }}Completed:{{ instance.completed_at.strftime('%Y-%m-%dT%H:%M:%SZ') if instance.completed_at else '-' }}
Generated:{{ generated_at.strftime('%Y-%m-%dT%H:%M:%SZ') }}
+
+
+
QR Code
+
OPAL BUILD REPORT
+
+
+ + {# ============ END ITEMS ============ #} +

END ITEM

+ {% if productions or output_items %} + + + + + + + + + + + + + {% for p in productions %} + + + + + + + + + {% endfor %} + {% if not productions %} + {% for o in output_items %} + + + + + + + + + {% endfor %} + {% endif %} + +
PARTINTERNAL PNOPAL #SERIALQTYSTATUS
{{ p.inventory_record.part.name if p.inventory_record and p.inventory_record.part else '-' }}{{ p.inventory_record.part.internal_pn if p.inventory_record and p.inventory_record.part else '-' }}{{ p.produced_opal_number or '-' }}{{ p.serial_number or '-' }}{{ p.quantity }}{{ p.status.value.upper() if p.status.value is defined else p.status }}
{{ o.part.name if o.part else '-' }}{{ o.part.internal_pn if o.part else '-' }}--{{ o.quantity_produced }}PLANNED
+ {% else %} +
No end items produced by this work order.
+ {% endif %} + + {# ============ OPERATIONS SUMMARY ============ #} +

OPERATIONS SUMMARY

+ {% if ops_summary %} + + + + + + + + + + + + + + {% for op in ops_summary %} + + + + + + + + + + {% endfor %} + +
STEPTITLESTATUSSTARTEDCOMPLETEDOPERATORSIGNOFF
{{ op.step_number }}{{ op.title }}{{ op.status.upper().replace('_', ' ') }}{{ op.started_at.strftime('%Y-%m-%dT%H:%M:%SZ') if op.started_at else '-' }}{{ op.completed_at.strftime('%Y-%m-%dT%H:%M:%SZ') if op.completed_at else '-' }}{{ op.operator or '-' }}{{ op.signoff or '-' }}
+ {% else %} +
No operations recorded.
+ {% endif %} + + {# ============ DATA COLLECTS ============ #} +

DATA COLLECTS

+ {% if data_collects %} + {% for dc in data_collects %} +
+
+ {{ dc.step_number }} + {{ dc.step_title }} +
+
+ + + + + + + + + + {% for row in dc.rows %} + + + + + + {% endfor %} + +
FIELDVALUEUNIT
{{ row.label }}{{ row.value }}{{ row.unit or '-' }}
+
+
+ {% endfor %} + {% else %} +
No data was captured during this work order.
+ {% endif %} + + {# ============ DATASET CHARTS ============ #} +

DATASET CHARTS

+ {% if chart_datasets %} + {% for cds in chart_datasets %} + {% for field in cds.chart_fields %} +
+

{{ cds.dataset.name }} - {{ field.label or field.name }}{% if field.unit %} ({{ field.unit }}){% endif %}

+
+ +
+ + + + + {% for f in cds.all_fields %} + + {% endfor %} + + + + {% for p in cds.points %} + + + {% for f in cds.all_fields %} + + {% endfor %} + + {% endfor %} + +
RECORDED{{ (f.label or f.name) | upper }}{% if f.unit %} ({{ f.unit }}){% endif %}
{{ p.recorded_at.strftime('%Y-%m-%dT%H:%M:%SZ') }}{{ p.values.get(f.name, '-') }}
+
+ {% endfor %} + {% endfor %} + {% else %} +
No dataset fields are enabled for charting on this work order's data.
+ {% endif %} + + {# ============ ISSUE TICKETS ============ #} +

ISSUE TICKETS

+ {% if issues %} + + + + + + + + + + + + + + + {% for iss in issues %} + + + + + + + + + + + {% endfor %} + +
ISSUE #TYPEPRIORITYSTATUSTITLEROOT CAUSEDISPOSITIONOPENED
{{ iss.issue_number }}{{ (iss.issue_type.value if iss.issue_type.value is defined else iss.issue_type).upper().replace('_', ' ') }}{{ (iss.priority.value if iss.priority.value is defined else iss.priority).upper() }}{{ (iss.status.value if iss.status.value is defined else iss.status).upper().replace('_', ' ') }}{{ iss.title }}{{ (iss.root_cause or '-')[:200] }}{% if iss.root_cause and iss.root_cause|length > 200 %}...{% endif %}{{ (iss.disposition_type.value if iss.disposition_type and iss.disposition_type.value is defined else (iss.disposition_type or '-')) | upper | replace('_', ' ') }}{{ iss.created_at.strftime('%Y-%m-%dT%H:%M:%SZ') if iss.created_at else '-' }}
+ {% else %} +
No issues were logged against this work order.
+ {% endif %} + + {# ============ CLOSEOUT PHOTOS ============ #} +

CLOSEOUT PHOTOS

+ {% if closeout_attachments %} +
+ {% for att in closeout_attachments %} +
+ {{ att.original_filename }} +
+ {{ att.original_filename }}
+ {{ att.created_at.strftime('%Y-%m-%dT%H:%M:%SZ') if att.created_at else '' }} +
+
+ {% endfor %} +
+ {% else %} +
No closeout photos attached. Photos can be marked closeout from the execution detail page.
+ {% endif %} + + {# ============ FOOTER SIGNATURES ============ #} + + + + + diff --git a/src/opal/web/templates/executions/tabs/meta.html b/src/opal/web/templates/executions/tabs/meta.html index ab631a6..c7678c5 100644 --- a/src/opal/web/templates/executions/tabs/meta.html +++ b/src/opal/web/templates/executions/tabs/meta.html @@ -1,5 +1,13 @@
-{% call ok.panel(instance.work_order_number or "EXECUTION #" ~ instance.id, actions=ok.status(inst_status | upper | replace('_', ' '))) %} +{% set instance_actions %} + {{ ok.status(inst_status | upper | replace('_', ' ')) }} + {% if inst_status == 'completed' %} + {{ ok.btn("BUILD REPORT", href="/executions/" ~ instance.id ~ "/report", attrs='target="_blank" rel="noopener"') }} + {% else %} + {{ ok.btn("BUILD REPORT", attrs='disabled title="Available once the work order is COMPLETED"') }} + {% endif %} +{% endset %} +{% call ok.panel(instance.work_order_number or "EXECUTION #" ~ instance.id, actions=instance_actions) %} {% call ok.table() %} {{ ok.detail_row("PROCEDURE", ok.link(instance.procedure.name, "/procedures/" ~ instance.procedure_id), th_width="160px") }} {{ ok.detail_row("VERSION", ok.mono("v" ~ version.version_number) if version else "-", th_width="160px") }} @@ -92,9 +100,13 @@
- {% if inst_status in ['pending', 'in_progress'] %} -
- + {% if inst_status in ['pending', 'in_progress', 'completed'] %} +
+ + {{ ok.btn("UPLOAD", variant="primary", attrs='onclick="uploadAttachment()"') }}
{% endif %} diff --git a/tests/unit/test_executions_report.py b/tests/unit/test_executions_report.py new file mode 100644 index 0000000..900872e --- /dev/null +++ b/tests/unit/test_executions_report.py @@ -0,0 +1,137 @@ +"""Smoke tests for the BUILD REPORT route at /executions/{id}/report.""" + +from datetime import UTC, datetime + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from opal.db.models import User +from opal.db.models.attachment import Attachment +from opal.db.models.dataset import DataPoint, Dataset +from opal.db.models.execution import InstanceStatus, ProcedureInstance + + +@pytest.fixture +def web_client(client: TestClient, test_user: User) -> TestClient: + """TestClient pre-authenticated with the cookie the auth middleware expects.""" + client.cookies.set("opal_user_id", str(test_user.id)) + return client + + +def _create_completed_instance(client: TestClient, db_session: Session) -> ProcedureInstance: + """Create a published procedure + an instance, force status to COMPLETED.""" + proc_resp = client.post("/api/procedures", json={"name": "Build Report Proc"}) + assert proc_resp.status_code in (200, 201), proc_resp.text + proc_id = proc_resp.json()["id"] + + client.post(f"/api/procedures/{proc_id}/steps", json={"title": "Solder"}) + client.post(f"/api/procedures/{proc_id}/publish") + + inst_resp = client.post("/api/procedure-instances", json={"procedure_id": proc_id}) + assert inst_resp.status_code == 201, inst_resp.text + instance_id = inst_resp.json()["id"] + + instance = db_session.query(ProcedureInstance).filter(ProcedureInstance.id == instance_id).one() + instance.status = InstanceStatus.COMPLETED + instance.started_at = datetime.now(UTC) + instance.completed_at = datetime.now(UTC) + db_session.commit() + db_session.refresh(instance) + return instance + + +def test_report_renders_when_completed(web_client: TestClient, db_session: Session): + instance = _create_completed_instance(web_client, db_session) + resp = web_client.get(f"/executions/{instance.id}/report") + assert resp.status_code == 200 + body = resp.text + assert "BUILD REPORT" in body + assert "OPERATIONS SUMMARY" in body + assert "CLOSEOUT PHOTOS" in body + + +def test_report_rejects_when_not_completed(web_client: TestClient, db_session: Session): + proc_resp = web_client.post("/api/procedures", json={"name": "Pending Proc"}) + proc_id = proc_resp.json()["id"] + web_client.post(f"/api/procedures/{proc_id}/steps", json={"title": "Step 1"}) + web_client.post(f"/api/procedures/{proc_id}/publish") + inst_resp = web_client.post("/api/procedure-instances", json={"procedure_id": proc_id}) + instance_id = inst_resp.json()["id"] + # Status stays PENDING - route should refuse. + resp = web_client.get(f"/executions/{instance_id}/report") + assert resp.status_code == 400 + assert "PENDING" in resp.text or "COMPLETED" in resp.text + + +def test_report_includes_only_closeout_photos(web_client: TestClient, db_session: Session): + instance = _create_completed_instance(web_client, db_session) + + closeout = Attachment( + original_filename="closeout_shot.jpg", + stored_filename="closeout-uuid.jpg", + mime_type="image/jpeg", + size_bytes=1234, + kind="closeout", + procedure_instance_id=instance.id, + ) + plain = Attachment( + original_filename="random_note.txt", + stored_filename="plain-uuid.txt", + mime_type="text/plain", + size_bytes=10, + kind=None, + procedure_instance_id=instance.id, + ) + db_session.add_all([closeout, plain]) + db_session.commit() + db_session.refresh(closeout) + db_session.refresh(plain) + + resp = web_client.get(f"/executions/{instance.id}/report") + assert resp.status_code == 200 + body = resp.text + assert f"/api/attachments/{closeout.id}/download" in body + assert f"/api/attachments/{plain.id}/download" not in body + + +def test_report_charts_only_fields_marked_chart_true(web_client: TestClient, db_session: Session): + instance = _create_completed_instance(web_client, db_session) + + # Grab a real step execution to anchor data points to this WO. + db_session.refresh(instance) + step_exec = instance.step_executions[0] + + dataset = Dataset( + name="Build Readings", + schema={ + "fields": [ + {"name": "pressure", "type": "number", "unit": "PSI", "chart": True}, + {"name": "temperature", "type": "number", "unit": "C"}, + {"name": "notes", "type": "text", "chart": True}, + ] + }, + ) + db_session.add(dataset) + db_session.flush() + + point = DataPoint( + dataset_id=dataset.id, + recorded_at=datetime.now(UTC), + values={"pressure": 120, "temperature": 22, "notes": "ok"}, + step_execution_id=step_exec.id, + ) + db_session.add(point) + db_session.commit() + + resp = web_client.get(f"/executions/{instance.id}/report") + assert resp.status_code == 200 + body = resp.text + + # The pressure field is numeric + chart:true → a canvas is rendered. + assert f'id="chart-{dataset.id}-0"' in body + # Only one chart canvas for this dataset: temperature lacks chart:true, + # and notes is non-numeric so it must be excluded even though chart:true. + assert f'id="chart-{dataset.id}-1"' not in body + # And the field name "pressure" should appear in the data-field attribute. + assert 'data-field="pressure"' in body or "data-field='pressure'" in body diff --git a/uv.lock b/uv.lock index e5d7d1b..84a8254 100644 --- a/uv.lock +++ b/uv.lock @@ -65,6 +65,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "certifi" version = "2025.11.12" @@ -74,6 +83,76 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -187,6 +266,65 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, + { url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -344,6 +482,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -374,6 +521,33 @@ 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" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "linkify-it-py" version = "2.0.3" @@ -501,6 +675,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mcp" +version = "1.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/83/d1efe7c2980d8a3afa476f4e3d42d53dd54c0ab94c27bee5d755b45c8b73/mcp-1.27.1.tar.gz", hash = "sha256:0f47e1820f8f8f941466b39749eb1d1839a04caddca2bc60e9d46e8a99914924", size = 608458, upload-time = "2026-05-08T16:50:12.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/73/42d9596facebdb533b7f0b86c1b0364ef350d1f8ba78b1052e8a58b48b65/mcp-1.27.1-py3-none-any.whl", hash = "sha256:1af3c4203b329430fde7a87b4fcb6392a041f5cb851fd68fc674016ab4e7c06f", size = 216260, upload-time = "2026-05-08T16:50:10.547Z" }, +] + [[package]] name = "mdit-py-plugins" version = "0.5.0" @@ -532,6 +731,7 @@ dependencies = [ { name = "fastapi" }, { name = "httpx" }, { name = "jinja2" }, + { name = "mcp" }, { name = "packaging" }, { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, @@ -566,6 +766,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.115.0" }, { name = "httpx", specifier = ">=0.28.0" }, { name = "jinja2", specifier = ">=3.1.4" }, + { name = "mcp", specifier = ">=1.0" }, { name = "packaging", specifier = ">=24.0" }, { name = "playwright", marker = "extra == 'e2e'", specifier = ">=1.49.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.10.0" }, @@ -639,6 +840,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -832,6 +1042,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/69/12bafee3cc485d977f596e0d803d7c6fb147430fc35dfe505730aa3a28dd/pyinstaller_hooks_contrib-2026.1-py3-none-any.whl", hash = "sha256:66ad4888ba67de6f3cfd7ef554f9dd1a4389e2eb19f84d7129a5a6818e3f2180", size = 452841, upload-time = "2026-02-18T13:01:14.471Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -893,6 +1117,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" }, ] +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -957,6 +1200,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "rich" version = "14.2.0" @@ -970,6 +1227,114 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, ] +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + [[package]] name = "ruff" version = "0.14.10" @@ -1056,6 +1421,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, +] + [[package]] name = "starlette" version = "0.50.0" From 07b0194e0faf6574d81461a5b0938a9fb850b594 Mon Sep 17 00:00:00 2001 From: Abby Bigaouette Date: Tue, 26 May 2026 01:32:11 -0700 Subject: [PATCH 2/2] Onshape settings UI: enable integration without editing .env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an admin-only /settings/onshape/configure form so Onshape API credentials can be entered, edited, and tested from the web UI. - New AppSetting model + migration for runtime-mutable key/value config that survives restarts and overrides env values. - apply_db_overlay() rebuilds the runtime Settings from app_setting rows; called at server lifespan startup and after each save. - OnshapeClient.get_session_info() backs the TEST CONNECTION button; bad credentials surface as a friendly "Authentication failed" banner instead of a raw JSON decode error. - Settings index now always shows the ONSHAPE INTEGRATION panel — CONFIGURE button when disabled, EDIT alongside PULL/PUSH when enabled. Form follows the standard OPAL panel + form_actions style and reuses ok.status / var(--accent-*) for messaging. - Replace broken .alert classes in onshape_documents.html with the standard inline-color flash style used elsewhere. - Bump to 1.3.2. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../8a5444d1e3e6_add_app_setting_table.py | 36 +++++ pyproject.toml | 2 +- src/opal/api/app.py | 10 ++ src/opal/config.py | 72 ++++++++++ src/opal/db/models/__init__.py | 2 + src/opal/db/models/app_setting.py | 27 ++++ src/opal/integrations/onshape/client.py | 4 + src/opal/web/routes.py | 127 ++++++++++++++++++ .../settings/_onshape_test_banner.html | 7 + src/opal/web/templates/settings/index.html | 11 ++ .../templates/settings/onshape_configure.html | 107 +++++++++++++++ .../templates/settings/onshape_documents.html | 4 +- uv.lock | 2 +- 13 files changed, 407 insertions(+), 4 deletions(-) create mode 100644 migrations/versions/8a5444d1e3e6_add_app_setting_table.py create mode 100644 src/opal/db/models/app_setting.py create mode 100644 src/opal/web/templates/settings/_onshape_test_banner.html create mode 100644 src/opal/web/templates/settings/onshape_configure.html diff --git a/migrations/versions/8a5444d1e3e6_add_app_setting_table.py b/migrations/versions/8a5444d1e3e6_add_app_setting_table.py new file mode 100644 index 0000000..e439f92 --- /dev/null +++ b/migrations/versions/8a5444d1e3e6_add_app_setting_table.py @@ -0,0 +1,36 @@ +"""add app_setting table + +Revision ID: 8a5444d1e3e6 +Revises: 0c42f39a04cc +Create Date: 2026-05-26 01:09:23.839749 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '8a5444d1e3e6' +down_revision: Union[str, None] = '0c42f39a04cc' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('app_setting', + sa.Column('key', sa.String(length=64), nullable=False), + sa.Column('value', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('key') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('app_setting') + # ### end Alembic commands ### diff --git a/pyproject.toml b/pyproject.toml index 77df2d9..b906b8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "opal" -version = "1.3.0" +version = "1.3.2" description = "Operations, Procedures, Assets, Logistics - ERP for small teams and hardware projects" readme = "README.md" requires-python = ">=3.11" diff --git a/src/opal/api/app.py b/src/opal/api/app.py index c8edbe7..97ce02c 100644 --- a/src/opal/api/app.py +++ b/src/opal/api/app.py @@ -30,6 +30,16 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: settings = get_settings() settings.ensure_directories() + # Overlay DB-stored AppSetting values (Onshape credentials edited via + # /settings/onshape) on top of env-loaded settings before we read any + # integration config. + from opal.config import apply_db_overlay, get_active_settings + from opal.db.base import SessionLocal + + with contextlib.suppress(Exception), SessionLocal() as _db: + apply_db_overlay(_db) + settings = get_active_settings() + # Start Onshape polling if enabled polling_task: asyncio.Task | None = None if settings.onshape_enabled and settings.onshape_poll_interval_minutes > 0: diff --git a/src/opal/config.py b/src/opal/config.py index 030952d..2d68c53 100644 --- a/src/opal/config.py +++ b/src/opal/config.py @@ -10,6 +10,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict if TYPE_CHECKING: + from sqlalchemy.orm import Session + from opal.project import ProjectConfig @@ -226,3 +228,73 @@ def get_active_settings() -> Settings: def get_active_project() -> "ProjectConfig | None": """Get the currently active project configuration.""" return _active_project + + +# Subset of Settings fields editable from the in-app settings UI. The DB +# overlay only touches these; everything else stays env-driven. +_DB_OVERLAY_FIELDS: tuple[str, ...] = ( + "onshape_access_key", + "onshape_secret_key", + "onshape_base_url", + "onshape_poll_interval_minutes", + "onshape_webhook_secret", +) + + +def apply_db_overlay(db: "Session") -> Settings: + """Overlay DB-stored AppSetting values onto the active Settings. + + Reads every row from the ``app_setting`` table and, for each key listed + in :data:`_DB_OVERLAY_FIELDS`, replaces the matching field on the active + Settings instance. Rebuilds ``_runtime_settings`` so future + ``get_active_settings()`` callers see the overlay. Idempotent — safe to + call after any settings edit. + """ + global _runtime_settings + + from opal.db.models.app_setting import AppSetting + + base = get_active_settings() + overrides: dict[str, object] = {} + rows = db.query(AppSetting).filter(AppSetting.key.in_(_DB_OVERLAY_FIELDS)).all() + for row in rows: + if row.value is None: + continue + field = Settings.model_fields.get(row.key) + if field is None: + continue + # Coerce text → field type. int is the only non-str we currently overlay. + if field.annotation is int: + try: + overrides[row.key] = int(row.value) + except ValueError: + continue + else: + overrides[row.key] = row.value + + if not overrides: + return base + + merged = base.model_dump() + merged.update(overrides) + _runtime_settings = Settings(**merged) + return _runtime_settings + + +def set_app_setting(db: "Session", key: str, value: str | None) -> None: + """Upsert a single AppSetting row. Caller commits.""" + from opal.db.models.app_setting import AppSetting + + row = db.query(AppSetting).filter(AppSetting.key == key).first() + if row is None: + row = AppSetting(key=key, value=value) + db.add(row) + else: + row.value = value + + +def get_app_setting(db: "Session", key: str) -> str | None: + from opal.db.models.app_setting import AppSetting + + row = db.query(AppSetting).filter(AppSetting.key == key).first() + return row.value if row else None diff --git a/src/opal/db/models/__init__.py b/src/opal/db/models/__init__.py index ef381ce..60fb235 100644 --- a/src/opal/db/models/__init__.py +++ b/src/opal/db/models/__init__.py @@ -1,5 +1,6 @@ """Database models.""" +from opal.db.models.app_setting import AppSetting from opal.db.models.attachment import Attachment from opal.db.models.audit import AuditLog from opal.db.models.dataset import DataPoint, Dataset @@ -35,6 +36,7 @@ from opal.db.models.workcenter import Workcenter __all__ = [ + "AppSetting", "AssemblyComponent", "Attachment", "AuditLog", diff --git a/src/opal/db/models/app_setting.py b/src/opal/db/models/app_setting.py new file mode 100644 index 0000000..b8f4cf8 --- /dev/null +++ b/src/opal/db/models/app_setting.py @@ -0,0 +1,27 @@ +"""Runtime-mutable application settings stored in the database. + +Provides a key/value escape hatch for config that needs to be edited from +the UI without touching .env. The Settings class still loads env defaults; +values present in this table override them. +""" + +from sqlalchemy import String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from opal.db.base import Base, TimestampMixin + + +class AppSetting(Base, TimestampMixin): + """A single runtime-mutable application setting. + + `key` is the canonical name (e.g. ``onshape_access_key``). `value` is + stored as text; consumers cast as needed. + """ + + __tablename__ = "app_setting" + + key: Mapped[str] = mapped_column(String(64), primary_key=True) + value: Mapped[str | None] = mapped_column(Text, nullable=True) + + def __repr__(self) -> str: + return f"" diff --git a/src/opal/integrations/onshape/client.py b/src/opal/integrations/onshape/client.py index d5e88b2..2317409 100644 --- a/src/opal/integrations/onshape/client.py +++ b/src/opal/integrations/onshape/client.py @@ -383,6 +383,10 @@ def _request( # ── API Methods ─────────────────────────────────────────────────── + def get_session_info(self) -> dict: + """Return current API session info. Use as a credential smoke-test.""" + return self._request("GET", "/api/v6/users/sessioninfo") + def get_document(self, document_id: str) -> OnshapeDocument: """Get document metadata.""" data = self._request("GET", f"/api/v6/documents/{document_id}") diff --git a/src/opal/web/routes.py b/src/opal/web/routes.py index 7b35a31..fc9bc8c 100644 --- a/src/opal/web/routes.py +++ b/src/opal/web/routes.py @@ -3078,6 +3078,133 @@ async def settings_page(request: Request, db: DbSession) -> HTMLResponse: return templates.TemplateResponse("settings/index.html", context) +def _onshape_form_context(request: Request, db: DbSession) -> dict[str, Any]: + """Build the template context for the Onshape configure form.""" + from opal.config import get_active_settings + + s = get_active_settings() + context = get_base_context(request, db, "Onshape - OPAL") + context["onshape_enabled"] = s.onshape_enabled + context["form"] = { + "access_key": s.onshape_access_key, + "base_url": s.onshape_base_url, + "poll_interval_minutes": s.onshape_poll_interval_minutes, + "has_secret_key": bool(s.onshape_secret_key), + "has_webhook_secret": bool(s.onshape_webhook_secret), + } + return context + + +@router.get("/settings/onshape/configure", response_class=HTMLResponse, response_model=None) +async def settings_onshape_configure_form( + request: Request, db: DbSession +) -> HTMLResponse | RedirectResponse: + """Render the Onshape credentials form (admin only).""" + if redirect := _require_admin_web(request, db): + return redirect + context = _onshape_form_context(request, db) + context["save_result"] = None + context["test_result"] = None + return templates.TemplateResponse("settings/onshape_configure.html", context) + + +@router.post("/settings/onshape/configure", response_class=HTMLResponse, response_model=None) +async def settings_onshape_configure_save( + request: Request, + db: DbSession, + access_key: str = Form(default=""), + secret_key: str = Form(default=""), + base_url: str = Form(default="https://cad.onshape.com"), + poll_interval_minutes: int = Form(default=15), + webhook_secret: str = Form(default=""), + clear_secret_key: bool = Form(default=False), + clear_webhook_secret: bool = Form(default=False), +) -> HTMLResponse | RedirectResponse: + """Persist Onshape credentials. Blank secret fields keep existing values + unless the matching ``clear_*`` checkbox was sent.""" + if redirect := _require_admin_web(request, db): + return redirect + + from opal.config import apply_db_overlay, get_active_settings, set_app_setting + + current = get_active_settings() + + new_secret: str | None + if clear_secret_key: + new_secret = "" + elif secret_key: + new_secret = secret_key + else: + new_secret = current.onshape_secret_key + + new_webhook: str | None + if clear_webhook_secret: + new_webhook = "" + elif webhook_secret: + new_webhook = webhook_secret + else: + new_webhook = current.onshape_webhook_secret + + set_app_setting(db, "onshape_access_key", access_key.strip()) + set_app_setting(db, "onshape_secret_key", new_secret) + set_app_setting(db, "onshape_base_url", base_url.strip() or "https://cad.onshape.com") + set_app_setting(db, "onshape_poll_interval_minutes", str(max(0, poll_interval_minutes))) + set_app_setting(db, "onshape_webhook_secret", new_webhook) + db.commit() + apply_db_overlay(db) + + context = _onshape_form_context(request, db) + context["save_result"] = {"ok": True, "message": "Onshape settings saved."} + context["test_result"] = None + return templates.TemplateResponse("settings/onshape_configure.html", context) + + +@router.post("/settings/onshape/test", response_class=HTMLResponse) +async def settings_onshape_test(request: Request, db: DbSession) -> HTMLResponse: + """Run a credential smoke-test against the saved Onshape config. + Returns an HTMX banner partial.""" + if redirect := _require_admin_web(request, db): + return redirect + + from opal.config import get_active_settings + from opal.integrations.onshape.client import OnshapeApiError, OnshapeClient + + s = get_active_settings() + if not s.onshape_enabled: + result = {"ok": False, "message": "Access key and secret key are required."} + else: + import json as _json + + try: + client = OnshapeClient( + access_key=s.onshape_access_key, + secret_key=s.onshape_secret_key, + base_url=s.onshape_base_url, + ) + try: + info = client.get_session_info() + finally: + client.close() + name = info.get("name") or info.get("email") or "session OK" + result = {"ok": True, "message": f"Connected as {name}."} + except OnshapeApiError as err: + result = {"ok": False, "message": f"Onshape API {err.status_code}: {err.detail}"} + except _json.JSONDecodeError: + # Onshape returns an HTML login page (HTTP 200) for bad credentials, + # which trips the JSON decoder before we ever see a 4xx. + result = { + "ok": False, + "message": "Authentication failed — check that the access and secret keys are correct.", + } + except Exception as err: + result = {"ok": False, "message": f"Connection failed: {err}"} + + return templates.TemplateResponse( + "settings/_onshape_test_banner.html", + {"request": request, "test_result": result}, + ) + + @router.get("/settings/onshape/sync-log", response_class=HTMLResponse) async def settings_onshape_sync_log(request: Request, db: DbSession) -> HTMLResponse: """HTMX partial: recent Onshape sync log entries.""" diff --git a/src/opal/web/templates/settings/_onshape_test_banner.html b/src/opal/web/templates/settings/_onshape_test_banner.html new file mode 100644 index 0000000..1269a6b --- /dev/null +++ b/src/opal/web/templates/settings/_onshape_test_banner.html @@ -0,0 +1,7 @@ +{% if test_result %} +
+ {{ test_result.message }} +
+{% endif %} diff --git a/src/opal/web/templates/settings/index.html b/src/opal/web/templates/settings/index.html index 4f08ccb..2b85e44 100644 --- a/src/opal/web/templates/settings/index.html +++ b/src/opal/web/templates/settings/index.html @@ -105,6 +105,7 @@ {# ── ONSHAPE INTEGRATION ── #} {% if onshape_enabled %} {% set onshape_actions %} + {% if is_admin %}EDIT{% endif %}
{% endcall %} +{% else %} +{% set onshape_actions %} + {% if is_admin %}CONFIGURE{% endif %} +{% endset %} +{% call ok.panel("ONSHAPE INTEGRATION", actions=onshape_actions, margin_top="var(--space-md)") %} +

+ Onshape integration is {{ ok.status("DISABLED", "default") }}. + {% if is_admin %}Click CONFIGURE to enter API credentials.{% else %}An admin must configure API credentials.{% endif %} +

+{% endcall %} {% endif %} {# ── USER MANAGEMENT (admin only) ── #} diff --git a/src/opal/web/templates/settings/onshape_configure.html b/src/opal/web/templates/settings/onshape_configure.html new file mode 100644 index 0000000..7f3ba93 --- /dev/null +++ b/src/opal/web/templates/settings/onshape_configure.html @@ -0,0 +1,107 @@ +{% extends "layouts/base.html" %} +{% import 'opalkit/_macros.html' as ok %} + +{% block breadcrumbs %} +{{ ok.crumb("HOME", "/") }} +{{ ok.crumb("SETTINGS", "/settings") }} +{{ ok.crumb("ONSHAPE") }} +{% endblock %} + +{% block content %} +{% set status_badge %} +{%- if onshape_enabled -%}{{ ok.status("ENABLED", "ok") }}{%- else -%}{{ ok.status("DISABLED", "default") }}{%- endif -%} +{% endset %} + +{% set panel_actions %} + +{% endset %} + +{% call ok.panel("ONSHAPE INTEGRATION", actions=panel_actions, max_width="800px") %} + + {% call ok.table() %} + {{ ok.detail_row("STATUS", status_badge) }} + {% endcall %} + +

+ Credentials are stored in the local database and override any values from .env. + Generate an API key pair at dev-portal.onshape.com. +

+ +
+ +
+ + +
+ +
+ + + {% if form.has_secret_key %} + + + + + {% endif %} +
+ +
+
+ + +
+ +
+ + + 0 disables auto-pull. Polling restart requires a server restart. +
+
+ +
+ + + {% if form.has_webhook_secret %} + + + + + {% endif %} +
+ + {{ ok.form_actions(cancel_href="/settings", submit_text="SAVE") }} + + {% if save_result %} +
+ {{ save_result.message }} +
+ {% endif %} + +
+ +
+ {% if test_result %} + {% include "settings/_onshape_test_banner.html" %} + {% endif %} +
+ +{% endcall %} +{% endblock %} diff --git a/src/opal/web/templates/settings/onshape_documents.html b/src/opal/web/templates/settings/onshape_documents.html index 515127a..76cae8e 100644 --- a/src/opal/web/templates/settings/onshape_documents.html +++ b/src/opal/web/templates/settings/onshape_documents.html @@ -3,12 +3,12 @@ {# HTMX partial: Onshape registered documents table + add form #}
{% if onshape_doc_error %} -
+
{{ onshape_doc_error }}
{% endif %} {% if onshape_doc_success %} -
+
{{ onshape_doc_success }}
{% endif %} diff --git a/uv.lock b/uv.lock index 84a8254..ce789f2 100644 --- a/uv.lock +++ b/uv.lock @@ -723,7 +723,7 @@ wheels = [ [[package]] name = "opal" -version = "1.3.0" +version = "1.3.2" source = { editable = "." } dependencies = [ { name = "aiofiles" },