From c13fcb179ac37c33b8a3018f3a4909384ec2e7d5 Mon Sep 17 00:00:00 2001 From: Brayan Almonte Date: Tue, 11 Jun 2024 20:25:58 -0400 Subject: [PATCH] feat(robot-server): add protocol_kind to the protocols endpoint and persistance layer. (#15353) --- .../persistence/_migrations/v4_to_v5.py | 52 +++++ .../persistence/persistence_directory.py | 3 +- .../persistence/tables/__init__.py | 2 +- .../persistence/tables/schema_5.py | 138 ++++++++++++++ .../robot_server/protocols/protocol_models.py | 24 +++ .../robot_server/protocols/protocol_store.py | 12 ++ robot-server/robot_server/protocols/router.py | 56 +++++- .../http_api/persistence/test_reset.py | 6 +- .../protocols/test_analyses.tavern.yaml | 1 + ...lyses_with_run_time_parameters.tavern.yaml | 3 +- .../test_deck_coordinate_load.tavern.yaml | 1 + .../http_api/protocols/test_key.tavern.yaml | 1 + .../http_api/protocols/test_persistence.py | 4 +- .../protocols/test_upload.tavern.yaml | 1 + .../test_upload_protocol_kind.tavern.yaml | 76 ++++++++ .../protocols/test_v6_json_upload.tavern.yaml | 3 + .../test_v8_json_upload_flex.tavern.yaml | 2 + .../test_v8_json_upload_ot2.tavern.yaml | 1 + robot-server/tests/persistence/test_tables.py | 71 ++++++- .../tests/protocols/test_analyses_manager.py | 3 + .../tests/protocols/test_analysis_store.py | 1 + .../test_completed_analysis_store.py | 1 + .../tests/protocols/test_protocol_analyzer.py | 4 + .../tests/protocols/test_protocol_store.py | 42 +++++ .../tests/protocols/test_protocols_router.py | 178 +++++++++++++++++- .../tests/runs/router/test_base_router.py | 1 + .../tests/runs/test_run_data_manager.py | 1 + 27 files changed, 671 insertions(+), 17 deletions(-) create mode 100644 robot-server/robot_server/persistence/_migrations/v4_to_v5.py create mode 100644 robot-server/robot_server/persistence/tables/schema_5.py create mode 100644 robot-server/tests/integration/http_api/protocols/test_upload_protocol_kind.tavern.yaml diff --git a/robot-server/robot_server/persistence/_migrations/v4_to_v5.py b/robot-server/robot_server/persistence/_migrations/v4_to_v5.py new file mode 100644 index 00000000000..b05852bbaef --- /dev/null +++ b/robot-server/robot_server/persistence/_migrations/v4_to_v5.py @@ -0,0 +1,52 @@ +"""Migrate the persistence directory from schema 4 to 5. + +Summary of changes from schema 4: + +- Adds a new "protocol_kind" column to protocols table +""" + +from pathlib import Path +from contextlib import ExitStack +import shutil +from typing import Any + +import sqlalchemy + +from ..database import sql_engine_ctx +from ..tables import schema_5 +from .._folder_migrator import Migration + +_DB_FILE = "robot_server.db" + + +class Migration4to5(Migration): # noqa: D101 + def migrate(self, source_dir: Path, dest_dir: Path) -> None: + """Migrate the persistence directory from schema 4 to 5.""" + # Copy over all existing directories and files to new version + for item in source_dir.iterdir(): + if item.is_dir(): + shutil.copytree(src=item, dst=dest_dir / item.name) + else: + shutil.copy(src=item, dst=dest_dir / item.name) + dest_db_file = dest_dir / _DB_FILE + + # Append the new column to existing protocols in v4 database + with ExitStack() as exit_stack: + dest_engine = exit_stack.enter_context(sql_engine_ctx(dest_db_file)) + schema_5.metadata.create_all(dest_engine) + + def add_column( + engine: sqlalchemy.engine.Engine, + table_name: str, + column: Any, + ) -> None: + column_type = column.type.compile(engine.dialect) + engine.execute( + f"ALTER TABLE {table_name} ADD COLUMN {column.key} {column_type}" + ) + + add_column( + dest_engine, + schema_5.protocol_table.name, + schema_5.protocol_table.c.protocol_kind, + ) diff --git a/robot-server/robot_server/persistence/persistence_directory.py b/robot-server/robot_server/persistence/persistence_directory.py index b7982b38555..800cbd5b6f6 100644 --- a/robot-server/robot_server/persistence/persistence_directory.py +++ b/robot-server/robot_server/persistence/persistence_directory.py @@ -11,7 +11,7 @@ from anyio import Path as AsyncPath, to_thread from ._folder_migrator import MigrationOrchestrator -from ._migrations import up_to_3, v3_to_v4 +from ._migrations import up_to_3, v3_to_v4, v4_to_v5 _TEMP_PERSISTENCE_DIR_PREFIX: Final = "opentrons-robot-server-" @@ -51,6 +51,7 @@ async def prepare_active_subdirectory(prepared_root: Path) -> Path: migrations=[ up_to_3.MigrationUpTo3(subdirectory="3"), v3_to_v4.Migration3to4(subdirectory="4"), + v4_to_v5.Migration4to5(subdirectory="5"), ], temp_file_prefix="temp-", ) diff --git a/robot-server/robot_server/persistence/tables/__init__.py b/robot-server/robot_server/persistence/tables/__init__.py index 0aaf869fb35..59d06704ddb 100644 --- a/robot-server/robot_server/persistence/tables/__init__.py +++ b/robot-server/robot_server/persistence/tables/__init__.py @@ -1,7 +1,7 @@ """SQL database schemas.""" # Re-export the latest schema. -from .schema_4 import ( +from .schema_5 import ( metadata, protocol_table, analysis_table, diff --git a/robot-server/robot_server/persistence/tables/schema_5.py b/robot-server/robot_server/persistence/tables/schema_5.py new file mode 100644 index 00000000000..a77c2fbd449 --- /dev/null +++ b/robot-server/robot_server/persistence/tables/schema_5.py @@ -0,0 +1,138 @@ +"""v5 of our SQLite schema.""" + +import sqlalchemy + +from robot_server.persistence._utc_datetime import UTCDateTime + +metadata = sqlalchemy.MetaData() + +protocol_table = sqlalchemy.Table( + "protocol", + metadata, + sqlalchemy.Column( + "id", + sqlalchemy.String, + primary_key=True, + ), + sqlalchemy.Column( + "created_at", + UTCDateTime, + nullable=False, + ), + sqlalchemy.Column("protocol_key", sqlalchemy.String, nullable=True), + sqlalchemy.Column("protocol_kind", sqlalchemy.String, nullable=True), +) + +analysis_table = sqlalchemy.Table( + "analysis", + metadata, + sqlalchemy.Column( + "id", + sqlalchemy.String, + primary_key=True, + ), + sqlalchemy.Column( + "protocol_id", + sqlalchemy.String, + sqlalchemy.ForeignKey("protocol.id"), + index=True, + nullable=False, + ), + sqlalchemy.Column( + "analyzer_version", + sqlalchemy.String, + nullable=False, + ), + sqlalchemy.Column( + "completed_analysis", + # Stores a JSON string. See CompletedAnalysisStore. + sqlalchemy.String, + nullable=False, + ), + # column added in schema v4 + sqlalchemy.Column( + "run_time_parameter_values_and_defaults", + sqlalchemy.String, + nullable=True, + ), +) + +run_table = sqlalchemy.Table( + "run", + metadata, + sqlalchemy.Column( + "id", + sqlalchemy.String, + primary_key=True, + ), + sqlalchemy.Column( + "created_at", + UTCDateTime, + nullable=False, + ), + sqlalchemy.Column( + "protocol_id", + sqlalchemy.String, + sqlalchemy.ForeignKey("protocol.id"), + nullable=True, + ), + # column added in schema v1 + sqlalchemy.Column( + "state_summary", + sqlalchemy.String, + nullable=True, + ), + # column added in schema v1 + sqlalchemy.Column("engine_status", sqlalchemy.String, nullable=True), + # column added in schema v1 + sqlalchemy.Column("_updated_at", UTCDateTime, nullable=True), + # column added in schema v4 + sqlalchemy.Column( + "run_time_parameters", + # Stores a JSON string. See RunStore. + sqlalchemy.String, + nullable=True, + ), +) + +action_table = sqlalchemy.Table( + "action", + metadata, + sqlalchemy.Column( + "id", + sqlalchemy.String, + primary_key=True, + ), + sqlalchemy.Column("created_at", UTCDateTime, nullable=False), + sqlalchemy.Column("action_type", sqlalchemy.String, nullable=False), + sqlalchemy.Column( + "run_id", + sqlalchemy.String, + sqlalchemy.ForeignKey("run.id"), + nullable=False, + ), +) + +run_command_table = sqlalchemy.Table( + "run_command", + metadata, + sqlalchemy.Column("row_id", sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column( + "run_id", sqlalchemy.String, sqlalchemy.ForeignKey("run.id"), nullable=False + ), + sqlalchemy.Column("index_in_run", sqlalchemy.Integer, nullable=False), + sqlalchemy.Column("command_id", sqlalchemy.String, nullable=False), + sqlalchemy.Column("command", sqlalchemy.String, nullable=False), + sqlalchemy.Index( + "ix_run_run_id_command_id", # An arbitrary name for the index. + "run_id", + "command_id", + unique=True, + ), + sqlalchemy.Index( + "ix_run_run_id_index_in_run", # An arbitrary name for the index. + "run_id", + "index_in_run", + unique=True, + ), +) diff --git a/robot-server/robot_server/protocols/protocol_models.py b/robot-server/robot_server/protocols/protocol_models.py index 0e902d60034..b0c28e5e4eb 100644 --- a/robot-server/robot_server/protocols/protocol_models.py +++ b/robot-server/robot_server/protocols/protocol_models.py @@ -1,7 +1,9 @@ """Protocol file models.""" + from datetime import datetime from pydantic import BaseModel, Extra, Field from typing import Any, List, Optional +from enum import Enum from opentrons.protocol_reader import ( ProtocolType as ProtocolType, @@ -14,6 +16,21 @@ from .analysis_models import AnalysisSummary +class ProtocolKind(str, Enum): + """Kind of protocol, standard or quick-transfer.""" + + STANDARD = "standard" + QUICK_TRANSFER = "quick-transfer" + + @staticmethod + def from_string(name: Optional[str]) -> Optional["ProtocolKind"]: + """Get the ProtocolKind from a string.""" + for item in ProtocolKind: + if name == item.value: + return item + return None + + class ProtocolFile(BaseModel): """A file in a protocol.""" @@ -109,3 +126,10 @@ class Protocol(ResourceModel): " See `POST /protocols`." ), ) + + protocolKind: Optional[ProtocolKind] = Field( + ..., + description="The kind of protocol (standard or quick-transfer)." + "The client provides this field when the protocol is uploaded." + " See `POST /protocols`.", + ) diff --git a/robot-server/robot_server/protocols/protocol_store.py b/robot-server/robot_server/protocols/protocol_store.py index 17ae3345ea3..15f0530e76d 100644 --- a/robot-server/robot_server/protocols/protocol_store.py +++ b/robot-server/robot_server/protocols/protocol_store.py @@ -1,4 +1,5 @@ """Store and retrieve information about uploaded protocols.""" + from __future__ import annotations from dataclasses import dataclass @@ -35,6 +36,7 @@ class ProtocolResource: created_at: datetime source: ProtocolSource protocol_key: Optional[str] + protocol_kind: Optional[str] @dataclass(frozen=True) @@ -166,6 +168,7 @@ def insert(self, resource: ProtocolResource) -> None: protocol_id=resource.protocol_id, created_at=resource.created_at, protocol_key=resource.protocol_key, + protocol_kind=resource.protocol_kind, ) ) self._sources_by_id[resource.protocol_id] = resource.source @@ -183,6 +186,7 @@ def get(self, protocol_id: str) -> ProtocolResource: protocol_id=sql_resource.protocol_id, created_at=sql_resource.created_at, protocol_key=sql_resource.protocol_key, + protocol_kind=sql_resource.protocol_kind, source=self._sources_by_id[sql_resource.protocol_id], ) @@ -198,6 +202,7 @@ def get_all(self) -> List[ProtocolResource]: protocol_id=r.protocol_id, created_at=r.created_at, protocol_key=r.protocol_key, + protocol_kind=r.protocol_kind, source=self._sources_by_id[r.protocol_id], ) for r in all_sql_resources @@ -471,6 +476,7 @@ class _DBProtocolResource: protocol_id: str created_at: datetime protocol_key: Optional[str] + protocol_kind: Optional[str] def _convert_sql_row_to_dataclass( @@ -479,16 +485,21 @@ def _convert_sql_row_to_dataclass( protocol_id = sql_row.id protocol_key = sql_row.protocol_key created_at = sql_row.created_at + protocol_kind = sql_row.protocol_kind assert isinstance(protocol_id, str), f"Protocol ID {protocol_id} not a string" assert protocol_key is None or isinstance( protocol_key, str ), f"Protocol Key {protocol_key} not a string or None" + assert protocol_kind is None or isinstance( + protocol_kind, str + ), f"Protocol Kind {protocol_kind} not a string or None" return _DBProtocolResource( protocol_id=protocol_id, created_at=created_at, protocol_key=protocol_key, + protocol_kind=protocol_kind, ) @@ -499,4 +510,5 @@ def _convert_dataclass_to_sql_values( "id": resource.protocol_id, "created_at": resource.created_at, "protocol_key": resource.protocol_key, + "protocol_kind": resource.protocol_kind, } diff --git a/robot-server/robot_server/protocols/router.py b/robot-server/robot_server/protocols/router.py index f3ac7c22c33..044117d1f2c 100644 --- a/robot-server/robot_server/protocols/router.py +++ b/robot-server/robot_server/protocols/router.py @@ -1,4 +1,5 @@ """Router for /protocols endpoints.""" + import json import logging from textwrap import dedent @@ -10,7 +11,15 @@ from opentrons_shared_data.robot import user_facing_robot_type from typing_extensions import Literal -from fastapi import APIRouter, Depends, File, UploadFile, status, Form +from fastapi import ( + APIRouter, + Depends, + File, + HTTPException, + UploadFile, + status, + Form, +) from fastapi.responses import PlainTextResponse from pydantic import BaseModel, Field @@ -37,7 +46,7 @@ from .analyses_manager import AnalysesManager from .protocol_auto_deleter import ProtocolAutoDeleter -from .protocol_models import Protocol, ProtocolFile, Metadata +from .protocol_models import Protocol, ProtocolFile, Metadata, ProtocolKind from .analysis_store import AnalysisStore, AnalysisNotFoundError, AnalysisIsPendingError from .analysis_models import ProtocolAnalysis, AnalysisRequest, AnalysisSummary from .protocol_store import ( @@ -151,6 +160,18 @@ class ProtocolLinks(BaseModel): A new analysis is also started if the same protocol file is uploaded but with a different set of run-time parameter values than the most recent request. See the `/protocols/{id}/analyses/` endpoints for more details. + + You can provide the kind of protocol with the `protocol_kind` form data + The protocol kind can be: + + - `quick-transfer` for Quick Transfer protocols + - `standard` for non Quick transfer protocols + + if the `protocol_kind` is None it will be defaulted to `standard`. + + Quick transfer protocols: + - Do not store any run history + - Do not get auto deleted, instead they have a fixed max count. """ ), status_code=status.HTTP_201_CREATED, @@ -163,7 +184,7 @@ class ProtocolLinks(BaseModel): status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorBody[LastAnalysisPending]}, }, ) -async def create_protocol( +async def create_protocol( # noqa: C901 files: List[UploadFile] = File(...), # use Form because request is multipart/form-data # https://fastapi.tiangolo.com/tutorial/request-forms-and-files/ @@ -185,6 +206,14 @@ async def create_protocol( " always trigger an analysis (for now).", alias="runTimeParameterValues", ), + protocol_kind: Optional[str] = Form( + default=None, + description=( + "Whether this is a `standard` protocol or a `quick-transfer` protocol." + "if ommited, the protocol will be `standard` by default." + ), + alias="protocolKind", + ), protocol_directory: Path = Depends(get_protocol_directory), protocol_store: ProtocolStore = Depends(get_protocol_store), analysis_store: AnalysisStore = Depends(get_analysis_store), @@ -202,8 +231,9 @@ async def create_protocol( Arguments: files: List of uploaded files, from form-data. - key: Optional key for client-side tracking + key: Optional key for cli-side tracking run_time_parameter_values: Key value pairs of run-time parameters defined in a protocol. + protocol_kind: Optional key representing the kind of protocol. protocol_directory: Location to store uploaded files. protocol_store: In-memory database of protocol resources. analysis_store: In-memory database of protocol analyses. @@ -219,6 +249,13 @@ async def create_protocol( analysis_id: Unique identifier to attach to the analysis resource. created_at: Timestamp to attach to the new resource. """ + kind = ProtocolKind.from_string(protocol_kind) + if isinstance(protocol_kind, str) and kind is None: + raise HTTPException( + status_code=400, detail=f"Invalid protocol_kind: {protocol_kind}" + ) + kind = kind or ProtocolKind.STANDARD + for file in files: # TODO(mm, 2024-02-07): Investigate whether the filename can actually be None. assert file.filename is not None @@ -256,6 +293,7 @@ async def create_protocol( data = Protocol.construct( id=cached_protocol_id, createdAt=resource.created_at, + protocolKind=ProtocolKind.from_string(resource.protocol_kind), protocolType=resource.source.config.protocol_type, robotType=resource.source.robot_type, metadata=Metadata.parse_obj(resource.source.metadata), @@ -303,6 +341,7 @@ async def create_protocol( created_at=created_at, source=source, protocol_key=key, + protocol_kind=kind.value, ) protocol_auto_deleter.make_room_for_new_protocol() @@ -317,6 +356,7 @@ async def create_protocol( data = Protocol( id=protocol_id, createdAt=created_at, + protocolKind=kind, protocolType=source.config.protocol_type, robotType=source.robot_type, metadata=Metadata.parse_obj(source.metadata), @@ -397,6 +437,7 @@ async def get_protocols( Protocol.construct( id=r.protocol_id, createdAt=r.created_at, + protocolKind=ProtocolKind.from_string(r.protocol_kind), protocolType=r.source.config.protocol_type, robotType=r.source.robot_type, metadata=Metadata.parse_obj(r.source.metadata), @@ -476,6 +517,7 @@ async def get_protocol_by_id( data = Protocol.construct( id=protocolId, createdAt=resource.created_at, + protocolKind=ProtocolKind.from_string(resource.protocol_kind), protocolType=resource.source.config.protocol_type, robotType=resource.source.robot_type, metadata=Metadata.parse_obj(resource.source.metadata), @@ -601,9 +643,9 @@ async def create_protocol_analysis( data=analysis_summaries, meta=MultiBodyMeta(cursor=0, totalLength=len(analysis_summaries)), ), - status_code=status.HTTP_201_CREATED - if started_new_analysis - else status.HTTP_200_OK, + status_code=( + status.HTTP_201_CREATED if started_new_analysis else status.HTTP_200_OK + ), ) diff --git a/robot-server/tests/integration/http_api/persistence/test_reset.py b/robot-server/tests/integration/http_api/persistence/test_reset.py index 394671bba64..455385e95c5 100644 --- a/robot-server/tests/integration/http_api/persistence/test_reset.py +++ b/robot-server/tests/integration/http_api/persistence/test_reset.py @@ -40,9 +40,9 @@ async def _assert_reset_was_successful( all_files_and_directories = set(persistence_directory.glob("**/*")) expected_files_and_directories = { persistence_directory / "robot_server.db", - persistence_directory / "4", - persistence_directory / "4" / "protocols", - persistence_directory / "4" / "robot_server.db", + persistence_directory / "5", + persistence_directory / "5" / "protocols", + persistence_directory / "5" / "robot_server.db", } assert all_files_and_directories == expected_files_and_directories diff --git a/robot-server/tests/integration/http_api/protocols/test_analyses.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_analyses.tavern.yaml index 4fa8aa45038..91e892c4f70 100644 --- a/robot-server/tests/integration/http_api/protocols/test_analyses.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_analyses.tavern.yaml @@ -45,6 +45,7 @@ stages: files: !anything createdAt: !anything robotType: !anything + protocolKind: !anything metadata: !anything links: !anything diff --git a/robot-server/tests/integration/http_api/protocols/test_analyses_with_run_time_parameters.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_analyses_with_run_time_parameters.tavern.yaml index 393426aafa9..c6562f68cc5 100644 --- a/robot-server/tests/integration/http_api/protocols/test_analyses_with_run_time_parameters.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_analyses_with_run_time_parameters.tavern.yaml @@ -84,6 +84,7 @@ stages: files: !anything createdAt: !anything robotType: !anything + protocolKind: !anything metadata: !anything links: !anything @@ -242,4 +243,4 @@ stages: value: flex_8channel_50 default: flex_1channel_50 value: flex_1channel_50 - description: What pipette to use during the protocol. \ No newline at end of file + description: What pipette to use during the protocol. diff --git a/robot-server/tests/integration/http_api/protocols/test_deck_coordinate_load.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_deck_coordinate_load.tavern.yaml index d814a17e4de..9d74a46cb8d 100644 --- a/robot-server/tests/integration/http_api/protocols/test_deck_coordinate_load.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_deck_coordinate_load.tavern.yaml @@ -26,6 +26,7 @@ stages: role: main createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" robotType: OT-3 Standard + protocolKind: standard metadata: protocolName: Deck Coordinate PAPIv2 Test analyses: [] diff --git a/robot-server/tests/integration/http_api/protocols/test_key.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_key.tavern.yaml index c43723453f3..1a5e6173451 100644 --- a/robot-server/tests/integration/http_api/protocols/test_key.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_key.tavern.yaml @@ -26,6 +26,7 @@ stages: role: main createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" robotType: OT-2 Standard + protocolKind: standard metadata: apiLevel: '2.6' protocolName: basic_transfer_standalone diff --git a/robot-server/tests/integration/http_api/protocols/test_persistence.py b/robot-server/tests/integration/http_api/protocols/test_persistence.py index 0480accb39c..f18107b94c1 100644 --- a/robot-server/tests/integration/http_api/protocols/test_persistence.py +++ b/robot-server/tests/integration/http_api/protocols/test_persistence.py @@ -120,10 +120,10 @@ async def test_protocol_labware_files_persist() -> None: assert restarted_protocol_detail == protocol_detail four_tuberack = Path( - f"{server.persistence_directory}/4/protocols/{protocol_id}/cpx_4_tuberack_100ul.json" + f"{server.persistence_directory}/5/protocols/{protocol_id}/cpx_4_tuberack_100ul.json" ) six_tuberack = Path( - f"{server.persistence_directory}/4/protocols/{protocol_id}/cpx_6_tuberack_100ul.json" + f"{server.persistence_directory}/5/protocols/{protocol_id}/cpx_6_tuberack_100ul.json" ) assert four_tuberack.is_file() assert six_tuberack.is_file() diff --git a/robot-server/tests/integration/http_api/protocols/test_upload.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_upload.tavern.yaml index a1bf173b8d9..ab946a74d7d 100644 --- a/robot-server/tests/integration/http_api/protocols/test_upload.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_upload.tavern.yaml @@ -25,6 +25,7 @@ stages: role: main createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" robotType: OT-2 Standard + protocolKind: standard metadata: apiLevel: '2.6' protocolName: basic_transfer_standalone diff --git a/robot-server/tests/integration/http_api/protocols/test_upload_protocol_kind.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_upload_protocol_kind.tavern.yaml new file mode 100644 index 00000000000..cf16a094ec6 --- /dev/null +++ b/robot-server/tests/integration/http_api/protocols/test_upload_protocol_kind.tavern.yaml @@ -0,0 +1,76 @@ +test_name: Upload protocol with protocol kind form data and verify response. + +marks: + - ot3_only + - usefixtures: + - ot3_server_base_url + +stages: + - name: Upload protocol marked as quick-transfer + request: + url: '{ot3_server_base_url}/protocols' + method: POST + data: + protocolKind: quick-transfer + files: + files: 'tests/integration/protocols/empty_ot3.json' + response: + strict: + - json:off + save: + json: + protocol_id: data.id + analysis_id: data.analysisSummaries[0].id + status_code: 201 + json: + data: + id: !anystr + protocolType: json + protocolKind: quick-transfer + robotType: OT-3 Standard + - name: Upload protocol marked as standard + request: + url: '{ot3_server_base_url}/protocols' + method: POST + data: + protocolKind: standard + files: + files: '../shared-data/protocol/fixtures/8/simpleFlexV8.json' + response: + strict: + - json:off + save: + json: + protocol_id: data.id + analysis_id: data.analysisSummaries[0].id + status_code: 201 + json: + data: + id: !anystr + protocolType: json + protocolKind: standard + robotType: OT-3 Standard + +--- + +test_name: Make sure we reject invalid protocol kind values. + +marks: + - ot3_only + - usefixtures: + - ot3_server_base_url + + +stages: + - name: Upload protocol with invalid protocol kind + request: + url: '{ot3_server_base_url}/protocols' + method: POST + data: + protocolKind: "invalid_value" + files: + files: 'tests/integration/protocols/empty_ot3.json' + response: + strict: + - json:off + status_code: 400 diff --git a/robot-server/tests/integration/http_api/protocols/test_v6_json_upload.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_v6_json_upload.tavern.yaml index 55511d3ac88..1b0c603b38a 100644 --- a/robot-server/tests/integration/http_api/protocols/test_v6_json_upload.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_v6_json_upload.tavern.yaml @@ -23,6 +23,7 @@ stages: data: id: !anystr protocolType: json + protocolKind: standard analysisSummaries: - id: !anystr status: pending @@ -57,6 +58,7 @@ stages: - name: simpleV6.json role: main protocolType: json + protocolKind: standard robotType: OT-2 Standard metadata: tags: @@ -609,6 +611,7 @@ stages: data: id: !anystr protocolType: json + protocolKind: standard analysisSummaries: - id: !anystr status: pending diff --git a/robot-server/tests/integration/http_api/protocols/test_v8_json_upload_flex.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_v8_json_upload_flex.tavern.yaml index 6a3ff0da2f7..e53e3014c7d 100644 --- a/robot-server/tests/integration/http_api/protocols/test_v8_json_upload_flex.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_v8_json_upload_flex.tavern.yaml @@ -24,6 +24,7 @@ stages: data: id: !anystr protocolType: json + protocolKind: standard analysisSummaries: - id: !anystr status: pending @@ -58,6 +59,7 @@ stages: - name: simpleFlexV8.json role: main protocolType: json + protocolKind: standard robotType: OT-3 Standard metadata: tags: diff --git a/robot-server/tests/integration/http_api/protocols/test_v8_json_upload_ot2.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_v8_json_upload_ot2.tavern.yaml index f8636a651c3..55d4378a2b2 100644 --- a/robot-server/tests/integration/http_api/protocols/test_v8_json_upload_ot2.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_v8_json_upload_ot2.tavern.yaml @@ -57,6 +57,7 @@ stages: - name: simpleV8.json role: main protocolType: json + protocolKind: standard robotType: OT-2 Standard metadata: tags: diff --git a/robot-server/tests/persistence/test_tables.py b/robot-server/tests/persistence/test_tables.py index 5f3c45adcaa..ce8b4c8bca4 100644 --- a/robot-server/tests/persistence/test_tables.py +++ b/robot-server/tests/persistence/test_tables.py @@ -11,6 +11,7 @@ schema_3, schema_2, schema_4, + schema_5, ) # The statements that we expect to emit when we create a fresh database. @@ -26,6 +27,74 @@ # # Whitespace and formatting changes, on the other hand, are allowed. EXPECTED_STATEMENTS_LATEST = [ + """ + CREATE TABLE protocol ( + id VARCHAR NOT NULL, + created_at DATETIME NOT NULL, + protocol_key VARCHAR, + protocol_kind VARCHAR, + PRIMARY KEY (id) + ) + """, + """ + CREATE TABLE analysis ( + id VARCHAR NOT NULL, + protocol_id VARCHAR NOT NULL, + analyzer_version VARCHAR NOT NULL, + completed_analysis VARCHAR NOT NULL, + run_time_parameter_values_and_defaults VARCHAR, + PRIMARY KEY (id), + FOREIGN KEY(protocol_id) REFERENCES protocol (id) + ) + """, + """ + CREATE INDEX ix_analysis_protocol_id ON analysis (protocol_id) + """, + """ + CREATE TABLE run ( + id VARCHAR NOT NULL, + created_at DATETIME NOT NULL, + protocol_id VARCHAR, + state_summary VARCHAR, + engine_status VARCHAR, + _updated_at DATETIME, + run_time_parameters VARCHAR, + PRIMARY KEY (id), + FOREIGN KEY(protocol_id) REFERENCES protocol (id) + ) + """, + """ + CREATE TABLE action ( + id VARCHAR NOT NULL, + created_at DATETIME NOT NULL, + action_type VARCHAR NOT NULL, + run_id VARCHAR NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY(run_id) REFERENCES run (id) + ) + """, + """ + CREATE TABLE run_command ( + row_id INTEGER NOT NULL, + run_id VARCHAR NOT NULL, + index_in_run INTEGER NOT NULL, + command_id VARCHAR NOT NULL, + command VARCHAR NOT NULL, + PRIMARY KEY (row_id), + FOREIGN KEY(run_id) REFERENCES run (id) + ) + """, + """ + CREATE UNIQUE INDEX ix_run_run_id_command_id ON run_command (run_id, command_id) + """, + """ + CREATE UNIQUE INDEX ix_run_run_id_index_in_run ON run_command (run_id, index_in_run) + """, +] + +EXPECTED_STATEMENTS_V5 = EXPECTED_STATEMENTS_LATEST + +EXPECTED_STATEMENTS_V4 = [ """ CREATE TABLE protocol ( id VARCHAR NOT NULL, @@ -90,7 +159,6 @@ """, ] -EXPECTED_STATEMENTS_V4 = EXPECTED_STATEMENTS_LATEST EXPECTED_STATEMENTS_V3 = [ """ @@ -230,6 +298,7 @@ def _normalize_statement(statement: str) -> str: ("metadata", "expected_statements"), [ (latest_metadata, EXPECTED_STATEMENTS_LATEST), + (schema_5.metadata, EXPECTED_STATEMENTS_V5), (schema_4.metadata, EXPECTED_STATEMENTS_V4), (schema_3.metadata, EXPECTED_STATEMENTS_V3), (schema_2.metadata, EXPECTED_STATEMENTS_V2), diff --git a/robot-server/tests/protocols/test_analyses_manager.py b/robot-server/tests/protocols/test_analyses_manager.py index 3f1a0903efd..3e511d7ce17 100644 --- a/robot-server/tests/protocols/test_analyses_manager.py +++ b/robot-server/tests/protocols/test_analyses_manager.py @@ -10,6 +10,7 @@ from opentrons_shared_data.robot.dev_types import RobotType from robot_server.protocols import protocol_analyzer +from robot_server.protocols.protocol_models import ProtocolKind from robot_server.protocols.analyses_manager import AnalysesManager from robot_server.protocols.analysis_models import ( AnalysisSummary, @@ -78,6 +79,7 @@ async def test_start_analysis( content_hash="abc123", ), protocol_key="dummy-data-111", + protocol_kind=ProtocolKind.STANDARD.value, ) bool_parameter = BooleanParameter( displayName="Foo", variableName="Bar", default=True, value=False @@ -143,6 +145,7 @@ async def test_rtp_validation_error_in_start_analysis( content_hash="abc123", ), protocol_key="dummy-data-111", + protocol_kind=ProtocolKind.STANDARD.value, ) runner_load_exception = Exception("Uh oh!") diff --git a/robot-server/tests/protocols/test_analysis_store.py b/robot-server/tests/protocols/test_analysis_store.py index e7c8072ae63..42f96e352b7 100644 --- a/robot-server/tests/protocols/test_analysis_store.py +++ b/robot-server/tests/protocols/test_analysis_store.py @@ -84,6 +84,7 @@ def make_dummy_protocol_resource(protocol_id: str) -> ProtocolResource: content_hash="abc123", ), protocol_key=None, + protocol_kind="standard", ) diff --git a/robot-server/tests/protocols/test_completed_analysis_store.py b/robot-server/tests/protocols/test_completed_analysis_store.py index 1cac25fb4e1..9dac72db211 100644 --- a/robot-server/tests/protocols/test_completed_analysis_store.py +++ b/robot-server/tests/protocols/test_completed_analysis_store.py @@ -75,6 +75,7 @@ def make_dummy_protocol_resource(protocol_id: str) -> ProtocolResource: content_hash="abc123", ), protocol_key=None, + protocol_kind="standard", ) diff --git a/robot-server/tests/protocols/test_protocol_analyzer.py b/robot-server/tests/protocols/test_protocol_analyzer.py index 65bac73ca28..f367d7c69cf 100644 --- a/robot-server/tests/protocols/test_protocol_analyzer.py +++ b/robot-server/tests/protocols/test_protocol_analyzer.py @@ -26,6 +26,7 @@ import opentrons.util.helpers as datetime_helper from robot_server.protocols.analysis_store import AnalysisStore +from robot_server.protocols.protocol_models import ProtocolKind from robot_server.protocols.protocol_store import ProtocolResource from robot_server.protocols.protocol_analyzer import ProtocolAnalyzer import robot_server.errors.error_mappers as em @@ -84,6 +85,7 @@ async def test_load_runner( created_at=datetime(year=2021, month=1, day=1), source=protocol_source, protocol_key="dummy-data-111", + protocol_kind=ProtocolKind.STANDARD.value, ) subject = ProtocolAnalyzer( @@ -129,6 +131,7 @@ async def test_analyze( content_hash="abc123", ), protocol_key="dummy-data-111", + protocol_kind=ProtocolKind.STANDARD.value, ) analysis_command = pe_commands.WaitForResume( @@ -218,6 +221,7 @@ async def test_analyze_updates_pending_on_error( content_hash="abc123", ), protocol_key="dummy-data-111", + protocol_kind=ProtocolKind.STANDARD.value, ) raised_exception = Exception("You got me!!") diff --git a/robot-server/tests/protocols/test_protocol_store.py b/robot-server/tests/protocols/test_protocol_store.py index d75212fd2fe..e97a921ff01 100644 --- a/robot-server/tests/protocols/test_protocol_store.py +++ b/robot-server/tests/protocols/test_protocol_store.py @@ -70,6 +70,7 @@ async def test_insert_and_get_protocol( content_hash="abc123", ), protocol_key="dummy-data-111", + protocol_kind="standard", ) assert subject.has("protocol-id") is False @@ -98,6 +99,7 @@ async def test_insert_with_duplicate_key_raises( content_hash="abc123", ), protocol_key="dummy-data-111", + protocol_kind="standard", ) protocol_resource_2 = ProtocolResource( protocol_id="protocol-id", @@ -112,6 +114,7 @@ async def test_insert_with_duplicate_key_raises( content_hash="abc123", ), protocol_key="dummy-data-222", + protocol_kind="standard", ) subject.insert(protocol_resource_1) @@ -149,6 +152,7 @@ async def test_get_all_protocols( content_hash="abc123", ), protocol_key="dummy-data-111", + protocol_kind="standard", ) resource_2 = ProtocolResource( protocol_id="123", @@ -163,6 +167,7 @@ async def test_get_all_protocols( content_hash="abc123", ), protocol_key="dummy-data-222", + protocol_kind="standard", ) subject.insert(resource_1) @@ -199,6 +204,7 @@ async def test_remove_protocol( content_hash="abc123", ), protocol_key="dummy-data-111", + protocol_kind="standard", ) subject.insert(protocol_resource) @@ -238,6 +244,7 @@ def test_remove_protocol_conflict( content_hash="abc123", ), protocol_key=None, + protocol_kind="standard", ) subject.insert(protocol_resource) @@ -272,6 +279,7 @@ def test_get_usage_info( content_hash="abc123", ), protocol_key=None, + protocol_kind="standard", ) protocol_resource_2 = ProtocolResource( protocol_id="protocol-id-2", @@ -286,6 +294,7 @@ def test_get_usage_info( content_hash="abc123", ), protocol_key=None, + protocol_kind="standard", ) subject.insert(protocol_resource_1) @@ -355,6 +364,7 @@ def test_get_referencing_run_ids( content_hash="abc123", ), protocol_key=None, + protocol_kind="standard", ) subject.insert(protocol_resource_1) @@ -398,6 +408,7 @@ def test_get_protocol_ids( content_hash="abc1", ), protocol_key=None, + protocol_kind="standard", ) protocol_resource_2 = ProtocolResource( @@ -413,6 +424,7 @@ def test_get_protocol_ids( content_hash="abc2", ), protocol_key=None, + protocol_kind="standard", ) assert subject.get_all_ids() == [] @@ -428,3 +440,33 @@ def test_get_protocol_ids( subject.remove(protocol_id="protocol-id-2") assert subject.get_all_ids() == [] + + +async def test_insert_and_get_quick_transfer_protocol( + protocol_file_directory: Path, subject: ProtocolStore +) -> None: + """It should store a single quick-transfer protocol.""" + protocol_resource = ProtocolResource( + protocol_id="protocol-id", + created_at=datetime(year=2024, month=6, day=6, tzinfo=timezone.utc), + source=ProtocolSource( + directory=protocol_file_directory, + main_file=(protocol_file_directory / "abc.json"), + config=JsonProtocolConfig(schema_version=123), + files=[], + metadata={}, + robot_type="OT-3 Standard", + content_hash="abc123", + ), + protocol_key="dummy-key-111", + protocol_kind="quick-transfer", + ) + + assert subject.has("protocol-id") is False + + subject.insert(protocol_resource) + result = subject.get("protocol-id") + + assert result == protocol_resource + assert result.protocol_kind == "quick-transfer" + assert subject.has("protocol-id") is True diff --git a/robot-server/tests/protocols/test_protocols_router.py b/robot-server/tests/protocols/test_protocols_router.py index 3ec86efd5b2..eea5b0860ea 100644 --- a/robot-server/tests/protocols/test_protocols_router.py +++ b/robot-server/tests/protocols/test_protocols_router.py @@ -1,10 +1,11 @@ """Tests for the /protocols router.""" + import io import pytest from datetime import datetime from decoy import Decoy, matchers -from fastapi import UploadFile +from fastapi import HTTPException, UploadFile from pathlib import Path from opentrons.protocol_engine.types import RunTimeParamValuesType, NumberParameter @@ -45,6 +46,7 @@ Metadata, Protocol, ProtocolFile, + ProtocolKind, ProtocolType, ) from robot_server.protocols.protocol_store import ( @@ -146,6 +148,7 @@ async def test_get_protocols( content_hash="a_b_c", ), protocol_key="dummy-key-111", + protocol_kind=ProtocolKind.STANDARD.value, ) resource_2 = ProtocolResource( protocol_id="123", @@ -160,6 +163,7 @@ async def test_get_protocols( content_hash="1_2_3", ), protocol_key="dummy-key-222", + protocol_kind=ProtocolKind.STANDARD.value, ) analysis_1 = AnalysisSummary(id="analysis-id-abc", status=AnalysisStatus.PENDING) @@ -168,6 +172,7 @@ async def test_get_protocols( expected_protocol_1 = Protocol( id="abc", createdAt=created_at_1, + protocolKind=ProtocolKind.STANDARD, protocolType=ProtocolType.PYTHON, metadata=Metadata(), robotType="OT-2 Standard", @@ -178,6 +183,7 @@ async def test_get_protocols( expected_protocol_2 = Protocol( id="123", createdAt=created_at_2, + protocolKind=ProtocolKind.STANDARD, protocolType=ProtocolType.JSON, metadata=Metadata(), robotType="OT-3 Standard", @@ -255,6 +261,7 @@ async def test_get_protocol_by_id( content_hash="a_b_c", ), protocol_key="dummy-key-111", + protocol_kind=ProtocolKind.STANDARD.value, ) analysis_summary = AnalysisSummary( @@ -279,6 +286,7 @@ async def test_get_protocol_by_id( assert result.content.data == Protocol( id="protocol-id", createdAt=datetime(year=2021, month=1, day=1), + protocolKind=ProtocolKind.STANDARD, protocolType=ProtocolType.PYTHON, metadata=Metadata(), robotType="OT-2 Standard", @@ -349,6 +357,7 @@ async def test_create_existing_protocol( created_at=datetime(year=2020, month=1, day=1), source=protocol_source, protocol_key="dummy-key-222", + protocol_kind=ProtocolKind.STANDARD.value, ) completed_analysis = AnalysisSummary( @@ -399,6 +408,7 @@ async def test_create_existing_protocol( assert result.content.data == Protocol( id="the-og-proto-id", createdAt=datetime(year=2020, month=1, day=1), + protocolKind=ProtocolKind.STANDARD, protocolType=ProtocolType.JSON, metadata=Metadata(this_is_fake_metadata=True), # type: ignore[call-arg] robotType="OT-2 Standard", @@ -447,6 +457,7 @@ async def test_create_protocol( created_at=datetime(year=2021, month=1, day=1), source=protocol_source, protocol_key="dummy-key-111", + protocol_kind=ProtocolKind.STANDARD.value, ) pending_analysis = AnalysisSummary( @@ -501,6 +512,7 @@ async def test_create_protocol( assert result.content.data == Protocol( id="protocol-id", createdAt=datetime(year=2021, month=1, day=1), + protocolKind=ProtocolKind.STANDARD, protocolType=ProtocolType.JSON, metadata=Metadata(this_is_fake_metadata=True), # type: ignore[call-arg] robotType="OT-2 Standard", @@ -554,6 +566,7 @@ async def test_create_new_protocol_with_run_time_params( created_at=datetime(year=2021, month=1, day=1), source=protocol_source, protocol_key="dummy-key-111", + protocol_kind=ProtocolKind.STANDARD.value, ) run_time_parameter = NumberParameter( displayName="My parameter", @@ -658,6 +671,7 @@ async def test_create_existing_protocol_with_no_previous_analysis( created_at=datetime(year=2020, month=1, day=1), source=protocol_source, protocol_key="dummy-key-222", + protocol_kind=ProtocolKind.STANDARD.value, ) run_time_parameter = NumberParameter( displayName="My parameter", @@ -720,6 +734,7 @@ async def test_create_existing_protocol_with_no_previous_analysis( assert result.content.data == Protocol( id="the-og-proto-id", createdAt=datetime(year=2020, month=1, day=1), + protocolKind=ProtocolKind.STANDARD, protocolType=ProtocolType.JSON, metadata=Metadata(this_is_fake_metadata=True), # type: ignore[call-arg] robotType="OT-2 Standard", @@ -768,6 +783,7 @@ async def test_create_existing_protocol_with_different_run_time_params( created_at=datetime(year=2020, month=1, day=1), source=protocol_source, protocol_key="dummy-key-222", + protocol_kind=ProtocolKind.STANDARD.value, ) completed_summary = AnalysisSummary( @@ -839,6 +855,7 @@ async def test_create_existing_protocol_with_different_run_time_params( assert result.content.data == Protocol( id="the-og-proto-id", createdAt=datetime(year=2020, month=1, day=1), + protocolKind=ProtocolKind.STANDARD, protocolType=ProtocolType.JSON, metadata=Metadata(this_is_fake_metadata=True), # type: ignore[call-arg] robotType="OT-2 Standard", @@ -887,6 +904,7 @@ async def test_create_existing_protocol_with_same_run_time_params( created_at=datetime(year=2020, month=1, day=1), source=protocol_source, protocol_key="dummy-key-222", + protocol_kind=ProtocolKind.STANDARD.value, ) analysis_summaries = [ @@ -940,6 +958,7 @@ async def test_create_existing_protocol_with_same_run_time_params( assert result.content.data == Protocol( id="the-og-proto-id", createdAt=datetime(year=2020, month=1, day=1), + protocolKind=ProtocolKind.STANDARD, protocolType=ProtocolType.JSON, metadata=Metadata(this_is_fake_metadata=True), # type: ignore[call-arg] robotType="OT-2 Standard", @@ -988,6 +1007,7 @@ async def test_create_existing_protocol_with_pending_analysis_raises( created_at=datetime(year=2020, month=1, day=1), source=protocol_source, protocol_key="dummy-key-222", + protocol_kind=ProtocolKind.STANDARD.value, ) analysis_summaries = [ @@ -1427,6 +1447,7 @@ async def test_update_protocol_analyses_with_new_rtp_values( created_at=datetime(year=2020, month=1, day=1), source=protocol_source, protocol_key="dummy-key-222", + protocol_kind=ProtocolKind.STANDARD.value, ) analysis_summaries = [ AnalysisSummary( @@ -1523,6 +1544,7 @@ async def test_update_protocol_analyses_with_forced_reanalysis( created_at=datetime(year=2020, month=1, day=1), source=protocol_source, protocol_key="dummy-key-222", + protocol_kind=ProtocolKind.STANDARD.value, ) decoy.when(protocol_store.has(protocol_id="protocol-id")).then_return(True) decoy.when( @@ -1552,3 +1574,157 @@ async def test_update_protocol_analyses_with_forced_reanalysis( AnalysisSummary(id="analysis-id-2", status=AnalysisStatus.PENDING), ] assert result.status_code == 201 + + +async def test_create_protocol_kind_quick_transfer( + decoy: Decoy, + protocol_store: ProtocolStore, + analysis_store: AnalysisStore, + protocol_reader: ProtocolReader, + file_reader_writer: FileReaderWriter, + file_hasher: FileHasher, + analyses_manager: AnalysesManager, + protocol_auto_deleter: ProtocolAutoDeleter, +) -> None: + """It should store an uploaded protocol file marked as quick-transfer.""" + protocol_directory = Path("/dev/null") + content = bytes("some_content", encoding="utf-8") + uploaded_file = io.BytesIO(content) + + protocol_file = UploadFile(filename="foo.json", file=uploaded_file) + buffered_file = BufferedFile(name="blah", contents=content, path=None) + + protocol_source = ProtocolSource( + directory=Path("/dev/null"), + main_file=Path("/dev/null/foo.json"), + files=[ + ProtocolSourceFile( + path=Path("/dev/null/foo.json"), + role=ProtocolFileRole.MAIN, + ) + ], + metadata={"this_is_fake_metadata": True}, + robot_type="OT-3 Standard", + config=JsonProtocolConfig(schema_version=123), + content_hash="a_b_c", + ) + + protocol_resource = ProtocolResource( + protocol_id="protocol-id", + created_at=datetime(year=2021, month=1, day=1), + source=protocol_source, + protocol_key="dummy-key-111", + protocol_kind=ProtocolKind.QUICK_TRANSFER.value, + ) + run_time_parameter = NumberParameter( + displayName="My parameter", + variableName="cool_param", + type="int", + min=1, + max=5, + value=2.0, + default=3.0, + ) + pending_analysis = AnalysisSummary( + id="analysis-id", + status=AnalysisStatus.PENDING, + runTimeParameters=[run_time_parameter], + ) + decoy.when( + await file_reader_writer.read( + # TODO(mm, 2024-02-07): Recent FastAPI upgrades mean protocol_file.filename + # is typed as possibly None. Investigate whether that can actually happen in + # practice and whether we need to account for it. + files=[protocol_file] # type: ignore[list-item] + ) + ).then_return([buffered_file]) + + decoy.when(await file_hasher.hash(files=[buffered_file])).then_return("abc123") + + decoy.when( + await protocol_reader.save( + files=[buffered_file], + directory=protocol_directory / "protocol-id", + content_hash="abc123", + ) + ).then_return(protocol_source) + decoy.when( + await analyses_manager.start_analysis( + analysis_id="analysis-id", + protocol_resource=protocol_resource, + run_time_param_values={}, + ) + ).then_return(pending_analysis) + decoy.when(protocol_store.get_all()).then_return([]) + + result = await create_protocol( + files=[protocol_file], + key="dummy-key-111", + run_time_parameter_values="{}", + protocol_directory=protocol_directory, + protocol_store=protocol_store, + analysis_store=analysis_store, + file_reader_writer=file_reader_writer, + protocol_reader=protocol_reader, + file_hasher=file_hasher, + analyses_manager=analyses_manager, + protocol_auto_deleter=protocol_auto_deleter, + robot_type="OT-3 Standard", + protocol_kind=ProtocolKind.QUICK_TRANSFER.value, + protocol_id="protocol-id", + analysis_id="analysis-id", + created_at=datetime(year=2021, month=1, day=1), + ) + + decoy.verify( + protocol_auto_deleter.make_room_for_new_protocol(), + protocol_store.insert(protocol_resource), + ) + + assert result.content.data == Protocol( + id="protocol-id", + createdAt=datetime(year=2021, month=1, day=1), + protocolKind=ProtocolKind.QUICK_TRANSFER, + protocolType=ProtocolType.JSON, + metadata=Metadata(this_is_fake_metadata=True), # type: ignore[call-arg] + robotType="OT-3 Standard", + analysisSummaries=[pending_analysis], + files=[ProtocolFile(name="foo.json", role=ProtocolFileRole.MAIN)], + key="dummy-key-111", + ) + assert result.status_code == 201 + + +async def test_create_protocol_kind_invalid( + protocol_store: ProtocolStore, + analysis_store: AnalysisStore, + protocol_reader: ProtocolReader, + file_reader_writer: FileReaderWriter, + file_hasher: FileHasher, + protocol_auto_deleter: ProtocolAutoDeleter, +) -> None: + """It should throw a 400 error if the protocol kind is invalid.""" + protocol_directory = Path("/dev/null") + content = bytes("some_content", encoding="utf-8") + uploaded_file = io.BytesIO(content) + protocol_file = UploadFile(filename="foo.json", file=uploaded_file) + + with pytest.raises(HTTPException) as exc_info: + await create_protocol( + files=[protocol_file], + key="dummy-key-111", + protocol_directory=protocol_directory, + protocol_store=protocol_store, + analysis_store=analysis_store, + file_reader_writer=file_reader_writer, + protocol_reader=protocol_reader, + file_hasher=file_hasher, + protocol_auto_deleter=protocol_auto_deleter, + robot_type="OT-3 Standard", + protocol_id="protocol-id", + analysis_id="analysis-id", + protocol_kind="invalid", + created_at=datetime(year=2021, month=1, day=1), + ) + + assert exc_info.value.status_code == 400 diff --git a/robot-server/tests/runs/router/test_base_router.py b/robot-server/tests/runs/router/test_base_router.py index 5763935cc39..a42692e4a0e 100644 --- a/robot-server/tests/runs/router/test_base_router.py +++ b/robot-server/tests/runs/router/test_base_router.py @@ -130,6 +130,7 @@ async def test_create_protocol_run( protocol_resource = ProtocolResource( protocol_id=protocol_id, protocol_key=None, + protocol_kind=None, created_at=datetime(year=2022, month=2, day=2), source=ProtocolSource( directory=Path("/dev/null"), diff --git a/robot-server/tests/runs/test_run_data_manager.py b/robot-server/tests/runs/test_run_data_manager.py index 853110e0ca5..e0f4fae523c 100644 --- a/robot-server/tests/runs/test_run_data_manager.py +++ b/robot-server/tests/runs/test_run_data_manager.py @@ -215,6 +215,7 @@ async def test_create_with_options( created_at=datetime(year=2022, month=2, day=2), source=None, # type: ignore[arg-type] protocol_key=None, + protocol_kind="standard", ) labware_offset = pe_types.LabwareOffsetCreate(