diff --git a/README.md b/README.md index 58a7074..825daaf 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ pip install -e .[dev] ## Usage ### VScode -![](example-usage.gif) +![Example usage](example-usage.gif) Requires the [pylance extension](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance) to be installed for similar functionality. diff --git a/src/aind_slims_api/core.py b/src/aind_slims_api/core.py index 82cc13a..e98feb9 100644 --- a/src/aind_slims_api/core.py +++ b/src/aind_slims_api/core.py @@ -22,7 +22,7 @@ from aind_slims_api import config from aind_slims_api.exceptions import SlimsRecordNotFound -from aind_slims_api.models.attachment import SlimsAttachment +from aind_slims_api.models import SlimsAttachment from aind_slims_api.models.base import SlimsBaseModel from aind_slims_api.types import SLIMS_TABLES @@ -105,10 +105,20 @@ def resolve_model_alias( model: Type[SlimsBaseModelTypeVar], attr_name: str, ) -> str: - """Given a SlimsBaseModel object, resolve its pk to the actual value""" + """Given a SlimsBaseModel object, resolve its pk to the actual value + + Notes + ----- + - Raises ValueError if the alias cannot be resolved + - Resolves the validation alias for a given field name + """ for field_name, field_info in model.model_fields.items(): - if field_name == attr_name and field_info.alias: - return field_info.alias + if ( + field_name == attr_name + and field_info.validation_alias + and isinstance(field_info.validation_alias, str) + ): + return field_info.validation_alias else: raise ValueError(f"Cannot resolve alias for {attr_name} on {model}") diff --git a/src/aind_slims_api/models/attachment.py b/src/aind_slims_api/models/attachment.py index c6787de..30204d2 100644 --- a/src/aind_slims_api/models/attachment.py +++ b/src/aind_slims_api/models/attachment.py @@ -11,9 +11,10 @@ class SlimsAttachment(SlimsBaseModel): Examples -------- >>> from aind_slims_api import SlimsClient + >>> from aind_slims_api import models >>> client = SlimsClient() >>> rig_metadata_attachment = client.fetch_model( - ... SlimsAttachment, + ... models.SlimsAttachment, ... name="rig323_EPHYS1_OPTO_2024-02-12.json" ... ) >>> rig_metadata = client.fetch_attachment_content( @@ -23,6 +24,14 @@ class SlimsAttachment(SlimsBaseModel): '323_EPHYS1_OPTO_2024-02-12' """ - pk: int = Field(..., alias="attm_pk") - name: str = Field(alias="attm_name") + pk: int = Field( + ..., + serialization_alias="attm_pk", + validation_alias="attm_pk", + ) + name: str = Field( + ..., + serialization_alias="attm_name", + validation_alias="attm_name", + ) _slims_table = "Attachment" diff --git a/src/aind_slims_api/models/base.py b/src/aind_slims_api/models/base.py index 527ef0c..e4de97a 100644 --- a/src/aind_slims_api/models/base.py +++ b/src/aind_slims_api/models/base.py @@ -18,6 +18,7 @@ class SlimsBaseModel( BaseModel, from_attributes=True, validate_assignment=True, + populate_by_name=True, ): """Pydantic model to represent a SLIMS record. Subclass with fields matching those in the SLIMS record. diff --git a/src/aind_slims_api/models/behavior_session.py b/src/aind_slims_api/models/behavior_session.py index 7d46955..3e9df62 100644 --- a/src/aind_slims_api/models/behavior_session.py +++ b/src/aind_slims_api/models/behavior_session.py @@ -4,7 +4,7 @@ import logging from datetime import datetime -from typing import ClassVar +from typing import ClassVar, Optional from pydantic import Field @@ -16,6 +16,27 @@ class SlimsBehaviorSession(SlimsBaseModel): """Model for an instance of the Behavior Session ContentEvent + Properties + ---------- + mouse_pk : Optional[int] + The primary key of the mouse associated with this behavior session. + instrument_pk : Optional[int] + The primary key of the instrument associated with this behavior session. + trainer_pks : Optional[list[int]] + The primary keys of the trainers associated with this behavior session. + task : Optional[str] + Name of the task associated with the session. + task_stage : Optional[str] + Name of the stage associated with the session. + task_schema_version : Optional[str] + Version of the task schema. + is_curriculum_suggestion : Optional[bool] + Whether the session is a curriculum suggestion. + date : Optional[datetime] + Date of the suggestion. + notes : Optional[str] + Notes about the session. + Examples -------- Read a session. @@ -35,15 +56,15 @@ class SlimsBehaviorSession(SlimsBaseModel): >>> instrument = client.fetch_model(SlimsInstrument, name="323_EPHYS1_OPTO") >>> added = client.add_model( ... SlimsBehaviorSession( - ... cnvn_fk_content=mouse.pk, - ... cnvn_cf_fk_instrument=instrument.pk, - ... cnvn_cf_fk_trainer=[trainer.pk], - ... cnvn_cf_notes="notes", - ... cnvn_cf_taskStage="stage", - ... cnvn_cf_task="task", - ... cnvn_cf_taskSchemaVersion="0.0.1", - ... cnvn_cf_stageIsOnCurriculum=True, - ... cnvn_cf_scheduledDate=datetime(2021, 1, 2), + ... mouse_pk=mouse.pk, + ... instrument_pk=instrument.pk, + ... trainer_pks=[trainer.pk], + ... notes="notes", + ... task_stage="stage", + ... task="task", + ... task_schema_version="0.0.1", + ... is_curriculum_suggestion=True, + ... date=datetime(2021, 1, 2), ... ) ... ) @@ -57,27 +78,64 @@ class SlimsBehaviorSession(SlimsBaseModel): ... ) """ - pk: int | None = Field(default=None, alias="cnvn_pk") - mouse_pk: int | None = Field( + pk: Optional[int] = Field( + default=None, + serialization_alias="cnvn_pk", + validation_alias="cnvn_pk", + ) + mouse_pk: Optional[int] = Field( default=None, - alias="cnvn_fk_content", + serialization_alias="cnvn_fk_content", + validation_alias="cnvn_fk_content", description=( "The primary key of the mouse associated with this behavior session." ), ) # used as reference to mouse - notes: str | None = Field(default=None, alias="cnvn_cf_notes") - task_stage: str | None = Field(default=None, alias="cnvn_cf_taskStage") - instrument: int | None = Field(default=None, alias="cnvn_cf_fk_instrument") - trainers: list[int] = Field(default=[], alias="cnvn_cf_fk_trainer") - task: str | None = Field(default=None, alias="cnvn_cf_task") - is_curriculum_suggestion: bool | None = Field( - default=None, alias="cnvn_cf_stageIsOnCurriculum" + notes: Optional[str] = Field( + default=None, + serialization_alias="cnvn_cf_notes", + validation_alias="cnvn_cf_notes", + ) + task_stage: Optional[str] = Field( + default=None, + serialization_alias="cnvn_cf_taskStage", + validation_alias="cnvn_cf_taskStage", + ) + instrument_pk: Optional[int] = Field( + default=None, + serialization_alias="cnvn_fk_instrument", + validation_alias="cnvn_fk_instrument", + ) + trainer_pks: Optional[list[int]] = Field( + default=[], + serialization_alias="cnvn_cf_fk_trainer", + validation_alias="cnvn_cf_fk_trainer", ) - task_schema_version: str | None = Field( - default=None, alias="cnvn_cf_taskSchemaVersion" + task: Optional[str] = Field( + default=None, + serialization_alias="cnvn_cf_task", + validation_alias="cnvn_cf_task", + ) + is_curriculum_suggestion: Optional[bool] = Field( + default=None, + serialization_alias="cnvn_cf_stageIsOnCurriculum", + validation_alias="cnvn_cf_stageIsOnCurriculum", + ) + task_schema_version: Optional[str] = Field( + default=None, + serialization_alias="cnvn_cf_taskSchemaVersion", + validation_alias="cnvn_cf_taskSchemaVersion", + ) + software_version: Optional[str] = Field( + default=None, + serialization_alias="cnvn_cf_softwareVersion", + validation_alias="cnvn_cf_softwareVersion", + ) + date: Optional[datetime] = Field( + default=None, + serialization_alias="cnvn_cf_scheduledDate", + validation_alias="cnvn_cf_scheduledDate", ) - software_version: str | None = Field(default=None, alias="cnvn_cf_softwareVersion") - date: datetime | None = Field(default=None, alias="cnvn_cf_scheduledDate") cnvn_fk_contentEventType: int = 10 # pk of Behavior Session ContentEvent _slims_table = "ContentEvent" _base_fetch_filters: ClassVar[dict[str, str]] = { diff --git a/src/aind_slims_api/models/instrument.py b/src/aind_slims_api/models/instrument.py index bb47ce4..a68c1b1 100644 --- a/src/aind_slims_api/models/instrument.py +++ b/src/aind_slims_api/models/instrument.py @@ -1,5 +1,7 @@ """Contains a model for the instrument content, and a method for fetching it""" +from typing import Optional + from pydantic import Field from aind_slims_api.models.base import SlimsBaseModel @@ -15,12 +17,18 @@ class SlimsInstrument(SlimsBaseModel): >>> instrument = client.fetch_model(SlimsInstrument, name="323_EPHYS1_OPTO") """ + # can't use alias for this due to https://github.com/pydantic/pydantic/issues/5893 name: str = Field( ..., - alias="nstr_name", + serialization_alias="nstr_name", + validation_alias="nstr_name", description="The name of the instrument", ) - pk: int = Field(..., alias="nstr_pk") + pk: Optional[int] = Field( + default=None, + serialization_alias="nstr_pk", + validation_alias="nstr_pk", + ) _slims_table = "Instrument" # todo add more useful fields diff --git a/src/aind_slims_api/models/mouse.py b/src/aind_slims_api/models/mouse.py index 33a5442..daa6233 100644 --- a/src/aind_slims_api/models/mouse.py +++ b/src/aind_slims_api/models/mouse.py @@ -1,6 +1,6 @@ """Contains a model for the mouse content, and a method for fetching it""" -from typing import Annotated, ClassVar +from typing import Annotated, ClassVar, Optional from pydantic import BeforeValidator, Field @@ -23,14 +23,30 @@ class SlimsMouseContent(SlimsBaseModel): """ baseline_weight_g: Annotated[float | None, UnitSpec("g")] = Field( - ..., alias="cntn_cf_baselineWeight" + ..., + serialization_alias="cntn_cf_baselineWeight", + validation_alias="cntn_cf_baselineWeight", + ) + point_of_contact: Optional[str] = Field( + ..., + serialization_alias="cntn_cf_scientificPointOfContact", + validation_alias="cntn_cf_scientificPointOfContact", ) - point_of_contact: str | None = Field(..., alias="cntn_cf_scientificPointOfContact") water_restricted: Annotated[bool, BeforeValidator(lambda x: x or False)] = Field( - ..., alias="cntn_cf_waterRestricted" + ..., + serialization_alias="cntn_cf_waterRestricted", + validation_alias="cntn_cf_waterRestricted", + ) + barcode: str = Field( + ..., + serialization_alias="cntn_barCode", + validation_alias="cntn_barCode", + ) + pk: Optional[int] = Field( + default=None, + serialization_alias="cntn_pk", + validation_alias="cntn_pk", ) - barcode: str = Field(..., alias="cntn_barCode") - pk: int = Field(..., alias="cntn_pk") _slims_table = "Content" _base_fetch_filters: ClassVar[dict[str, str]] = { diff --git a/src/aind_slims_api/models/unit.py b/src/aind_slims_api/models/unit.py index e3771bc..692521e 100644 --- a/src/aind_slims_api/models/unit.py +++ b/src/aind_slims_api/models/unit.py @@ -10,8 +10,20 @@ class SlimsUnit(SlimsBaseModel): """Model for unit information in SLIMS""" - name: str = Field(..., alias="unit_name") - abbreviation: Optional[str] = Field("", alias="unit_abbreviation") - pk: int = Field(..., alias="unit_pk") + name: str = Field( + ..., + serialization_alias="unit_name", + validation_alias="unit_name", + ) + abbreviation: Optional[str] = Field( + "", + serialization_alias="unit_abbreviation", + validation_alias="unit_abbreviation", + ) + pk: int = Field( + ..., + serialization_alias="unit_pk", + validation_alias="unit_pk", + ) _slims_table = "Unit" diff --git a/src/aind_slims_api/models/user.py b/src/aind_slims_api/models/user.py index e8eb09b..2f21553 100644 --- a/src/aind_slims_api/models/user.py +++ b/src/aind_slims_api/models/user.py @@ -18,11 +18,35 @@ class SlimsUser(SlimsBaseModel): >>> user = client.fetch_model(SlimsUser, username="LKim") """ - username: str = Field(..., alias="user_userName") - first_name: Optional[str] = Field("", alias="user_firstName") - last_name: Optional[str] = Field("", alias="user_lastName") - full_name: Optional[str] = Field("", alias="user_fullName") - email: Optional[str] = Field("", alias="user_email") - pk: int = Field(..., alias="user_pk") + username: str = Field( + ..., + serialization_alias="user_userName", + validation_alias="user_userName", + ) + first_name: Optional[str] = Field( + "", + serialization_alias="user_firstName", + validation_alias="user_firstName", + ) + last_name: Optional[str] = Field( + "", + serialization_alias="user_lastName", + validation_alias="user_lastName", + ) + full_name: Optional[str] = Field( + "", + serialization_alias="user_fullName", + validation_alias="user_fullName", + ) + email: Optional[str] = Field( + "", + serialization_alias="user_email", + validation_alias="user_email", + ) + pk: int = Field( + ..., + serialization_alias="user_pk", + validation_alias="user_pk", + ) _slims_table = "User" diff --git a/src/aind_slims_api/write_models.py b/src/aind_slims_api/write_models.py deleted file mode 100644 index e606a28..0000000 --- a/src/aind_slims_api/write_models.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Model writing utilities adding records to the SLIMS database. - -Notes ------ -- Here due to restructuring of models, will likely be deprecated in the future. -""" - -import logging - -from aind_slims_api.core import SlimsClient -from aind_slims_api.models import ( - SlimsBehaviorSession, - SlimsInstrument, - SlimsMouseContent, - SlimsUser, -) - -logger = logging.getLogger() - - -def write_behavior_session_content_events( - client: SlimsClient, - mouse: SlimsMouseContent, - instrument: SlimsInstrument, - trainers: list[SlimsUser], - *behavior_sessions: SlimsBehaviorSession, -) -> list[SlimsBehaviorSession]: - """Writes behavior sessions to the SLIMS database. - - Notes - ----- - - Here due to restructuring of models, will likely be deprecated in the - future. - """ - trainer_pks = [trainer.pk for trainer in trainers] - logger.debug(f"Trainer pks: {trainer_pks}") - added = [] - for behavior_session in behavior_sessions: - updated = behavior_session.model_copy( - update={ - "mouse_pk": mouse.pk, - "instrument": instrument.pk, - "trainers": trainer_pks, - }, - ) - logger.debug(f"Resolved behavior session: {updated}") - added.append(client.add_model(updated)) - - return added diff --git a/tests/test_behavior_session.py b/tests/test_behavior_session.py index 0dced64..72f8398 100644 --- a/tests/test_behavior_session.py +++ b/tests/test_behavior_session.py @@ -5,7 +5,7 @@ import unittest from datetime import datetime from pathlib import Path -from unittest.mock import MagicMock, call, patch +from unittest.mock import MagicMock, patch from slims.internal import Record @@ -14,7 +14,6 @@ from aind_slims_api.models.instrument import SlimsInstrument from aind_slims_api.models.mouse import SlimsMouseContent from aind_slims_api.models.user import SlimsUser -from aind_slims_api.write_models import write_behavior_session_content_events RESOURCES_DIR = Path(os.path.dirname(os.path.realpath(__file__))) / "resources" @@ -125,24 +124,6 @@ def test_fetch_behavior_session_content_events_success_sort_list( [item.json_entity for item in validated], ) - @patch("aind_slims_api.core.logger") - @patch("slims.slims.Slims.add") - def test_write_behavior_session_content_events_success( - self, mock_add: MagicMock, mock_log_info: MagicMock - ): - """Test write_behavior_session_content_events success""" - mock_add.return_value = self.example_write_sessions_response - added = write_behavior_session_content_events( - self.example_client, - self.example_mouse, - self.example_instrument, - [self.example_trainer], - *self.example_behavior_sessions, - ) - self.assertTrue(all((item.mouse_pk == self.example_mouse.pk for item in added))) - self.assertTrue(len(added) == len(self.example_behavior_sessions)) - mock_log_info.assert_has_calls([call.info("SLIMS Add: ContentEvent/79")]) - if __name__ == "__main__": unittest.main() diff --git a/tests/test_slimsmodel.py b/tests/test_slimsmodel.py index 53e1925..cb1e919 100644 --- a/tests/test_slimsmodel.py +++ b/tests/test_slimsmodel.py @@ -2,7 +2,7 @@ import unittest from datetime import datetime -from typing import Annotated +from typing import Annotated, Optional from pydantic import Field from slims.internal import Column, Record @@ -72,8 +72,16 @@ def test_alias(self): class TestModelAlias(SlimsBaseModel): """model with field aliases""" - field: str = Field(..., alias="alias") - pk: int = Field(None, alias="cntn_pk") + field: str = Field( + ..., + serialization_alias="alias", + validation_alias="alias", + ) + pk: Optional[int] = Field( + None, + serialization_alias="cntn_pk", + validation_alias="cntn_pk", + ) record = Record( json_entity={ @@ -82,7 +90,12 @@ class TestModelAlias(SlimsBaseModel): "datatype": "STRING", "name": "alias", "value": "value", - } + }, + { + "datatype": "INTEGER", + "name": "cntn_pk", + "value": 1, + }, ] }, slims_api=None,