From 5ed1f4cff2f9f6fccf9f81fce9228c1537a4462a Mon Sep 17 00:00:00 2001 From: Patrick Latimer <110747402+patricklatimer@users.noreply.github.com> Date: Fri, 14 Jun 2024 15:26:35 -0700 Subject: [PATCH 1/7] Generic Client (#10) * Initial add * Update dependencies * refactor: expand SlimsBaseModel and client * feat: add mouse model and fetch method * feat: add user model and fetch method * Feat client unit tests (#11) * docs: set line length to 88 * tests: adds test coverage --------- Co-authored-by: jtyoung84 <104453205+jtyoung84@users.noreply.github.com> --- .flake8 | 1 + .github/workflows/test_and_lint.yml | 2 +- .gitignore | 1 + doc_template/source/conf.py | 7 +- pyproject.toml | 13 +- src/aind_slims_api/__init__.py | 7 + src/aind_slims_api/configuration.py | 16 + src/aind_slims_api/core.py | 135 +++ src/aind_slims_api/mouse.py | 33 + src/aind_slims_api/user.py | 33 + .../example_fetch_mouse_response.json | 960 ++++++++++++++++++ .../example_fetch_unit_response.json | 472 +++++++++ .../example_fetch_user_response.json | 480 +++++++++ tests/test_configuration.py | 47 + tests/test_core.py | 172 ++++ tests/test_example.py | 16 - tests/test_mouse.py | 74 ++ tests/test_user.py | 74 ++ 18 files changed, 2519 insertions(+), 24 deletions(-) create mode 100644 src/aind_slims_api/configuration.py create mode 100644 src/aind_slims_api/core.py create mode 100644 src/aind_slims_api/mouse.py create mode 100644 src/aind_slims_api/user.py create mode 100644 tests/resources/example_fetch_mouse_response.json create mode 100644 tests/resources/example_fetch_unit_response.json create mode 100644 tests/resources/example_fetch_user_response.json create mode 100644 tests/test_configuration.py create mode 100644 tests/test_core.py delete mode 100644 tests/test_example.py create mode 100644 tests/test_mouse.py create mode 100644 tests/test_user.py diff --git a/.flake8 b/.flake8 index 6d5ce4f..b8016c4 100644 --- a/.flake8 +++ b/.flake8 @@ -4,3 +4,4 @@ exclude = __pycache__, build max-complexity = 10 +max-line-length = 88 diff --git a/.github/workflows/test_and_lint.yml b/.github/workflows/test_and_lint.yml index c8d832d..293d035 100644 --- a/.github/workflows/test_and_lint.yml +++ b/.github/workflows/test_and_lint.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ '3.8', '3.9', '3.10' ] + python-version: [ '3.10', '3.11', '3.12' ] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} diff --git a/.gitignore b/.gitignore index 06a56dd..df28b4e 100644 --- a/.gitignore +++ b/.gitignore @@ -109,6 +109,7 @@ venv/ ENV/ env.bak/ venv.bak/ +.conda # Spyder project settings .spyderproject diff --git a/doc_template/source/conf.py b/doc_template/source/conf.py index 1ecef49..820892b 100644 --- a/doc_template/source/conf.py +++ b/doc_template/source/conf.py @@ -1,12 +1,15 @@ """Configuration file for the Sphinx documentation builder.""" + # # For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html +from datetime import date + # -- Path Setup -------------------------------------------------------------- -from os.path import dirname, abspath +from os.path import abspath, dirname from pathlib import Path -from datetime import date + from aind_slims_api import __version__ as package_version INSTITUTE_NAME = "Allen Institute for Neural Dynamics" diff --git a/pyproject.toml b/pyproject.toml index b796ca6..111fbde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "aind-slims-api" description = "Generated from aind-library-template" license = {text = "MIT"} -requires-python = ">=3.7" +requires-python = ">=3.10" authors = [ {name = "Allen Institute for Neural Dynamics"} ] @@ -17,6 +17,9 @@ readme = "README.md" dynamic = ["version"] dependencies = [ + 'slims-python-api', + 'pydantic', + 'pydantic-settings' ] [project.optional-dependencies] @@ -27,7 +30,7 @@ dev = [ 'interrogate', 'isort', 'Sphinx', - 'furo' + 'furo', ] [tool.setuptools.packages.find] @@ -37,8 +40,8 @@ where = ["src"] version = {attr = "aind_slims_api.__version__"} [tool.black] -line-length = 79 -target_version = ['py36'] +line-length = 88 +target_version = ['py310'] exclude = ''' ( @@ -71,7 +74,7 @@ exclude_lines = [ fail_under = 100 [tool.isort] -line_length = 79 +line_length = 88 profile = "black" [tool.interrogate] diff --git a/src/aind_slims_api/__init__.py b/src/aind_slims_api/__init__.py index d0a8547..25ae369 100644 --- a/src/aind_slims_api/__init__.py +++ b/src/aind_slims_api/__init__.py @@ -1,2 +1,9 @@ """Init package""" + __version__ = "0.0.0" + +from aind_slims_api.configuration import AindSlimsApiSettings + +config = AindSlimsApiSettings() + +from aind_slims_api.core import SlimsClient # noqa diff --git a/src/aind_slims_api/configuration.py b/src/aind_slims_api/configuration.py new file mode 100644 index 0000000..6b6d62e --- /dev/null +++ b/src/aind_slims_api/configuration.py @@ -0,0 +1,16 @@ +""" Library Configuration model """ + +from pydantic import SecretStr +from pydantic_settings import BaseSettings + + +class AindSlimsApiSettings(BaseSettings): + """Settings for SLIMS Client + + Per pydantic-settings docs + https://docs.pydantic.dev/latest/concepts/pydantic_settings/ + Loads slims credentials from environment variables if present""" + + slims_url: str = "https://aind-test.us.slims.agilent.com/slimsrest/" + slims_username: str = "" + slims_password: SecretStr = "" diff --git a/src/aind_slims_api/core.py b/src/aind_slims_api/core.py new file mode 100644 index 0000000..d22f4ec --- /dev/null +++ b/src/aind_slims_api/core.py @@ -0,0 +1,135 @@ +"""Contents: + +Utilities for creating pydantic models for SLIMS data: + SlimsBaseModel - to be subclassed for SLIMS pydantic models + UnitSpec - To be included in a type annotation of a Quantity field + +SlimsClient - Basic wrapper around slims-python-api client with convenience + methods and integration with SlimsBaseModel subtypes +""" + +import logging +from functools import lru_cache +from typing import Literal, Optional + +from slims.criteria import Criterion, conjunction, equals +from slims.internal import Record as SlimsRecord +from slims.slims import Slims, _SlimsApiException + +from aind_slims_api import config + +logger = logging.getLogger() + +# List of slims tables manually accessed, there are many more +SLIMSTABLES = Literal[ + "Project", + "Content", + "ContentEvent", + "Unit", + "Result", + "Test", + "User", + "Groups", +] + + +class SlimsClient: + """Wrapper around slims-python-api client with convenience methods""" + + def __init__(self, url=None, username=None, password=None): + """Create object and try to connect to database""" + self.url = url or config.slims_url + self.db: Optional[Slims] = None + + self.connect( + self.url, + username or config.slims_username, + password or config.slims_password.get_secret_value(), + ) + + def connect(self, url: str, username: str, password: str): + """Connect to the database""" + self.db = Slims( + "slims", + url, + username, + password, + ) + + def fetch( + self, + table: SLIMSTABLES, + *args, + sort: Optional[str | list[str]] = None, + start: Optional[int] = None, + end: Optional[int] = None, + **kwargs, + ) -> list[SlimsRecord]: + """Fetch from the SLIMS database + + Args: + table (str): SLIMS table to query + sort (str | list[str], optional): Fields to sort by; e.g. date + start (int, optional): The first row to return + end (int, optional): The last row to return + *args (Slims.criteria.Criterion): Optional criteria to apply + **kwargs (dict[str,str]): "field=value" filters + + Returns: + records (list[SlimsRecord] | None): Matching records, if any + """ + criteria = conjunction() + for arg in args: + if isinstance(arg, Criterion): + criteria.add(arg) + + for k, v in kwargs.items(): + criteria.add(equals(k, v)) + try: + records = self.db.fetch( + table, + criteria, + sort=sort, + start=start, + end=end, + ) + except _SlimsApiException as e: + # TODO: Add better error handling + # Let's just raise error for the time being + raise e + + return records + + @lru_cache(maxsize=None) + def fetch_pk(self, table: SLIMSTABLES, *args, **kwargs) -> int | None: + """SlimsClient.fetch but returns the pk of the first returned record""" + records = self.fetch(table, *args, **kwargs) + if len(records) > 0: + return records[0].pk() + else: + return None + + def fetch_user(self, user_name: str): + """Fetches a user by username""" + return self.fetch("User", user_userName=user_name) + + def add(self, table: SLIMSTABLES, data: dict): + """Add a SLIMS record to a given SLIMS table""" + record = self.db.add(table, data) + logger.info(f"SLIMS Add: {table}/{record.pk()}") + return record + + def update(self, table: SLIMSTABLES, pk: int, data: dict): + """Update a SLIMS record""" + record = self.db.fetch_by_pk(table, pk) + if record is None: + raise ValueError(f'No data in SLIMS "{table}" table for pk "{pk}"') + new_record = record.update(data) + logger.info(f"SLIMS Update: {table}/{pk}") + return new_record + + def rest_link(self, table: SLIMSTABLES, **kwargs): + """Construct a url link to a SLIMS table with arbitrary filters""" + base_url = f"{self.url}/rest/{table}" + queries = [f"?{k}={v}" for k, v in kwargs.items()] + return base_url + "".join(queries) diff --git a/src/aind_slims_api/mouse.py b/src/aind_slims_api/mouse.py new file mode 100644 index 0000000..b9f5aaa --- /dev/null +++ b/src/aind_slims_api/mouse.py @@ -0,0 +1,33 @@ +"""Contains a model for the mouse content, and a method for fetching it""" + +import logging +from typing import Optional + +from aind_slims_api.core import SlimsClient + +logger = logging.getLogger() + + +def fetch_mouse_content( + client: SlimsClient, + mouse_name: str, +) -> Optional[dict]: + """Fetches mouse information for a mouse with labtracks id {mouse_name}""" + mice = client.fetch( + "Content", + cntp_name="Mouse", + cntn_barCode=mouse_name, + ) + + if len(mice) > 0: + mouse_details = mice[0] + if len(mice) > 1: + logger.warning( + f"Warning, Multiple mice in SLIMS with barcode " + f"{mouse_name}, using pk={mouse_details.cntn_pk.value}" + ) + else: + logger.warning("Warning, Mouse not in SLIMS") + mouse_details = None + + return None if mouse_details is None else mouse_details.json_entity diff --git a/src/aind_slims_api/user.py b/src/aind_slims_api/user.py new file mode 100644 index 0000000..1e5ac46 --- /dev/null +++ b/src/aind_slims_api/user.py @@ -0,0 +1,33 @@ +"""Contains a model for a user, and a method for fetching it""" + +import logging +from typing import Optional + +from aind_slims_api.core import SlimsClient + +logger = logging.getLogger() + + +def fetch_user( + client: SlimsClient, + username: str, +) -> Optional[dict]: + """Fetches user information for a user with username {username}""" + users = client.fetch( + "User", + user_userName=username, + ) + + if len(users) > 0: + user_details = users[0] + if len(users) > 1: + logger.warning( + f"Warning, Multiple users in SLIMS with " + f"username {[u.json_entity for u in users]}, " + f"using pk={user_details.pk()}" + ) + else: + logger.warning("Warning, User not in SLIMS") + user_details = None + + return None if user_details is None else user_details.json_entity diff --git a/tests/resources/example_fetch_mouse_response.json b/tests/resources/example_fetch_mouse_response.json new file mode 100644 index 0000000..ff82db9 --- /dev/null +++ b/tests/resources/example_fetch_mouse_response.json @@ -0,0 +1,960 @@ +[ + { + "pk": 3038, + "tableName": "Content", + "columns": [ + { + "datatype": "FOREIGN_KEY", + "name": "cntn_fk_originalContent", + "title": "Original Content", + "position": 0, + "value": null, + "hidden": true, + "editable": true, + "foreignTable": "Content", + "displayValue": null, + "displayField": "cntn_originalContentBarCode", + "foreignDisplayColumn": "cntn_id" + }, + { + "datatype": "ENUM", + "name": "icon", + "title": "Icon", + "position": 1, + "value": "data_icons/mouse.png", + "hidden": false, + "editable": true, + "displayValue": "data_icons/mouse.png" + }, + { + "datatype": "ENUM", + "name": "containerIcon", + "title": "Container icon", + "position": 2, + "value": null, + "hidden": false, + "editable": true + }, + { + "datatype": "FOREIGN_KEY", + "name": "cntn_fk_category", + "title": "Category", + "position": 3, + "value": 45, + "hidden": false, + "editable": true, + "foreignTable": "ContentType", + "displayValue": "Subjects", + "displayField": "category_name", + "foreignDisplayColumn": "cntp_name" + }, + { + "datatype": "FOREIGN_KEY", + "name": "cntn_fk_contentType", + "title": "Type", + "position": 4, + "value": 5, + "hidden": false, + "editable": false, + "foreignTable": "ContentType", + "displayValue": "Mouse", + "displayField": "cntp_name", + "foreignDisplayColumn": "cntp_name" + }, + { + "datatype": "QUANTITY", + "name": "cntn_cf_volume", + "title": "Volume", + "position": 5, + "value": null, + "hidden": false, + "editable": true, + "unit": "ml" + }, + { + "datatype": "STRING", + "name": "cntn_barCode", + "title": "Barcode", + "position": 6, + "value": "123456", + "hidden": false, + "editable": false + }, + { + "datatype": "FOREIGN_KEY", + "name": "cntn_fk_containerContentType", + "title": "Container type", + "position": 7, + "value": null, + "hidden": false, + "editable": true, + "foreignTable": "ContentType", + "displayValue": null, + "displayField": "container_name", + "foreignDisplayColumn": "cntp_name" + }, + { + "datatype": "FOREIGN_KEY", + "name": "cntn_fk_product", + "title": "Product", + "position": 8, + "value": null, + "hidden": true, + "editable": true, + "foreignTable": "Product", + "displayValue": null, + "displayField": "prdc_name", + "foreignDisplayColumn": "prdc_name" + }, + { + "datatype": "QUANTITY", + "name": "cntn_cf_baselineWeight", + "title": "Baseline Weight", + "position": 9, + "value": 25.2, + "hidden": false, + "editable": true, + "unit": "g" + }, + { + "datatype": "QUANTITY", + "name": "cntn_cf_mass", + "title": "Mass", + "position": 10, + "value": null, + "hidden": true, + "editable": true, + "unit": "g" + }, + { + "datatype": "BOOLEAN", + "name": "cntn_cf_waterRestricted", + "title": "Water Restricted", + "position": 11, + "value": null, + "hidden": false, + "editable": true + }, + { + "datatype": "FLOAT", + "name": "cntn_dilutionFactor", + "title": "Dilution Factor", + "position": 12, + "value": null, + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "cntn_id", + "title": "ID", + "position": 13, + "value": "123456", + "hidden": false, + "editable": false + }, + { + "datatype": "STRING", + "name": "cntn_cf_contactPerson", + "title": "Contact Person", + "position": 14, + "value": "PersonB", + "hidden": false, + "editable": true + }, + { + "datatype": "STRING", + "name": "cntn_cf_scientificPointOfContact", + "title": "Scientific Point of Contact", + "position": 15, + "value": "PersonC", + "hidden": false, + "editable": true + }, + { + "datatype": "FOREIGN_KEY", + "name": "cntn_fk_user", + "title": "User", + "position": 16, + "value": null, + "hidden": false, + "editable": true, + "foreignTable": "User", + "displayValue": null, + "displayField": "user_userName", + "foreignDisplayColumn": "user_userName" + }, + { + "datatype": "FLOAT", + "name": "cntn_quantity", + "title": "Quantity", + "position": 17, + "value": null, + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "cntn_unit", + "title": "Unit", + "position": 18, + "value": null, + "hidden": true, + "editable": true + }, + { + "datatype": "ENUM", + "name": "cntn_status", + "title": "Status", + "position": 19, + "value": "10", + "hidden": false, + "editable": false, + "displayValue": "Pending" + }, + { + "datatype": "FOREIGN_KEY", + "name": "cntn_fk_status", + "title": "Status", + "position": 20, + "value": 28, + "hidden": false, + "editable": true, + "foreignTable": "Status", + "displayValue": "Pending", + "displayField": "stts_name", + "foreignDisplayColumn": "stts_name" + }, + { + "datatype": "FOREIGN_KEY", + "name": "cntn_fk_location", + "title": "Location", + "position": 21, + "value": null, + "hidden": false, + "editable": true, + "foreignTable": "Location", + "displayValue": null, + "displayField": "lctn_name", + "foreignDisplayColumn": "lctn_name" + }, + { + "datatype": "FOREIGN_KEY", + "name": "cntn_fk_location_recursive", + "title": "Location (including sublocations)", + "position": 22, + "value": null, + "hidden": false, + "editable": true, + "foreignTable": "Location", + "displayValue": null, + "displayField": "lctn_name", + "foreignDisplayColumn": "lctn_name" + }, + { + "datatype": "STRING", + "name": "locationPath", + "title": "Location path", + "position": 23, + "value": null, + "hidden": false, + "editable": false + }, + { + "datatype": "FOREIGN_KEY", + "name": "cntn_fk_disease", + "title": "Disease", + "position": 24, + "value": null, + "hidden": true, + "editable": true, + "foreignTable": "Disease", + "displayValue": null, + "displayField": "diss_name", + "foreignDisplayColumn": "diss_name" + }, + { + "datatype": "FOREIGN_KEY", + "name": "cntn_fk_group", + "title": "Group", + "position": 25, + "value": null, + "hidden": false, + "editable": true, + "foreignTable": "Groups", + "displayValue": null, + "displayField": "grps_groupName", + "foreignDisplayColumn": "grps_groupName" + }, + { + "datatype": "FOREIGN_KEY", + "name": "cntn_fk_provider", + "title": "Provider", + "position": 26, + "value": null, + "hidden": true, + "editable": true, + "foreignTable": "Provider", + "displayValue": null, + "displayField": "prvd_name", + "foreignDisplayColumn": "prvd_name" + }, + { + "datatype": "STRING", + "name": "cntn_position_row", + "title": "Located at row", + "position": 27, + "value": null, + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "cntn_position_column", + "title": "Located at column", + "position": 28, + "value": null, + "hidden": true, + "editable": true + }, + { + "datatype": "DATE", + "name": "cntn_cf_dateOfBirth", + "title": "Date of birth", + "position": 29, + "value": 1715774400000, + "hidden": false, + "editable": true, + "dateFormat": "MM/dd/yyyy", + "subType": "date", + "timeZone": "America/Los_Angeles" + }, + { + "datatype": "FOREIGN_KEY", + "name": "cntn_cf_fk_fundingCode", + "title": "Project ID", + "position": 30, + "value": 1385, + "hidden": false, + "editable": true, + "foreignTable": "ReferenceDataRecord", + "displayValue": "Learning mFISH - V1 omFISH", + "displayField": "cntn_cf_fk_fundingCode_display", + "foreignDisplayColumn": "rdrc_name" + }, + { + "datatype": "STRING", + "name": "cntn_cf_genotype", + "title": "Full Genotype", + "position": 31, + "value": "SSt", + "hidden": false, + "editable": true + }, + { + "datatype": "STRING", + "name": "cntn_cf_iacucProtocol", + "title": "IACUC Protocol", + "position": 32, + "value": null, + "hidden": false, + "editable": true + }, + { + "datatype": "STRING", + "name": "cntn_cf_labtracksGroup", + "title": "LabTracks Group", + "position": 33, + "value": null, + "hidden": false, + "editable": true + }, + { + "datatype": "STRING", + "name": "cntn_cf_labtracksId", + "title": "Labtracks ID", + "position": 34, + "value": "123456", + "hidden": false, + "editable": true + }, + { + "datatype": "STRING", + "name": "cntn_cf_lightcycle", + "title": "LightCycle", + "position": 35, + "value": "Reverse Light Cycle (9 PM-9 AM light)", + "hidden": false, + "editable": true + }, + { + "datatype": "INTEGER", + "name": "cntn_cf_mouseAge", + "title": "Mouse age (mo)", + "position": 36, + "value": 29, + "hidden": false, + "editable": true + }, + { + "datatype": "STRING", + "name": "cntn_cf_parentBarcode", + "title": "Parent barcode", + "position": 37, + "value": "N/A - no parent", + "hidden": false, + "editable": true + }, + { + "datatype": "ENUM", + "name": "cntn_cf_sex", + "title": "Sex", + "position": 38, + "value": null, + "hidden": false, + "editable": true + }, + { + "datatype": "FOREIGN_KEY", + "name": "cntn_fk_product_strain", + "title": "Product (filtering without version)", + "position": 39, + "value": null, + "hidden": false, + "editable": true, + "foreignTable": "Product", + "displayValue": null, + "displayField": "prdc_name", + "foreignDisplayColumn": "prdc_name" + }, + { + "datatype": "STRING", + "name": "relationToProband", + "title": "Relation to proband", + "position": 40, + "value": null, + "hidden": false, + "editable": true + }, + { + "datatype": "FOREIGN_KEY", + "name": "father", + "title": "Father", + "position": 41, + "value": null, + "hidden": false, + "editable": true, + "foreignTable": "Content", + "displayValue": null, + "displayField": "father_display", + "foreignDisplayColumn": "cntn_id" + }, + { + "datatype": "FOREIGN_KEY", + "name": "mother", + "title": "Mother", + "position": 42, + "value": null, + "hidden": false, + "editable": true, + "foreignTable": "Content", + "displayValue": null, + "displayField": "mother_display", + "foreignDisplayColumn": "cntn_id" + }, + { + "datatype": "INTEGER", + "name": "derivedCount", + "title": "Derivation count", + "position": 43, + "value": 1, + "hidden": false, + "editable": true + }, + { + "datatype": "INTEGER", + "name": "ingredientCount", + "title": "Ingredient count", + "position": 44, + "value": 0, + "hidden": false, + "editable": true + }, + { + "datatype": "INTEGER", + "name": "mixCount", + "title": "Mix count", + "position": 45, + "value": 0, + "hidden": false, + "editable": true + }, + { + "datatype": "STRING", + "name": "cntn_createdBy", + "title": "Created by", + "position": 46, + "value": "PersonD", + "hidden": false, + "editable": true + }, + { + "datatype": "DATE", + "name": "cntn_createdOn", + "title": "Created on", + "position": 47, + "value": 1715797617414, + "hidden": false, + "editable": true, + "dateFormat": "MM/dd/yyyy HH:mm:ss", + "subType": "datetime", + "timeZone": "America/Los_Angeles" + }, + { + "datatype": "STRING", + "name": "cntn_modifiedBy", + "title": "Modified by", + "position": 48, + "value": "PersonE", + "hidden": false, + "editable": true + }, + { + "datatype": "DATE", + "name": "cntn_modifiedOn", + "title": "Modified on", + "position": 49, + "value": 1716928135809, + "hidden": false, + "editable": true, + "dateFormat": "MM/dd/yyyy HH:mm:ss", + "subType": "datetime", + "timeZone": "America/Los_Angeles" + }, + { + "datatype": "FOREIGN_KEY", + "name": "flags", + "title": "Flags", + "position": 50, + "value": null, + "hidden": false, + "editable": true, + "foreignTable": "ContentEventType", + "displayValue": null, + "displayField": "cnvt_name", + "foreignDisplayColumn": "cnvt_name" + }, + { + "datatype": "FOREIGN_KEY", + "name": "previousFlags", + "title": "Previous flags", + "position": 51, + "value": null, + "hidden": false, + "editable": true, + "foreignTable": "ContentEventType", + "displayValue": null, + "displayField": "cnvt_name", + "foreignDisplayColumn": "cnvt_name" + }, + { + "datatype": "STRING", + "name": "cntn_externalId", + "title": "External Id", + "position": 52, + "value": null, + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "lctn_name", + "title": "Name", + "position": 53, + "value": null, + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "isNaFilter", + "title": "Field is N/A", + "position": 54, + "value": null, + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "isNotNaFilter", + "title": "Field is not N/A", + "position": 55, + "value": null, + "hidden": true, + "editable": true + }, + { + "datatype": "BOOLEAN", + "name": "cntp_containerType", + "title": "Is a container type", + "position": 56, + "value": null, + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "mother_display", + "title": "mother_display", + "position": 57, + "value": null, + "hidden": true, + "editable": true + }, + { + "datatype": "INTEGER", + "name": "xprs_input", + "title": "xprs_input", + "position": 58, + "value": null, + "hidden": true, + "editable": true + }, + { + "datatype": "BOOLEAN", + "name": "cntp_canEnrollInStudy", + "title": "Can enroll in a study", + "position": 59, + "value": true, + "hidden": true, + "editable": true + }, + { + "datatype": "INTEGER", + "name": "cntn_pk", + "title": "cntn_pk", + "position": 60, + "value": 3038, + "hidden": true, + "editable": true + }, + { + "datatype": "INTEGER", + "name": "attachmentCount", + "title": "attachmentCount", + "position": 61, + "value": 0, + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "lctn_rows", + "title": "Rows", + "position": 62, + "value": null, + "hidden": true, + "editable": true + }, + { + "datatype": "BOOLEAN", + "name": "cntp_slimsGeneratesBarcode", + "title": "SLIMS generates a barcode for content of this type", + "position": 63, + "value": false, + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "prvd_name", + "title": "Name", + "position": 64, + "value": null, + "hidden": true, + "editable": true + }, + { + "datatype": "BOOLEAN", + "name": "lctp_positionLess", + "title": "Don't require positions", + "position": 65, + "value": null, + "hidden": true, + "editable": true + }, + { + "datatype": "BOOLEAN", + "name": "cntp_useBarcodeAsId", + "title": "Use barcode as id", + "position": 66, + "value": true, + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "lctn_barCode", + "title": "Barcode", + "position": 67, + "value": null, + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "diss_name", + "title": "Name", + "position": 68, + "value": null, + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "lctn_columns", + "title": "Columns", + "position": 69, + "value": null, + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "grps_groupName", + "title": "Name", + "position": 70, + "value": null, + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "stts_name", + "title": "Name", + "position": 71, + "value": "Pending", + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "cntp_name", + "title": "Name", + "position": 72, + "value": "Mouse", + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "user_userName", + "title": "User name", + "position": 73, + "value": null, + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "father_display", + "title": "father_display", + "position": 74, + "value": null, + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "cntn_originalContentBarCode", + "title": "Original content barcode", + "position": 75, + "value": null, + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "sorc_name", + "title": "Name", + "position": 76, + "value": null, + "hidden": true, + "editable": true + }, + { + "datatype": "INTEGER", + "name": "xprs_output", + "title": "xprs_output", + "position": 77, + "value": null, + "hidden": true, + "editable": true + } + ], + "canUpdate": true, + "canDelete": true, + "links": [ + { + "rel": "self", + "href": "http://fake_url/rest/Content/3038" + }, + { + "rel": "cntn_fk_category", + "href": "http://fake_url/rest/ContentType/45" + }, + { + "rel": "cntn_fk_contentType", + "href": "http://fake_url/rest/ContentType/5" + }, + { + "rel": "cntn_fk_status", + "href": "http://fake_url/rest/Status/28" + }, + { + "rel": "cntn_cf_fk_fundingCode", + "href": "http://fake_url/rest/ReferenceDataRecord/1385" + }, + { + "rel": "-prjo_fk_content", + "href": "http://fake_url/rest/PrinterJob?prjo_fk_content=3038" + }, + { + "rel": "-flfm_fk_content", + "href": "http://fake_url/rest/Fulfillment?flfm_fk_content=3038" + }, + { + "rel": "-cnvn_fk_content", + "href": "http://fake_url/rest/ContentEvent?cnvn_fk_content=3038" + }, + { + "rel": "-cnst_fk_content", + "href": "http://fake_url/rest/ContentStudy?cnst_fk_content=3038" + }, + { + "rel": "-rlrn_fk_content", + "href": "http://fake_url/rest/RuleRun?rlrn_fk_content=3038" + }, + { + "rel": "-qtus_fk_content", + "href": "http://fake_url/rest/QuantityUsage?qtus_fk_content=3038" + }, + { + "rel": "-ccpt_fk_content", + "href": "http://fake_url/rest/Occupation?ccpt_fk_content=3038" + }, + { + "rel": "-corl_fk_from", + "href": "http://fake_url/rest/ContentRelation?corl_fk_from=3038" + }, + { + "rel": "-corl_fk_to", + "href": "http://fake_url/rest/ContentRelation?corl_fk_to=3038" + }, + { + "rel": "-ssnc_fk_content", + "href": "http://fake_url/rest/SavedSelectionContent?ssnc_fk_content=3038" + }, + { + "rel": "-rdcn_fk_content", + "href": "http://fake_url/rest/OrderContent?rdcn_fk_content=3038" + }, + { + "rel": "-rqst_fk_content", + "href": "http://fake_url/rest/Request?rqst_fk_content=3038" + }, + { + "rel": "-cntn_pk", + "href": "http://fake_url/rest/Schedule?cntn_pk=3038" + }, + { + "rel": "-qlng_fk_content", + "href": "http://fake_url/rest/QueueElementIngredient?qlng_fk_content=3038" + }, + { + "rel": "-qlag_fk_content", + "href": "http://fake_url/rest/QueueElementAggregator?qlag_fk_content=3038" + }, + { + "rel": "-tpnr_fk_batch", + "href": "http://fake_url/rest/TimepointEnrollment?tpnr_fk_batch=3038" + }, + { + "rel": "-ssnr_fk_content", + "href": "http://fake_url/rest/StabilityStudyEnrollment?ssnr_fk_content=3038" + }, + { + "rel": "-qulm_fk_content", + "href": "http://fake_url/rest/QueueElement?qulm_fk_content=3038" + }, + { + "rel": "-xrsc_fk_content", + "href": "http://fake_url/rest/ExperimentRunStepContent?xrsc_fk_content=3038" + }, + { + "rel": "-kitc_fk_content", + "href": "http://fake_url/rest/KitContent?kitc_fk_content=3038" + }, + { + "rel": "-nrcn_fk_content", + "href": "http://fake_url/rest/InstrumentRunContent?nrcn_fk_content=3038" + }, + { + "rel": "-rslt_fk_content", + "href": "http://fake_url/rest/Result?rslt_fk_content=3038" + }, + { + "rel": "-rslt_cf_fk_primaryAntibody", + "href": "http://fake_url/rest/Result?rslt_cf_fk_primaryAntibody=3038" + }, + { + "rel": "-rslt_cf_fk_secondaryAntibody", + "href": "http://fake_url/rest/Result?rslt_cf_fk_secondaryAntibody=3038" + }, + { + "rel": "-rslt_cf_fk_virus", + "href": "http://fake_url/rest/Result?rslt_cf_fk_virus=3038" + }, + { + "rel": "-rslt_fk_batch", + "href": "http://fake_url/rest/Result?rslt_fk_batch=3038" + }, + { + "rel": "-ordr_cf_fk_dye", + "href": "http://fake_url/rest/Order?ordr_cf_fk_dye=3038" + }, + { + "rel": "-ordr_fk_batch", + "href": "http://fake_url/rest/Order?ordr_fk_batch=3038" + }, + { + "rel": "-cntn_fk_originalContent", + "href": "http://fake_url/rest/Content?cntn_fk_originalContent=3038" + }, + { + "rel": "-cntn_cf_fk_primaryStain", + "href": "http://fake_url/rest/Content?cntn_cf_fk_primaryStain=3038" + }, + { + "rel": "-cntn_cf_fk_secondaryStain", + "href": "http://fake_url/rest/Content?cntn_cf_fk_secondaryStain=3038" + }, + { + "rel": "-father", + "href": "http://fake_url/rest/Content?father=3038" + }, + { + "rel": "-mother", + "href": "http://fake_url/rest/Content?mother=3038" + }, + { + "rel": "-pptp_fk_content", + "href": "http://fake_url/rest/PlatePositionOutput?pptp_fk_content=3038" + }, + { + "rel": "attachments", + "href": "http://fake_url/rest/attachment/Content/3038" + } + ] + } +] \ No newline at end of file diff --git a/tests/resources/example_fetch_unit_response.json b/tests/resources/example_fetch_unit_response.json new file mode 100644 index 0000000..e14783d --- /dev/null +++ b/tests/resources/example_fetch_unit_response.json @@ -0,0 +1,472 @@ +[ + { + "pk": 31, + "tableName": "Unit", + "columns": [ + { + "datatype": "STRING", + "name": "unit_abbreviation", + "title": "Abbreviation", + "position": 0, + "value": "pm^3", + "hidden": false, + "editable": true + }, + { + "datatype": "STRING", + "name": "unit_name", + "title": "Name", + "position": 1, + "value": "picometer^3", + "hidden": false, + "editable": true + }, + { + "datatype": "FOREIGN_KEY", + "name": "unit_fk_originalUnit", + "title": "Original unit", + "position": 2, + "value": null, + "hidden": false, + "editable": true, + "foreignTable": "Unit", + "displayValue": null, + "displayField": "originalUnitAbbreviation", + "foreignDisplayColumn": "unit_abbreviation" + }, + { + "datatype": "STRING", + "name": "unit_fromOriginalUnitGroovy", + "title": "Value expression to convert from original unit", + "position": 3, + "value": null, + "hidden": false, + "editable": true + }, + { + "datatype": "STRING", + "name": "unit_toOriginalUnitGroovy", + "title": "Value expression to convert to original unit", + "position": 4, + "value": null, + "hidden": false, + "editable": true + }, + { + "datatype": "ENUM", + "name": "unit_prefix", + "title": "Prefix", + "position": 5, + "value": null, + "hidden": false, + "editable": true + }, + { + "datatype": "STRING", + "name": "unit_subDimension", + "title": "Sub dimension", + "position": 6, + "value": null, + "hidden": false, + "editable": true + }, + { + "datatype": "ENUM", + "name": "unit_type", + "title": "Type", + "position": 7, + "value": "COMPOUND", + "hidden": false, + "editable": true, + "displayValue": "Compound" + }, + { + "datatype": "FOREIGN_KEY", + "name": "unit_fk_dimension", + "title": "Dimension", + "position": 8, + "value": 5, + "hidden": false, + "editable": true, + "foreignTable": "Dimension", + "displayValue": "Volume", + "displayField": "dmns_name", + "foreignDisplayColumn": "dmns_name" + }, + { + "datatype": "STRING", + "name": "unit_createdBy", + "title": "Created by", + "position": 9, + "value": "admin", + "hidden": false, + "editable": true + }, + { + "datatype": "DATE", + "name": "unit_createdOn", + "title": "Created on", + "position": 10, + "value": 1668796217413, + "hidden": false, + "editable": true, + "dateFormat": "MM/dd/yyyy HH:mm:ss", + "subType": "datetime", + "timeZone": "America/Los_Angeles" + }, + { + "datatype": "STRING", + "name": "unit_modifiedBy", + "title": "Modified by", + "position": 11, + "value": "PersonA", + "hidden": false, + "editable": true + }, + { + "datatype": "DATE", + "name": "unit_modifiedOn", + "title": "Modified on", + "position": 12, + "value": 1674504031658, + "hidden": false, + "editable": true, + "dateFormat": "MM/dd/yyyy HH:mm:ss", + "subType": "datetime", + "timeZone": "America/Los_Angeles" + }, + { + "datatype": "INTEGER", + "name": "unit_pk", + "title": "unit_pk", + "position": 13, + "value": 31, + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "dmns_name", + "title": "Dimension", + "position": 14, + "value": "Volume", + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "unit_seqNo", + "title": "Sequence number", + "position": 15, + "value": "-33.0000000000000000000", + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "unit_subdimension", + "title": "unit_subdimension", + "position": 16, + "value": null, + "hidden": true, + "editable": true + }, + { + "datatype": "BOOLEAN", + "name": "unit_suspicious", + "title": "Suspicious", + "position": 17, + "value": false, + "hidden": true, + "editable": true + } + ], + "canUpdate": true, + "canDelete": true, + "links": [ + { + "rel": "self", + "href": "http://fake_url/rest/Unit/31" + }, + { + "rel": "unit_fk_dimension", + "href": "http://fake_url/rest/Dimension/5" + }, + { + "rel": "-tftr_fk_unit", + "href": "http://fake_url/rest/TableFieldTypeRestriction?tftr_fk_unit=31" + }, + { + "rel": "-tftr_fk_lowerUnit", + "href": "http://fake_url/rest/TableFieldTypeRestriction?tftr_fk_lowerUnit=31" + }, + { + "rel": "-tftr_fk_upperUnit", + "href": "http://fake_url/rest/TableFieldTypeRestriction?tftr_fk_upperUnit=31" + }, + { + "rel": "-tbfl_fk_unit", + "href": "http://fake_url/rest/Field?tbfl_fk_unit=31" + }, + { + "rel": "-tbfl_fk_lowerUnit", + "href": "http://fake_url/rest/Field?tbfl_fk_lowerUnit=31" + }, + { + "rel": "-tbfl_fk_upperUnit", + "href": "http://fake_url/rest/Field?tbfl_fk_upperUnit=31" + }, + { + "rel": "-unit_fk_originalUnit", + "href": "http://fake_url/rest/Unit?unit_fk_originalUnit=31" + }, + { + "rel": "-cmnl_fk_unit", + "href": "http://fake_url/rest/CompoundUnitLink?cmnl_fk_unit=31" + }, + { + "rel": "-cmnl_fk_compoundUnit", + "href": "http://fake_url/rest/CompoundUnitLink?cmnl_fk_compoundUnit=31" + }, + { + "rel": "attachments", + "href": "http://fake_url/rest/attachment/Unit/31" + } + ] + }, + { + "pk": 15, + "tableName": "Unit", + "columns": [ + { + "datatype": "STRING", + "name": "unit_abbreviation", + "title": "Abbreviation", + "position": 0, + "value": "pm^2", + "hidden": false, + "editable": true + }, + { + "datatype": "STRING", + "name": "unit_name", + "title": "Name", + "position": 1, + "value": "picometer^2", + "hidden": false, + "editable": true + }, + { + "datatype": "FOREIGN_KEY", + "name": "unit_fk_originalUnit", + "title": "Original unit", + "position": 2, + "value": null, + "hidden": false, + "editable": true, + "foreignTable": "Unit", + "displayValue": null, + "displayField": "originalUnitAbbreviation", + "foreignDisplayColumn": "unit_abbreviation" + }, + { + "datatype": "STRING", + "name": "unit_fromOriginalUnitGroovy", + "title": "Value expression to convert from original unit", + "position": 3, + "value": null, + "hidden": false, + "editable": true + }, + { + "datatype": "STRING", + "name": "unit_toOriginalUnitGroovy", + "title": "Value expression to convert to original unit", + "position": 4, + "value": null, + "hidden": false, + "editable": true + }, + { + "datatype": "ENUM", + "name": "unit_prefix", + "title": "Prefix", + "position": 5, + "value": null, + "hidden": false, + "editable": true + }, + { + "datatype": "STRING", + "name": "unit_subDimension", + "title": "Sub dimension", + "position": 6, + "value": null, + "hidden": false, + "editable": true + }, + { + "datatype": "ENUM", + "name": "unit_type", + "title": "Type", + "position": 7, + "value": "COMPOUND", + "hidden": false, + "editable": true, + "displayValue": "Compound" + }, + { + "datatype": "FOREIGN_KEY", + "name": "unit_fk_dimension", + "title": "Dimension", + "position": 8, + "value": 4, + "hidden": false, + "editable": true, + "foreignTable": "Dimension", + "displayValue": "Surface", + "displayField": "dmns_name", + "foreignDisplayColumn": "dmns_name" + }, + { + "datatype": "STRING", + "name": "unit_createdBy", + "title": "Created by", + "position": 9, + "value": "admin", + "hidden": false, + "editable": true + }, + { + "datatype": "DATE", + "name": "unit_createdOn", + "title": "Created on", + "position": 10, + "value": 1668796214042, + "hidden": false, + "editable": true, + "dateFormat": "MM/dd/yyyy HH:mm:ss", + "subType": "datetime", + "timeZone": "America/Los_Angeles" + }, + { + "datatype": "STRING", + "name": "unit_modifiedBy", + "title": "Modified by", + "position": 11, + "value": "PersonA", + "hidden": false, + "editable": true + }, + { + "datatype": "DATE", + "name": "unit_modifiedOn", + "title": "Modified on", + "position": 12, + "value": 1674504029413, + "hidden": false, + "editable": true, + "dateFormat": "MM/dd/yyyy HH:mm:ss", + "subType": "datetime", + "timeZone": "America/Los_Angeles" + }, + { + "datatype": "INTEGER", + "name": "unit_pk", + "title": "unit_pk", + "position": 13, + "value": 15, + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "dmns_name", + "title": "Dimension", + "position": 14, + "value": "Surface", + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "unit_seqNo", + "title": "Sequence number", + "position": 15, + "value": "-24.0000000000000000000", + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "unit_subdimension", + "title": "unit_subdimension", + "position": 16, + "value": null, + "hidden": true, + "editable": true + }, + { + "datatype": "BOOLEAN", + "name": "unit_suspicious", + "title": "Suspicious", + "position": 17, + "value": false, + "hidden": true, + "editable": true + } + ], + "canUpdate": true, + "canDelete": true, + "links": [ + { + "rel": "self", + "href": "http://fake_url/rest/Unit/15" + }, + { + "rel": "unit_fk_dimension", + "href": "http://fake_url/rest/Dimension/4" + }, + { + "rel": "-tftr_fk_unit", + "href": "http://fake_url/rest/TableFieldTypeRestriction?tftr_fk_unit=15" + }, + { + "rel": "-tftr_fk_lowerUnit", + "href": "http://fake_url/rest/TableFieldTypeRestriction?tftr_fk_lowerUnit=15" + }, + { + "rel": "-tftr_fk_upperUnit", + "href": "http://fake_url/rest/TableFieldTypeRestriction?tftr_fk_upperUnit=15" + }, + { + "rel": "-tbfl_fk_unit", + "href": "http://fake_url/rest/Field?tbfl_fk_unit=15" + }, + { + "rel": "-tbfl_fk_lowerUnit", + "href": "http://fake_url/rest/Field?tbfl_fk_lowerUnit=15" + }, + { + "rel": "-tbfl_fk_upperUnit", + "href": "http://fake_url/rest/Field?tbfl_fk_upperUnit=15" + }, + { + "rel": "-unit_fk_originalUnit", + "href": "http://fake_url/rest/Unit?unit_fk_originalUnit=15" + }, + { + "rel": "-cmnl_fk_unit", + "href": "http://fake_url/rest/CompoundUnitLink?cmnl_fk_unit=15" + }, + { + "rel": "-cmnl_fk_compoundUnit", + "href": "http://fake_url/rest/CompoundUnitLink?cmnl_fk_compoundUnit=15" + }, + { + "rel": "attachments", + "href": "http://fake_url/rest/attachment/Unit/15" + } + ] + } +] \ No newline at end of file diff --git a/tests/resources/example_fetch_user_response.json b/tests/resources/example_fetch_user_response.json new file mode 100644 index 0000000..09b4a6f --- /dev/null +++ b/tests/resources/example_fetch_user_response.json @@ -0,0 +1,480 @@ +[ + { + "pk": 8, + "tableName": "User", + "columns": [ + { + "datatype": "FOREIGN_KEY", + "name": "user_fk_avatar", + "title": "Avatar", + "position": 0, + "value": null, + "hidden": false, + "editable": true, + "foreignTable": "Avatar", + "displayValue": null, + "displayField": "avat_name", + "foreignDisplayColumn": "avat_name" + }, + { + "datatype": "STRING", + "name": "user_userName", + "title": "User name", + "position": 1, + "value": "PersonA", + "hidden": false, + "editable": true + }, + { + "datatype": "STRING", + "name": "user_uniqueIdentifier", + "title": "Unique identifier", + "position": 2, + "value": "user_persona", + "hidden": false, + "editable": true + }, + { + "datatype": "ENUM", + "name": "user_authenticationMethod", + "title": "Authentication method", + "position": 3, + "value": "LOCAL", + "hidden": false, + "editable": true, + "displayValue": "Local Authentication" + }, + { + "datatype": "BOOLEAN", + "name": "user_passwordDirty", + "title": "Require password change on next login", + "position": 4, + "value": false, + "hidden": false, + "editable": true + }, + { + "datatype": "FOREIGN_KEY", + "name": "user_fk_role", + "title": "Role", + "position": 5, + "value": 2, + "hidden": false, + "editable": true, + "foreignTable": "Role", + "displayValue": "Administrator", + "displayField": "role_name", + "foreignDisplayColumn": "role_name" + }, + { + "datatype": "ENUM", + "name": "user_license", + "title": "License type", + "position": 6, + "value": "NAMED", + "hidden": false, + "editable": true, + "displayValue": "Named" + }, + { + "datatype": "STRING", + "name": "user_lastName", + "title": "Last name", + "position": 7, + "value": "A", + "hidden": false, + "editable": true + }, + { + "datatype": "STRING", + "name": "user_firstName", + "title": "First name", + "position": 8, + "value": "Person", + "hidden": false, + "editable": true + }, + { + "datatype": "STRING", + "name": "user_fullName", + "title": "Full name", + "position": 9, + "value": "Person A", + "hidden": false, + "editable": true + }, + { + "datatype": "STRING", + "name": "user_email", + "title": "E-mail", + "position": 10, + "value": "person.a@fake_url", + "hidden": false, + "editable": true + }, + { + "datatype": "ENUM", + "name": "user_locale", + "title": "Language", + "position": 11, + "value": "en", + "hidden": false, + "editable": true, + "displayValue": "English" + }, + { + "datatype": "BOOLEAN", + "name": "user_browserNotifications", + "title": "Enable browser notifications", + "position": 12, + "value": true, + "hidden": false, + "editable": true + }, + { + "datatype": "ENUM", + "name": "user_notificationLevel", + "title": "Display notifications with this level or higher", + "position": 13, + "value": "INFO", + "hidden": false, + "editable": true, + "displayValue": "Info" + }, + { + "datatype": "STRING", + "name": "user_ecm3Account", + "title": "ECM Account", + "position": 14, + "value": null, + "hidden": false, + "editable": true + }, + { + "datatype": "STRING", + "name": "user_ecm3Domain", + "title": "ECM Domain", + "position": 15, + "value": null, + "hidden": false, + "editable": true + }, + { + "datatype": "STRING", + "name": "user_ecm3User", + "title": "ECM Username", + "position": 16, + "value": null, + "hidden": false, + "editable": true + }, + { + "datatype": "BOOLEAN", + "name": "user_active", + "title": "Active", + "position": 17, + "value": true, + "hidden": false, + "editable": true + }, + { + "datatype": "BOOLEAN", + "name": "user_locked", + "title": "Locked", + "position": 18, + "value": false, + "hidden": false, + "editable": true + }, + { + "datatype": "STRING", + "name": "user_createdBy", + "title": "Created by", + "position": 19, + "value": "PersonB", + "hidden": false, + "editable": true + }, + { + "datatype": "DATE", + "name": "user_createdOn", + "title": "Created on", + "position": 20, + "value": 1670355126130, + "hidden": false, + "editable": true, + "dateFormat": "MM/dd/yyyy HH:mm:ss", + "subType": "datetime", + "timeZone": "America/Los_Angeles" + }, + { + "datatype": "STRING", + "name": "user_modifiedBy", + "title": "Modified by", + "position": 21, + "value": "PersonA", + "hidden": false, + "editable": true + }, + { + "datatype": "DATE", + "name": "user_modifiedOn", + "title": "Modified on", + "position": 22, + "value": 1717788094709, + "hidden": false, + "editable": true, + "dateFormat": "MM/dd/yyyy HH:mm:ss", + "subType": "datetime", + "timeZone": "America/Los_Angeles" + }, + { + "datatype": "INTEGER", + "name": "user_fk_perspective", + "title": "Layout", + "position": 24, + "value": 42, + "hidden": true, + "editable": true + }, + { + "datatype": "INTEGER", + "name": "attachmentCount", + "title": "attachmentCount", + "position": 25, + "value": 0, + "hidden": true, + "editable": true + }, + { + "datatype": "INTEGER", + "name": "user_pk", + "title": "user_pk", + "position": 26, + "value": 8, + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "user_salt", + "title": "Salt", + "position": 27, + "value": "+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=", + "hidden": true, + "editable": true + }, + { + "datatype": "INTEGER", + "name": "user_failedLoginAttempts", + "title": "Failed login attempts", + "position": 28, + "value": 0, + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "role_name", + "title": "Name", + "position": 29, + "value": "Administrator", + "hidden": true, + "editable": true + }, + { + "datatype": "STRING", + "name": "user_timezone", + "title": "Timezone", + "position": 30, + "value": "America/Los_Angeles", + "hidden": true, + "editable": true + } + ], + "canUpdate": true, + "canDelete": true, + "links": [ + { + "rel": "self", + "href": "http://fake_url/rest/User/8" + }, + { + "rel": "user_fk_role", + "href": "http://fake_url/rest/Role/2" + }, + { + "rel": "-wdgt_fk_user", + "href": "http://fake_url/rest/Widget?wdgt_fk_user=8" + }, + { + "rel": "-prdc_fk_user", + "href": "http://fake_url/rest/Product?prdc_fk_user=8" + }, + { + "rel": "-prjo_fk_user", + "href": "http://fake_url/rest/PrinterJob?prjo_fk_user=8" + }, + { + "rel": "-wrfl_fk_user", + "href": "http://fake_url/rest/Workflow?wrfl_fk_user=8" + }, + { + "rel": "-crfr_fk_user", + "href": "http://fake_url/rest/CaseReportForm?crfr_fk_user=8" + }, + { + "rel": "-prjc_fk_user", + "href": "http://fake_url/rest/Project?prjc_fk_user=8" + }, + { + "rel": "-cnvn_fk_user", + "href": "http://fake_url/rest/ContentEvent?cnvn_fk_user=8" + }, + { + "rel": "-note_fk_user", + "href": "http://fake_url/rest/Note?note_fk_user=8" + }, + { + "rel": "-qtus_fk_user", + "href": "http://fake_url/rest/QuantityUsage?qtus_fk_user=8" + }, + { + "rel": "-evnt_fk_user", + "href": "http://fake_url/rest/Event?evnt_fk_user=8" + }, + { + "rel": "-wrkl_fk_user", + "href": "http://fake_url/rest/WorkList?wrkl_fk_user=8" + }, + { + "rel": "-dhbd_fk_user", + "href": "http://fake_url/rest/Dashboard?dhbd_fk_user=8" + }, + { + "rel": "-attm_fk_user", + "href": "http://fake_url/rest/Attachment?attm_fk_user=8" + }, + { + "rel": "-attm_fk_ecm3UploadUser", + "href": "http://fake_url/rest/Attachment?attm_fk_ecm3UploadUser=8" + }, + { + "rel": "-nfsr_fk_user", + "href": "http://fake_url/rest/NotificationUser?nfsr_fk_user=8" + }, + { + "rel": "-rprt_fk_user", + "href": "http://fake_url/rest/Report?rprt_fk_user=8" + }, + { + "rel": "-rqst_fk_assignee", + "href": "http://fake_url/rest/Request?rqst_fk_assignee=8" + }, + { + "rel": "-lnwo_fk_user", + "href": "http://fake_url/rest/ElnWorkedOn?lnwo_fk_user=8" + }, + { + "rel": "-userPk", + "href": "http://fake_url/rest/ELNTree?userPk=8" + }, + { + "rel": "-lctn_fk_user", + "href": "http://fake_url/rest/Location?lctn_fk_user=8" + }, + { + "rel": "-rdrc_fk_user", + "href": "http://fake_url/rest/ReferenceDataRecord?rdrc_fk_user=8" + }, + { + "rel": "-srgr_fk_user", + "href": "http://fake_url/rest/UserGroup?srgr_fk_user=8" + }, + { + "rel": "-rtrg_fk_user", + "href": "http://fake_url/rest/RightsUserGroup?rtrg_fk_user=8" + }, + { + "rel": "-timr_fk_user", + "href": "http://fake_url/rest/TimerRun?timr_fk_user=8" + }, + { + "rel": "-xprn_fk_user", + "href": "http://fake_url/rest/ExperimentRun?xprn_fk_user=8" + }, + { + "rel": "-bgjb_fk_user", + "href": "http://fake_url/rest/BackgroundJob?bgjb_fk_user=8" + }, + { + "rel": "-xprs_fk_user", + "href": "http://fake_url/rest/ExperimentRunStep?xprs_fk_user=8" + }, + { + "rel": "-chlg_fk_user", + "href": "http://fake_url/rest/Changelog?chlg_fk_user=8" + }, + { + "rel": "-stud_fk_user", + "href": "http://fake_url/rest/Study?stud_fk_user=8" + }, + { + "rel": "-stop_fk_user", + "href": "http://fake_url/rest/Sop?stop_fk_user=8" + }, + { + "rel": "-rslt_cf_fk_surgeon", + "href": "http://fake_url/rest/Result?rslt_cf_fk_surgeon=8" + }, + { + "rel": "-rslt_cf_fk_behaviorTrainer", + "href": "http://fake_url/rest/Result?rslt_cf_fk_behaviorTrainer=8" + }, + { + "rel": "-rslt_fk_filler", + "href": "http://fake_url/rest/Result?rslt_fk_filler=8" + }, + { + "rel": "-prsp_fk_user", + "href": "http://fake_url/rest/PerspectiveComponent?prsp_fk_user=8" + }, + { + "rel": "-ordr_fk_user", + "href": "http://fake_url/rest/Order?ordr_fk_user=8" + }, + { + "rel": "-ordr_cf_fk_histologyRequester", + "href": "http://fake_url/rest/Order?ordr_cf_fk_histologyRequester=8" + }, + { + "rel": "-xprm_fk_user", + "href": "http://fake_url/rest/Experiment?xprm_fk_user=8" + }, + { + "rel": "-cntn_fk_user", + "href": "http://fake_url/rest/Content?cntn_fk_user=8" + }, + { + "rel": "-cntn_cf_fk_histologyRequester", + "href": "http://fake_url/rest/Content?cntn_cf_fk_histologyRequester=8" + }, + { + "rel": "-flrn_fk_user", + "href": "http://fake_url/rest/FlowRun?flrn_fk_user=8" + }, + { + "rel": "-prsp_fk_user", + "href": "http://fake_url/rest/Perspective?prsp_fk_user=8" + }, + { + "rel": "-nstr_fk_user", + "href": "http://fake_url/rest/Instrument?nstr_fk_user=8" + }, + { + "rel": "attachments", + "href": "http://fake_url/rest/attachment/User/8" + } + ] + } +] \ No newline at end of file diff --git a/tests/test_configuration.py b/tests/test_configuration.py new file mode 100644 index 0000000..d1c5f92 --- /dev/null +++ b/tests/test_configuration.py @@ -0,0 +1,47 @@ +"""Tests methods in configuration module""" + +import os +import unittest +from unittest.mock import patch + +from aind_slims_api.configuration import AindSlimsApiSettings + + +class TestAindSlimsApiSettings(unittest.TestCase): + """Tests methods in AindSlimsApiSettings class""" + + def test_default_settings(self): + """Tests that the class will be set with defaults""" + default_settings = AindSlimsApiSettings() + + self.assertEqual( + "https://aind-test.us.slims.agilent.com/slimsrest/", + default_settings.slims_url, + ) + self.assertEqual("", default_settings.slims_username) + self.assertEqual("", default_settings.slims_password.get_secret_value()) + + @patch.dict( + os.environ, + { + "SLIMS_URL": "https://aind.us.slims.agilent.com/slimsrest/", + "SLIMS_PASSWORD": "password2", + "SLIMS_USERNAME": "user2", + }, + clear=True, + ) + def test_settings_from_env_vars(self): + """Tests that the class can be set from env vars""" + default_settings = AindSlimsApiSettings() + + self.assertEqual( + "https://aind.us.slims.agilent.com/slimsrest/", default_settings.slims_url + ) + self.assertEqual("user2", default_settings.slims_username) + self.assertEqual( + "password2", default_settings.slims_password.get_secret_value() + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..5445b91 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,172 @@ +""" Tests methods in core module""" + +import json +import os +import unittest +from copy import deepcopy +from pathlib import Path +from unittest.mock import MagicMock, patch + +from slims.criteria import conjunction, equals +from slims.internal import Record, _SlimsApiException + +from aind_slims_api.core import SlimsClient + +RESOURCES_DIR = Path(os.path.dirname(os.path.realpath(__file__))) / "resources" + + +class TestSlimsClient(unittest.TestCase): + """Tests methods in SlimsClient class""" + + @classmethod + def setUpClass(cls): + """Sets up class by downloading responses""" + example_client = SlimsClient( + url="http://fake_url", username="user", password="pass" + ) + cls.example_client = example_client + + def get_response(attribute_name: str): + """Utility method to download json file with {attribute_name}.json + from resource dir""" + with open( + os.path.join(str(RESOURCES_DIR), f"{attribute_name}.json"), "r" + ) as f: + response = [ + Record(json_entity=r, slims_api=example_client.db.slims_api) + for r in json.load(f) + ] + return response + + cls.example_fetch_unit_response = get_response("example_fetch_unit_response") + cls.example_fetch_mouse_response = get_response("example_fetch_mouse_response") + cls.example_fetch_user_response = get_response("example_fetch_user_response") + + def test_rest_link(self): + """Tests rest_link method with both queries and no queries.""" + + rest_link_no_queries = self.example_client.rest_link(table="Content") + rest_link_with_queries = self.example_client.rest_link( + table="Content", **{"limit": 1, "start": 0} + ) + self.assertEqual("http://fake_url/rest/Content", rest_link_no_queries) + self.assertEqual( + "http://fake_url/rest/Content?limit=1?start=0", rest_link_with_queries + ) + + @patch("slims.slims.Slims.fetch") + def test_fetch(self, mock_slims_fetch: MagicMock): + """Tests fetch method success""" + mock_slims_fetch.return_value = self.example_fetch_unit_response + response = self.example_client.fetch(table="Unit", start=0, end=2) + self.assertEqual(self.example_fetch_unit_response, response) + + @patch("slims.slims.Slims.fetch") + def test_fetch_with_criteria(self, mock_slims_fetch: MagicMock): + """Tests fetch method constructs criteria correctly""" + mock_slims_fetch.return_value = self.example_fetch_mouse_response + response = self.example_client.fetch( + "Content", + equals("cntn_barCode", "123456"), + cntp_name="Mouse", + ) + expected_criteria = ( + conjunction() + .add(equals("cntn_barCode", "123456")) + .add(equals("cntp_name", "Mouse")) + ) + actual_criteria = mock_slims_fetch.mock_calls[0].args[1] + self.assertEqual(expected_criteria.to_dict(), actual_criteria.to_dict()) + self.assertEqual(self.example_fetch_mouse_response, response) + + @patch("slims.slims.Slims.fetch") + def test_fetch_error(self, mock_slims_fetch: MagicMock): + """Tests fetch method when a _SlimsApiException is raised""" + mock_slims_fetch.side_effect = _SlimsApiException("Something went wrong") + with self.assertRaises(_SlimsApiException) as e: + self.example_client.fetch( + "Content", + cntp_name="Mouse", + ) + self.assertEqual("Something went wrong", e.exception.args[0]) + + @patch("slims.slims.Slims.fetch") + def test_fetch_user(self, mock_slims_fetch: MagicMock): + """Tests fetch_user method""" + mock_slims_fetch.return_value = self.example_fetch_user_response + response = self.example_client.fetch_user(user_name="PersonA") + self.assertEqual(self.example_fetch_user_response, response) + + @patch("slims.slims.Slims.fetch") + def test_fetch_pk(self, mock_slims_fetch: MagicMock): + """Tests fetch_pk method when several records are returned""" + # Use this example_client since result is cached + example_client = SlimsClient( + url="http://fake_url", username="user", password="pass" + ) + mock_slims_fetch.return_value = self.example_fetch_unit_response + pk = example_client.fetch_pk(table="Unit") + self.assertEqual(31, pk) + + @patch("slims.slims.Slims.fetch") + def test_fetch_pk_none(self, mock_slims_fetch: MagicMock): + """Tests fetch_pk method when no records are returned""" + # Use this example_client since result is cached + example_client = SlimsClient( + url="http://fake_url", username="user", password="pass" + ) + mock_slims_fetch.return_value = [] + pk = example_client.fetch_pk(table="Content") + self.assertIsNone(pk) + + @patch("logging.Logger.info") + @patch("slims.slims.Slims.add") + def test_add(self, mock_slims_add: MagicMock, mock_log: MagicMock): + """Tests add method""" + mock_slims_add.return_value = self.example_fetch_unit_response[0] + input_data = deepcopy(self.example_fetch_unit_response[0].json_entity) + record = self.example_client.add(table="Unit", data=input_data) + self.assertEqual(self.example_fetch_unit_response[0], record) + mock_log.assert_called_once_with("SLIMS Add: Unit/31") + + @patch("slims.slims.Slims.fetch_by_pk") + @patch("logging.Logger.info") + @patch("slims.internal.Record.update") + def test_update( + self, mock_update: MagicMock, mock_log: MagicMock, mock_fetch_by_pk: MagicMock + ): + """Tests update method success""" + input_data = deepcopy(self.example_fetch_unit_response[0].json_entity) + mock_record = Record( + json_entity=input_data, slims_api=self.example_client.db.slims_api + ) + mock_fetch_by_pk.return_value = mock_record + new_data = deepcopy(input_data) + new_data["columns"][0]["value"] = "PM^3" + mocked_updated_record = Record( + json_entity=new_data, slims_api=self.example_client.db.slims_api + ) + mock_update.return_value = mocked_updated_record + new_record = self.example_client.update(table="Unit", pk=31, data=new_data) + self.assertEqual(mocked_updated_record, new_record) + mock_log.assert_called_once_with("SLIMS Update: Unit/31") + + @patch("slims.slims.Slims.fetch_by_pk") + @patch("logging.Logger.info") + @patch("slims.internal.Record.update") + def test_update_failure( + self, mock_update: MagicMock, mock_log: MagicMock, mock_fetch_by_pk: MagicMock + ): + """Tests update method when a failure occurs""" + mock_fetch_by_pk.return_value = None + with self.assertRaises(ValueError) as e: + self.example_client.update(table="Unit", pk=30000, data={}) + self.assertEqual( + 'No data in SLIMS "Unit" table for pk "30000"', e.exception.args[0] + ) + mock_update.assert_not_called() + mock_log.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_example.py b/tests/test_example.py deleted file mode 100644 index 06e9e0d..0000000 --- a/tests/test_example.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Example test template.""" - -import unittest - - -class ExampleTest(unittest.TestCase): - """Example Test Class""" - - def test_assert_example(self): - """Example of how to test the truth of a statement.""" - - self.assertTrue(1 == 1) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_mouse.py b/tests/test_mouse.py new file mode 100644 index 0000000..9c698fb --- /dev/null +++ b/tests/test_mouse.py @@ -0,0 +1,74 @@ +"""Tests methods in mouse module""" + +import json +import os +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from slims.internal import Record + +from aind_slims_api.core import SlimsClient +from aind_slims_api.mouse import fetch_mouse_content + +RESOURCES_DIR = Path(os.path.dirname(os.path.realpath(__file__))) / "resources" + + +class TestMouse(unittest.TestCase): + """Tests top level methods in mouse module""" + + @classmethod + def setUpClass(cls): + """Load json files of expected responses from slims""" + example_client = SlimsClient( + url="http://fake_url", username="user", password="pass" + ) + cls.example_client = example_client + with open(RESOURCES_DIR / "example_fetch_mouse_response.json", "r") as f: + response = [ + Record(json_entity=r, slims_api=example_client.db.slims_api) + for r in json.load(f) + ] + cls.example_fetch_mouse_response = response + + @patch("slims.slims.Slims.fetch") + def test_fetch_mouse_content_success(self, mock_fetch: MagicMock): + """Test fetch_mouse_content when successful""" + mock_fetch.return_value = self.example_fetch_mouse_response + mouse_details = fetch_mouse_content(self.example_client, mouse_name="123456") + self.assertEqual( + self.example_fetch_mouse_response[0].json_entity, mouse_details + ) + + @patch("logging.Logger.warning") + @patch("slims.slims.Slims.fetch") + def test_fetch_mouse_content_no_mouse( + self, mock_fetch: MagicMock, mock_log_warn: MagicMock + ): + """Test fetch_mouse_content when no mouse is returned""" + mock_fetch.return_value = [] + mouse_details = fetch_mouse_content(self.example_client, mouse_name="12") + self.assertIsNone(mouse_details) + mock_log_warn.assert_called_with("Warning, Mouse not in SLIMS") + + @patch("logging.Logger.warning") + @patch("slims.slims.Slims.fetch") + def test_fetch_mouse_content_many_mouse( + self, mock_fetch: MagicMock, mock_log_warn: MagicMock + ): + """Test fetch_mouse_content when too many mice are returned""" + mock_fetch.return_value = [ + self.example_fetch_mouse_response[0], + self.example_fetch_mouse_response[0], + ] + mouse_details = fetch_mouse_content(self.example_client, mouse_name="123456") + self.assertEqual( + self.example_fetch_mouse_response[0].json_entity, mouse_details + ) + mock_log_warn.assert_called_with( + "Warning, Multiple mice in SLIMS with barcode 123456, using pk=3038" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_user.py b/tests/test_user.py new file mode 100644 index 0000000..02a705d --- /dev/null +++ b/tests/test_user.py @@ -0,0 +1,74 @@ +"""Tests methods in user module""" + +import json +import os +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from slims.internal import Record + +from aind_slims_api.core import SlimsClient +from aind_slims_api.user import fetch_user + +RESOURCES_DIR = Path(os.path.dirname(os.path.realpath(__file__))) / "resources" + + +class TestUser(unittest.TestCase): + """Tests top level methods in user module""" + + @classmethod + def setUpClass(cls): + """Load json files of expected responses from slims""" + example_client = SlimsClient( + url="http://fake_url", username="user", password="pass" + ) + cls.example_client = example_client + with open(RESOURCES_DIR / "example_fetch_user_response.json", "r") as f: + response = [ + Record(json_entity=r, slims_api=example_client.db.slims_api) + for r in json.load(f) + ] + cls.example_fetch_user_response = response + + @patch("slims.slims.Slims.fetch") + def test_fetch_user_content_success(self, mock_fetch: MagicMock): + """Test fetch_user when successful""" + mock_fetch.return_value = self.example_fetch_user_response + user_info = fetch_user(self.example_client, username="PersonA") + self.assertEqual(self.example_fetch_user_response[0].json_entity, user_info) + + @patch("logging.Logger.warning") + @patch("slims.slims.Slims.fetch") + def test_fetch_user_content_no_user( + self, mock_fetch: MagicMock, mock_log_warn: MagicMock + ): + """Test fetch_user when no user is returned""" + mock_fetch.return_value = [] + user_info = fetch_user(self.example_client, username="PersonX") + self.assertIsNone(user_info) + mock_log_warn.assert_called_with("Warning, User not in SLIMS") + + @patch("logging.Logger.warning") + @patch("slims.slims.Slims.fetch") + def test_fetch_user_content_many_users( + self, mock_fetch: MagicMock, mock_log_warn: MagicMock + ): + """Test fetch_user_content when too many users are returned""" + mocked_response = [ + self.example_fetch_user_response[0], + self.example_fetch_user_response[0], + ] + mock_fetch.return_value = mocked_response + user_info = fetch_user(self.example_client, username="PersonA") + self.assertEqual(self.example_fetch_user_response[0].json_entity, user_info) + expected_warning = ( + f"Warning, Multiple users in SLIMS with " + f"username {[u.json_entity for u in mocked_response]}, " + f"using pk={mocked_response[0].pk()}" + ) + mock_log_warn.assert_called_with(expected_warning) + + +if __name__ == "__main__": + unittest.main() From 269101b879b302c2a87877c92ad635d16c247a6d Mon Sep 17 00:00:00 2001 From: jtyoung84 <104453205+jtyoung84@users.noreply.github.com> Date: Tue, 18 Jun 2024 17:40:26 -0700 Subject: [PATCH 2/7] ci: updates workflows to enable publishing (#13) --- .github/workflows/init.yml | 52 --------------------------- .github/workflows/tag_and_publish.yml | 14 +++----- 2 files changed, 5 insertions(+), 61 deletions(-) delete mode 100644 .github/workflows/init.yml diff --git a/.github/workflows/init.yml b/.github/workflows/init.yml deleted file mode 100644 index 0336013..0000000 --- a/.github/workflows/init.yml +++ /dev/null @@ -1,52 +0,0 @@ -# Workflow runs only once when the template is first used. -# File can be safely deleted after repo is initialized. -name: Initialize repository -on: - push: - branches: - - main - -jobs: - initialize-package: - name: Initialize the package - if: ${{github.event.repository.name != 'aind-library-template'}} - runs-on: ubuntu-latest - env: - REPO_NAME: ${{ github.event.repository.name }} - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - steps: - - name: Checkout - uses: actions/checkout@v3 - - name: Rename package - run: | - pkg_name=$(echo "${REPO_NAME}" | tr - _) - current_description='description = "Prints messages to stdout. Simple boilerplate for libraries."' - new_description='description = "Generated from aind-library-template"' - readme_description='Template for a minimal, basic repository for an AIND library.' - new_readme_description='Generated from aind-library-template' - echo "Package Name ${pkg_name}" - mkdir src/${pkg_name} - touch src/${pkg_name}/__init__.py - echo '"""Init package"""' >> src/${pkg_name}/__init__.py - echo '__version__ = "0.0.0"' >> src/${pkg_name}/__init__.py - sed -i "s/aind_library_template/${pkg_name}/" pyproject.toml - sed -i "s/aind-library-template/${REPO_NAME}/" pyproject.toml - sed -i "s/aind_library_template/${pkg_name}/" doc_template/source/conf.py - sed -i "s/${current_description}/${new_description}/" pyproject.toml - sed -i "/pandas/d" pyproject.toml - sed -i "s/aind-library-template/${REPO_NAME}/" README.md - sed -i "s/${readme_description}/${new_readme_description}/" README.md - - name: Commit changes - uses: EndBug/add-and-commit@v9 - with: - default_author: github_actions - message: "ci: version bump [skip actions]" - add: '["pyproject.toml", "README.md", "src/*", "doc_template/source/conf.py"]' - remove: '["-r src/aind_library_template", "tests/test_message_handler.py"]' - - name: Add first tag - run: | - git tag v0.0.0 - git push origin v0.0.0 - - name: Disable workflow - run: | - gh workflow disable -R $GITHUB_REPOSITORY "${{ github.workflow }}" diff --git a/.github/workflows/tag_and_publish.yml b/.github/workflows/tag_and_publish.yml index 90419da..b74c9fd 100644 --- a/.github/workflows/tag_and_publish.yml +++ b/.github/workflows/tag_and_publish.yml @@ -3,9 +3,7 @@ on: push: branches: - main -# Remove line 61 to enable automated semantic version bumps. -# Change line 67 from "if: false" to "if: true" to enable PyPI publishing. -# Requires that svc-aindscicomp be added as an admin to repo. + jobs: update_badges: runs-on: ubuntu-latest @@ -16,10 +14,10 @@ jobs: ref: ${{ env.DEFAULT_BRANCH }} fetch-depth: 0 token: ${{ secrets.SERVICE_TOKEN }} - - name: Set up Python 3.8 + - name: Set up Python 3.10 uses: actions/setup-python@v3 with: - python-version: 3.8 + python-version: 3.10 - name: Install dependencies run: | python -m pip install -e .[dev] --no-cache-dir @@ -62,22 +60,20 @@ jobs: add: '["README.md"]' tag: needs: update_badges - if: ${{github.event.repository.name == 'aind-library-template'}} uses: AllenNeuralDynamics/aind-github-actions/.github/workflows/tag.yml@main secrets: SERVICE_TOKEN: ${{ secrets.SERVICE_TOKEN }} publish: needs: tag - if: false runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Pull latest changes run: git pull origin main - - name: Set up Python 3.8 + - name: Set up Python 3.10 uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.10 - name: Install dependencies run: | pip install --upgrade setuptools wheel twine build From 83cdceb5232ac2ba232d25daf281292d2971c77b Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Jun 2024 00:40:49 +0000 Subject: [PATCH 3/7] ci: version bump [skip actions] --- src/aind_slims_api/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aind_slims_api/__init__.py b/src/aind_slims_api/__init__.py index 25ae369..27ad85f 100644 --- a/src/aind_slims_api/__init__.py +++ b/src/aind_slims_api/__init__.py @@ -1,6 +1,6 @@ """Init package""" -__version__ = "0.0.0" +__version__ = "0.0.1" from aind_slims_api.configuration import AindSlimsApiSettings From af5389d2e53b768186db739b83fda51300eddf10 Mon Sep 17 00:00:00 2001 From: jtyoung84 <104453205+jtyoung84@users.noreply.github.com> Date: Tue, 18 Jun 2024 17:52:57 -0700 Subject: [PATCH 4/7] hot-fix: patches typo in yaml file (#14) --- .github/workflows/tag_and_publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tag_and_publish.yml b/.github/workflows/tag_and_publish.yml index b74c9fd..5847b17 100644 --- a/.github/workflows/tag_and_publish.yml +++ b/.github/workflows/tag_and_publish.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Python 3.10 uses: actions/setup-python@v3 with: - python-version: 3.10 + python-version: '3.10' - name: Install dependencies run: | python -m pip install -e .[dev] --no-cache-dir @@ -73,7 +73,7 @@ jobs: - name: Set up Python 3.10 uses: actions/setup-python@v2 with: - python-version: 3.10 + python-version: '3.10' - name: Install dependencies run: | pip install --upgrade setuptools wheel twine build From a9331d644a3aa2f3433b7b52467f9a6bfce86a2f Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Jun 2024 00:53:25 +0000 Subject: [PATCH 5/7] ci: update badges [skip actions] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d56f45c..e1a8d80 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![semantic-release: angular](https://img.shields.io/badge/semantic--release-angular-e10079?logo=semantic-release)](https://github.com/semantic-release/semantic-release) ![Interrogate](https://img.shields.io/badge/interrogate-100.0%25-brightgreen) ![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen?logo=codecov) -![Python](https://img.shields.io/badge/python->=3.7-blue?logo=python) +![Python](https://img.shields.io/badge/python->=3.10-blue?logo=python) This library is for pythonic access to data stored in the AIND SLIMS deployment. It is not meant to be a generic way to interact with SLIMS, but rather specific to how data is modeled in AIND's SLIMS instance. From 4f6cc2fe431a18a9778f87ad6b3cf10c17e96923 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Jun 2024 00:53:39 +0000 Subject: [PATCH 6/7] ci: version bump [skip actions] --- src/aind_slims_api/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aind_slims_api/__init__.py b/src/aind_slims_api/__init__.py index 27ad85f..2475431 100644 --- a/src/aind_slims_api/__init__.py +++ b/src/aind_slims_api/__init__.py @@ -1,6 +1,6 @@ """Init package""" -__version__ = "0.0.1" +__version__ = "0.0.2" from aind_slims_api.configuration import AindSlimsApiSettings From 655ab269508286811a323eaa714859b82a32eb28 Mon Sep 17 00:00:00 2001 From: Patrick Latimer <110747402+patricklatimer@users.noreply.github.com> Date: Tue, 25 Jun 2024 10:48:52 -0700 Subject: [PATCH 7/7] feat: add pydantic model for slims records (#12) * feat: add pydantic model for slims records * fix: f-string typo * feat: add unit model * feat: include json_entity in models * feat: return dict if validation fails * test: update unit tests * test: clear existing env vars in config test * style: clean up core/user test * test: add unitspec/mouse validation tests * fix: return json_entity if validation fails * style: change relative imports to absolute --- src/aind_slims_api/core.py | 154 +++++++++++++++++++++++++++++++++++- src/aind_slims_api/mouse.py | 50 ++++++++++-- src/aind_slims_api/unit.py | 20 +++++ src/aind_slims_api/user.py | 32 ++++++-- tests/test_configuration.py | 5 ++ tests/test_core.py | 52 +++++++++++- tests/test_mouse.py | 21 ++++- tests/test_slimsmodel.py | 104 ++++++++++++++++++++++++ tests/test_user.py | 32 +++++++- 9 files changed, 448 insertions(+), 22 deletions(-) create mode 100644 src/aind_slims_api/unit.py create mode 100644 tests/test_slimsmodel.py diff --git a/src/aind_slims_api/core.py b/src/aind_slims_api/core.py index d22f4ec..f7646cf 100644 --- a/src/aind_slims_api/core.py +++ b/src/aind_slims_api/core.py @@ -8,13 +8,24 @@ methods and integration with SlimsBaseModel subtypes """ -import logging +from datetime import datetime from functools import lru_cache +from pydantic import ( + BaseModel, + ValidationInfo, + field_serializer, + field_validator, +) +from pydantic.fields import FieldInfo +import logging from typing import Literal, Optional -from slims.criteria import Criterion, conjunction, equals -from slims.internal import Record as SlimsRecord from slims.slims import Slims, _SlimsApiException +from slims.internal import ( + Column as SlimsColumn, + Record as SlimsRecord, +) +from slims.criteria import Criterion, conjunction, equals from aind_slims_api import config @@ -33,6 +44,94 @@ ] +class UnitSpec: + """Used in type annotation metadata to specify units""" + + units: list[str] + preferred_unit: str = None + + def __init__(self, *args, preferred_unit=None): + """Set list of acceptable units from args, and preferred_unit""" + self.units = args + if len(self.units) == 0: + raise ValueError("One or more units must be specified") + if preferred_unit is None: + self.preferred_unit = self.units[0] + + +def _find_unit_spec(field: FieldInfo) -> UnitSpec | None: + """Given a Pydantic FieldInfo, find the UnitSpec in its metadata""" + metadata = field.metadata + for m in metadata: + if isinstance(m, UnitSpec): + return m + return None + + +class SlimsBaseModel( + BaseModel, + from_attributes=True, + validate_assignment=True, +): + """Pydantic model to represent a SLIMS record. + Subclass with fields matching those in the SLIMS record. + + For Quantities, specify acceptable units like so: + + class MyModel(SlimsBaseModel): + myfield: Annotated[float | None, UnitSpec("g","kg")] + + Quantities will be serialized using the first unit passed + + Datetime fields will be serialized to an integer ms timestamp + """ + + pk: int = None + json_entity: dict = None + _slims_table: SLIMSTABLES + + @field_validator("*", mode="before") + def _validate(cls, value, info: ValidationInfo): + """Validates a field, accounts for Quantities""" + if isinstance(value, SlimsColumn): + if value.datatype == "QUANTITY": + unit_spec = _find_unit_spec(cls.model_fields[info.field_name]) + if unit_spec is None: + msg = ( + f'Quantity field "{info.field_name}"' + "must be annotated with a UnitSpec" + ) + raise TypeError(msg) + if value.unit not in unit_spec.units: + msg = ( + f'Unexpected unit "{value.unit}" for field ' + f"{info.field_name}, Expected {unit_spec.units}" + ) + raise ValueError(msg) + return value.value + else: + return value + + @field_serializer("*") + def _serialize(self, field, info): + """Serialize a field, accounts for Quantities and datetime""" + unit_spec = _find_unit_spec(self.model_fields[info.field_name]) + if unit_spec and field is not None: + quantity = { + "amount": field, + "unit_display": unit_spec.preferred_unit, + } + return quantity + elif isinstance(field, datetime): + return int(field.timestamp() * 10**3) + else: + return field + + # TODO: Add links - need Record.json_entity['links']['self'] + # TODO: Add Table - need Record.json_entity['tableName'] + # TODO: Support attachments + + class SlimsClient: """Wrapper around slims-python-api client with convenience methods""" @@ -133,3 +232,52 @@ def rest_link(self, table: SLIMSTABLES, **kwargs): base_url = f"{self.url}/rest/{table}" queries = [f"?{k}={v}" for k, v in kwargs.items()] return base_url + "".join(queries) + + def add_model(self, model: SlimsBaseModel, *args, **kwargs) -> SlimsBaseModel: + """Given a SlimsBaseModel object, add it to SLIMS + Args + model (SlimsBaseModel): object to add + *args (str): fields to include in the serialization + **kwargs: passed to model.model_dump() + + Returns + An instance of the same type of model, with data from + the resulting SLIMS record + """ + fields_to_include = set(args) or None + fields_to_exclude = set(kwargs.get("exclude", [])) + fields_to_exclude.add("pk") + rtn = self.add( + model._slims_table, + model.model_dump( + include=fields_to_include, + exclude=fields_to_exclude, + **kwargs, + by_alias=True, + ), + ) + return type(model).model_validate(rtn) + + def update_model(self, model: SlimsBaseModel, *args, **kwargs): + """Given a SlimsBaseModel object, update its (existing) SLIMS record + + Args + model (SlimsBaseModel): object to update + *args (str): fields to include in the serialization + **kwargs: passed to model.model_dump() + + Returns + An instance of the same type of model, with data from + the resulting SLIMS record + """ + fields_to_include = set(args) or None + rtn = self.update( + model._slims_table, + model.pk, + model.model_dump( + include=fields_to_include, + by_alias=True, + **kwargs, + ), + ) + return type(model).model_validate(rtn) diff --git a/src/aind_slims_api/mouse.py b/src/aind_slims_api/mouse.py index b9f5aaa..f11717e 100644 --- a/src/aind_slims_api/mouse.py +++ b/src/aind_slims_api/mouse.py @@ -1,17 +1,51 @@ """Contains a model for the mouse content, and a method for fetching it""" import logging -from typing import Optional +from typing import Annotated -from aind_slims_api.core import SlimsClient +from pydantic import Field, BeforeValidator, ValidationError + +from aind_slims_api.core import SlimsBaseModel, SlimsClient, UnitSpec, SLIMSTABLES logger = logging.getLogger() +class SlimsMouseContent(SlimsBaseModel): + """Model for an instance of the Mouse ContentType""" + + baseline_weight_g: Annotated[float | None, UnitSpec("g")] = Field( + ..., alias="cntn_cf_baselineWeight" + ) + 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" + ) + barcode: str = Field(..., alias="cntn_barCode") + pk: int = Field(..., alias="cntn_pk") + + _slims_table: SLIMSTABLES = "Content" + + # TODO: Include other helpful fields (genotype, gender...) + + # pk: callable + # cntn_fk_category: SlimsColumn + # cntn_fk_contentType: SlimsColumn + # cntn_barCode: SlimsColumn + # cntn_id: SlimsColumn + # cntn_cf_contactPerson: SlimsColumn + # cntn_status: SlimsColumn + # cntn_fk_status: SlimsColumn + # cntn_fk_user: SlimsColumn + # cntn_cf_fk_fundingCode: SlimsColumn + # cntn_cf_genotype: SlimsColumn + # cntn_cf_labtracksId: SlimsColumn + # cntn_cf_parentBarcode: SlimsColumn + + def fetch_mouse_content( client: SlimsClient, mouse_name: str, -) -> Optional[dict]: +) -> SlimsMouseContent | dict | None: """Fetches mouse information for a mouse with labtracks id {mouse_name}""" mice = client.fetch( "Content", @@ -28,6 +62,12 @@ def fetch_mouse_content( ) else: logger.warning("Warning, Mouse not in SLIMS") - mouse_details = None + return + + try: + mouse = SlimsMouseContent.model_validate(mouse_details) + except ValidationError as e: + logger.error(f"SLIMS data validation failed, {repr(e)}") + return mouse_details.json_entity - return None if mouse_details is None else mouse_details.json_entity + return mouse diff --git a/src/aind_slims_api/unit.py b/src/aind_slims_api/unit.py new file mode 100644 index 0000000..73c2efc --- /dev/null +++ b/src/aind_slims_api/unit.py @@ -0,0 +1,20 @@ +"""Contains a model for a unit""" + +import logging +from typing import Optional + +from pydantic import Field + +from aind_slims_api.core import SlimsBaseModel + +logger = logging.getLogger() + + +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") + + _slims_table: str = "Unit" diff --git a/src/aind_slims_api/user.py b/src/aind_slims_api/user.py index 1e5ac46..74a6994 100644 --- a/src/aind_slims_api/user.py +++ b/src/aind_slims_api/user.py @@ -3,15 +3,31 @@ import logging from typing import Optional -from aind_slims_api.core import SlimsClient +from pydantic import Field, ValidationError + +from aind_slims_api.core import SlimsBaseModel, SlimsClient logger = logging.getLogger() +# TODO: Tighten this up once users are more commonly used +class SlimsUser(SlimsBaseModel): + """Model for user information in SLIMS""" + + 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") + + _slims_table: str = "User" + + def fetch_user( client: SlimsClient, username: str, -) -> Optional[dict]: +) -> SlimsUser | dict | None: """Fetches user information for a user with username {username}""" users = client.fetch( "User", @@ -23,11 +39,17 @@ def fetch_user( if len(users) > 1: logger.warning( f"Warning, Multiple users in SLIMS with " - f"username {[u.json_entity for u in users]}, " + f"username {username}, " f"using pk={user_details.pk()}" ) else: logger.warning("Warning, User not in SLIMS") - user_details = None + return + + try: + user = SlimsUser.model_validate(user_details) + except ValidationError as e: + logger.error(f"SLIMS data validation failed, {repr(e)}") + return user_details.json_entity - return None if user_details is None else user_details.json_entity + return user diff --git a/tests/test_configuration.py b/tests/test_configuration.py index d1c5f92..ab12d38 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -10,6 +10,11 @@ class TestAindSlimsApiSettings(unittest.TestCase): """Tests methods in AindSlimsApiSettings class""" + @patch.dict( + os.environ, + {}, + clear=True, + ) def test_default_settings(self): """Tests that the class will be set with defaults""" default_settings = AindSlimsApiSettings() diff --git a/tests/test_core.py b/tests/test_core.py index 5445b91..6ac5923 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -11,6 +11,7 @@ from slims.internal import Record, _SlimsApiException from aind_slims_api.core import SlimsClient +from aind_slims_api.unit import SlimsUnit RESOURCES_DIR = Path(os.path.dirname(os.path.realpath(__file__))) / "resources" @@ -133,7 +134,10 @@ def test_add(self, mock_slims_add: MagicMock, mock_log: MagicMock): @patch("logging.Logger.info") @patch("slims.internal.Record.update") def test_update( - self, mock_update: MagicMock, mock_log: MagicMock, mock_fetch_by_pk: MagicMock + self, + mock_update: MagicMock, + mock_log: MagicMock, + mock_fetch_by_pk: MagicMock, ): """Tests update method success""" input_data = deepcopy(self.example_fetch_unit_response[0].json_entity) @@ -155,18 +159,60 @@ def test_update( @patch("logging.Logger.info") @patch("slims.internal.Record.update") def test_update_failure( - self, mock_update: MagicMock, mock_log: MagicMock, mock_fetch_by_pk: MagicMock + self, + mock_update: MagicMock, + mock_log: MagicMock, + mock_fetch_by_pk: MagicMock, ): """Tests update method when a failure occurs""" mock_fetch_by_pk.return_value = None with self.assertRaises(ValueError) as e: self.example_client.update(table="Unit", pk=30000, data={}) self.assertEqual( - 'No data in SLIMS "Unit" table for pk "30000"', e.exception.args[0] + 'No data in SLIMS "Unit" table for pk "30000"', + e.exception.args[0], ) mock_update.assert_not_called() mock_log.assert_not_called() + @patch("logging.Logger.info") + @patch("slims.slims.Slims.add") + def test_add_model(self, mock_slims_add: MagicMock, mock_log: MagicMock): + """Tests add_model method with mock mouse data""" + record = self.example_fetch_unit_response[0] + mock_slims_add.return_value = record + input_model = SlimsUnit.model_validate(record) + added = self.example_client.add_model(input_model) + self.assertEqual(input_model, added) + mock_log.assert_called_once_with("SLIMS Add: Unit/31") + + @patch("slims.slims.Slims.fetch_by_pk") + @patch("logging.Logger.info") + @patch("slims.internal.Record.update") + def test_update_model( + self, + mock_update: MagicMock, + mock_log: MagicMock, + mock_fetch_by_pk: MagicMock, + ): + """Tests update method success""" + input_data = deepcopy(self.example_fetch_unit_response[0].json_entity) + mock_record = Record( + json_entity=input_data, slims_api=self.example_client.db.slims_api + ) + mock_fetch_by_pk.return_value = mock_record + updated_model = SlimsUnit.model_validate(mock_record) + new_data = deepcopy(input_data) + new_data["columns"][0]["value"] = "PM^3" + mocked_updated_record = Record( + json_entity=new_data, slims_api=self.example_client.db.slims_api + ) + mock_update.return_value = mocked_updated_record + updated_model = SlimsUnit.model_validate(mocked_updated_record) + returned_model = self.example_client.update_model(updated_model) + self.assertEqual(updated_model, returned_model) + mock_log.assert_called_once_with("SLIMS Update: Unit/31") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_mouse.py b/tests/test_mouse.py index 9c698fb..9a8f72d 100644 --- a/tests/test_mouse.py +++ b/tests/test_mouse.py @@ -5,6 +5,7 @@ import unittest from pathlib import Path from unittest.mock import MagicMock, patch +from copy import deepcopy from slims.internal import Record @@ -37,7 +38,7 @@ def test_fetch_mouse_content_success(self, mock_fetch: MagicMock): mock_fetch.return_value = self.example_fetch_mouse_response mouse_details = fetch_mouse_content(self.example_client, mouse_name="123456") self.assertEqual( - self.example_fetch_mouse_response[0].json_entity, mouse_details + self.example_fetch_mouse_response[0].json_entity, mouse_details.json_entity ) @patch("logging.Logger.warning") @@ -63,12 +64,28 @@ def test_fetch_mouse_content_many_mouse( ] mouse_details = fetch_mouse_content(self.example_client, mouse_name="123456") self.assertEqual( - self.example_fetch_mouse_response[0].json_entity, mouse_details + self.example_fetch_mouse_response[0].json_entity, mouse_details.json_entity ) mock_log_warn.assert_called_with( "Warning, Multiple mice in SLIMS with barcode 123456, using pk=3038" ) + @patch("logging.Logger.error") + @patch("slims.slims.Slims.fetch") + def test_fetch_mouse_content_validation_fail( + self, mock_fetch: MagicMock, mock_log_error: MagicMock + ): + """Test fetch_mouse when successful""" + wrong_return = deepcopy(self.example_fetch_mouse_response) + wrong_return[0].cntn_cf_waterRestricted.value = 14 + mock_fetch.return_value = wrong_return + mouse_info = fetch_mouse_content(self.example_client, mouse_name="123456") + self.assertEqual( + self.example_fetch_mouse_response[0].json_entity, + mouse_info, + ) + mock_log_error.assert_called() + if __name__ == "__main__": unittest.main() diff --git a/tests/test_slimsmodel.py b/tests/test_slimsmodel.py new file mode 100644 index 0000000..a3ff69b --- /dev/null +++ b/tests/test_slimsmodel.py @@ -0,0 +1,104 @@ +""" Tests the generic SlimsBaseModel""" + +from datetime import datetime +from typing import Annotated +import unittest + +from pydantic import Field +from slims.internal import Record, Column + +from aind_slims_api.core import SlimsBaseModel, UnitSpec + + +class TestSlimsModel(unittest.TestCase): + """Example Test Class""" + + class TestModel(SlimsBaseModel, validate_assignment=True): + """Test case""" + + datefield: datetime = None + stringfield: str = None + quantfield: Annotated[float, UnitSpec("um", "nm")] = None + + def test_string_field(self): + """Test basic usage for SLIMS column to Model field""" + obj = self.TestModel() + obj.stringfield = Column( + { + "datatype": "STRING", + "name": "stringfield", + "value": "value", + } + ) + + self.assertEqual(obj.stringfield, "value") + + def test_quantity_field(self): + """Test validation/serialization of a quantity type, with unit""" + obj = self.TestModel() + obj.quantfield = Column( + { + "datatype": "QUANTITY", + "name": "quantfield", + "value": 28.28, + "unit": "um", + } + ) + + self.assertEqual(obj.quantfield, 28.28) + + serialized = obj.model_dump()["quantfield"] + expected = {"amount": 28.28, "unit_display": "um"} + + self.assertEqual(serialized, expected) + + def test_quantity_wrong_unit(self): + """Ensure you get an error with an unexpected unit""" + obj = self.TestModel() + with self.assertRaises(ValueError): + obj.quantfield = Column( + { + "datatype": "QUANTITY", + "name": "quantfield", + "value": 28.28, + "unit": "erg", + } + ) + + def test_alias(self): + """Test aliasing of fields""" + + class TestModelAlias(SlimsBaseModel): + """model with field aliases""" + + field: str = Field(..., alias="alias") + pk: int = Field(None, alias="cntn_pk") + + record = Record( + json_entity={ + "columns": [ + { + "datatype": "STRING", + "name": "alias", + "value": "value", + } + ] + }, + slims_api=None, + ) + obj = TestModelAlias.model_validate(record) + + self.assertEqual(obj.field, "value") + obj.field = "value2" + self.assertEqual(obj.field, "value2") + serialized = obj.model_dump(include="field", by_alias=True) + expected = {"alias": "value2"} + self.assertEqual(serialized, expected) + + def test_unitspec(self): + """Test unitspec with no arguments""" + self.assertRaises(ValueError, UnitSpec) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_user.py b/tests/test_user.py index 02a705d..1ea9a81 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -5,6 +5,7 @@ import unittest from pathlib import Path from unittest.mock import MagicMock, patch +from copy import deepcopy from slims.internal import Record @@ -36,7 +37,26 @@ def test_fetch_user_content_success(self, mock_fetch: MagicMock): """Test fetch_user when successful""" mock_fetch.return_value = self.example_fetch_user_response user_info = fetch_user(self.example_client, username="PersonA") - self.assertEqual(self.example_fetch_user_response[0].json_entity, user_info) + self.assertEqual( + self.example_fetch_user_response[0].json_entity, + user_info.json_entity, + ) + + @patch("logging.Logger.error") + @patch("slims.slims.Slims.fetch") + def test_fetch_user_content_validation_fail( + self, mock_fetch: MagicMock, mock_log_error: MagicMock + ): + """Test fetch_user when successful""" + wrong_return = deepcopy(self.example_fetch_user_response) + wrong_return[0].user_userName.value = 14 + mock_fetch.return_value = wrong_return + user_info = fetch_user(self.example_client, username="PersonA") + self.assertEqual( + self.example_fetch_user_response[0].json_entity, + user_info, + ) + mock_log_error.assert_called() @patch("logging.Logger.warning") @patch("slims.slims.Slims.fetch") @@ -60,11 +80,15 @@ def test_fetch_user_content_many_users( self.example_fetch_user_response[0], ] mock_fetch.return_value = mocked_response - user_info = fetch_user(self.example_client, username="PersonA") - self.assertEqual(self.example_fetch_user_response[0].json_entity, user_info) + username = "PersonA" + user_info = fetch_user(self.example_client, username=username) + self.assertEqual( + self.example_fetch_user_response[0].json_entity, + user_info.json_entity, + ) expected_warning = ( f"Warning, Multiple users in SLIMS with " - f"username {[u.json_entity for u in mocked_response]}, " + f"username {username}, " f"using pk={mocked_response[0].pk()}" ) mock_log_warn.assert_called_with(expected_warning)