From d7639aed82d198ed17d3fcd1bf3fa99517ffbc3c Mon Sep 17 00:00:00 2001 From: jtyoung84 <104453205+jtyoung84@users.noreply.github.com> Date: Wed, 18 Oct 2023 18:31:44 -0700 Subject: [PATCH 1/5] feat: adds slims client and models --- .github/workflows/ci.yml | 2 +- Dockerfile | 2 +- pyproject.toml | 7 +- src/aind_metadata_service/labtracks/client.py | 7 +- .../sharepoint/nsb2023/mapping.py | 18 +- src/aind_metadata_service/slims/__init__.py | 1 + src/aind_metadata_service/slims/client.py | 58 ++++ src/aind_metadata_service/slims/models.py | 290 ++++++++++++++++++ tests/labtracks/test_response_handler.py | 4 +- 9 files changed, 369 insertions(+), 20 deletions(-) create mode 100644 src/aind_metadata_service/slims/__init__.py create mode 100644 src/aind_metadata_service/slims/client.py create mode 100644 src/aind_metadata_service/slims/models.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84139d61..2503db53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ '3.7', '3.8', '3.9', '3.10' ] + python-version: [ '3.9', '3.10', '3.11' ] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} diff --git a/Dockerfile b/Dockerfile index b60d3b58..56ee158c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.8-slim +FROM python:3.9-slim WORKDIR /app # Install FreeTDS and dependencies diff --git a/pyproject.toml b/pyproject.toml index c3b3c216..0cd84297 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ license = {text = "MIT"} authors = [ {name = "Allen Institute for Neural Dynamics"} ] -requires-python = ">=3.7" +requires-python = ">=3.9" classifiers = [ "Programming Language :: Python :: 3" ] @@ -38,7 +38,8 @@ server = [ 'office365-rest-python-client==2.4.1', 'fastapi', 'uvicorn[standard]', - 'python-dateutil' + 'python-dateutil', + 'slims-python-api==6.8.0', ] client = [ @@ -54,7 +55,7 @@ readme = {file = ["README.md"]} [tool.black] line-length = 79 -target_version = ['py36'] +target_version = ['py39'] exclude = ''' ( diff --git a/src/aind_metadata_service/labtracks/client.py b/src/aind_metadata_service/labtracks/client.py index 2330e899..c18d8e6d 100644 --- a/src/aind_metadata_service/labtracks/client.py +++ b/src/aind_metadata_service/labtracks/client.py @@ -349,8 +349,11 @@ def _map_housing( room_id = None if room_id is None or int(room_id) < 0 else room_id cage_id = None if cage_id is None or int(cage_id) < 0 else cage_id - return Housing.construct(room_id=room_id, - cage_id=cage_id) if room_id is not None or cage_id is not None else None + return ( + Housing.construct(room_id=room_id, cage_id=cage_id) + if room_id is not None or cage_id is not None + else None + ) def map_response_to_subject(self, results: List[dict]) -> List[Subject]: """ diff --git a/src/aind_metadata_service/sharepoint/nsb2023/mapping.py b/src/aind_metadata_service/sharepoint/nsb2023/mapping.py index c0c1a7b2..0e9dc602 100644 --- a/src/aind_metadata_service/sharepoint/nsb2023/mapping.py +++ b/src/aind_metadata_service/sharepoint/nsb2023/mapping.py @@ -2955,8 +2955,7 @@ def burr_hole_info(self, burr_hole_num: int) -> BurrHoleInfo: alternating_current=self.aind_inj1_alternating_time, inj_duration=self.aind_inj1_ionto_time, inj_volume=self._map_burr_hole_volume( - vol=self.aind_inj1volperdepth, - dv=coordinate_depth + vol=self.aind_inj1volperdepth, dv=coordinate_depth ), fiber_implant_depth=self.aind_fiber_implant1_dv, ) @@ -2979,8 +2978,7 @@ def burr_hole_info(self, burr_hole_num: int) -> BurrHoleInfo: alternating_current=self.aind_inj2_alternating_time, inj_duration=self.aind_inj2_ionto_time, inj_volume=self._map_burr_hole_volume( - vol=self.aind_inj2volperdepth, - dv=coordinate_depth + vol=self.aind_inj2volperdepth, dv=coordinate_depth ), fiber_implant_depth=self.aind_fiber_implant2_dv, ) @@ -3003,8 +3001,7 @@ def burr_hole_info(self, burr_hole_num: int) -> BurrHoleInfo: alternating_current=self.aind_inj3_alternating_time, inj_duration=self.aind_inj3_ionto_time, inj_volume=self._map_burr_hole_volume( - vol=self.aind_inj3volperdepth, - dv=coordinate_depth + vol=self.aind_inj3volperdepth, dv=coordinate_depth ), fiber_implant_depth=self.aind_fiber_implant3_d_x00, ) @@ -3027,8 +3024,7 @@ def burr_hole_info(self, burr_hole_num: int) -> BurrHoleInfo: alternating_current=self.aind_inj4_alternating_time, inj_duration=self.aind_inj4_ionto_time, inj_volume=self._map_burr_hole_volume( - vol=self.aind_inj4volperdepth, - dv=coordinate_depth + vol=self.aind_inj4volperdepth, dv=coordinate_depth ), fiber_implant_depth=self.aind_fiber_implant4_d_x00, ) @@ -3051,8 +3047,7 @@ def burr_hole_info(self, burr_hole_num: int) -> BurrHoleInfo: alternating_current=self.aind_inj5_alternating_time, inj_duration=self.aind_inj5_ionto_time, inj_volume=self._map_burr_hole_volume( - vol=self.aind_inj5volperdepth, - dv=coordinate_depth + vol=self.aind_inj5volperdepth, dv=coordinate_depth ), fiber_implant_depth=self.aind_fiber_implant5_d_x00, ) @@ -3075,8 +3070,7 @@ def burr_hole_info(self, burr_hole_num: int) -> BurrHoleInfo: alternating_current=self.aind_inj6_alternating_time, inj_duration=self.aind_inj6_ionto_time, inj_volume=self._map_burr_hole_volume( - vol=self.aind_inj6volperdepth, - dv=coordinate_depth + vol=self.aind_inj6volperdepth, dv=coordinate_depth ), fiber_implant_depth=self.aind_fiber_implant6_d_x00, ) diff --git a/src/aind_metadata_service/slims/__init__.py b/src/aind_metadata_service/slims/__init__.py new file mode 100644 index 00000000..26e49eb9 --- /dev/null +++ b/src/aind_metadata_service/slims/__init__.py @@ -0,0 +1 @@ +"""Package to connect to SLIMS db""" diff --git a/src/aind_metadata_service/slims/client.py b/src/aind_metadata_service/slims/client.py new file mode 100644 index 00000000..b8b18902 --- /dev/null +++ b/src/aind_metadata_service/slims/client.py @@ -0,0 +1,58 @@ +"""Module for slims client""" + +from pydantic import BaseSettings, Extra, Field, SecretStr +from slims.criteria import equals +from slims.internal import Record +from slims.slims import Slims + + +class SlimsSettings(BaseSettings): + """Configuration class. Mostly a wrapper around smartsheet.Smartsheet + class constructor arguments.""" + + username: str = Field(..., description="User name") + password: SecretStr = Field(..., description="Password") + host: str = Field(..., description="host") + db: str = Field(default="slims", description="Database") + + class Config: + """Set env prefix and forbid extra fields.""" + + env_prefix = "SLIMS_" + extra = Extra.forbid + + +class SlimsClient: + """Client to connect to slims db""" + + def __init__(self, settings: SlimsSettings): + """Class constructor for slims client""" + self.settings = settings + self.client = Slims( + settings.db, + settings.host, + settings.username, + settings.password.get_secret_value(), + ) + + async def get_record(self, subject_id: str) -> Record: + """ + Retrieve a record from the Contents Table + Parameters + ---------- + subject_id : str + Labtracks id of subject to retrieve record for + + Returns + ------- + Record + A single slims Record + + """ + content_record = self.client.fetch( + "Content", + equals("cntn_cf_labtracksId", subject_id), + start=0, + end=1, + )[0] + return content_record diff --git a/src/aind_metadata_service/slims/models.py b/src/aind_metadata_service/slims/models.py new file mode 100644 index 00000000..4db9836c --- /dev/null +++ b/src/aind_metadata_service/slims/models.py @@ -0,0 +1,290 @@ +"""Module to contain SLIMS db models""" + +from datetime import date, datetime +from typing import List, Optional, Union + +from pydantic import BaseModel, Field +from pydantic.typing import Literal +from slims.internal import Record + + +class ContentsTableColumnInfo(BaseModel): + """A record pulled from slims has info attached to the columns.""" + + datatype: Literal[ + "BOOLEAN", + "DATE", + "ENUM", + "FLOAT", + "FOREIGN_KEY", + "INTEGER", + "QUANTITY", + "STRING", + ] + dateFormat: Optional[str] + displayField: Optional[str] + displayValue: Optional[str] + editable: bool + foreignDisplayColumn: Optional[str] + foreignTable: Optional[str] + hidden: bool + name: str + position: int + subType: Optional[str] + timeZone: Optional[str] + title: str + unit: Optional[str] + value: Union[int, bool, str, None] + + def _map_bool_to_field_str(self): + """Map BOOLEAN to a string representing the pydantic field""" + field_str = ( + f'{self.name}: Optional[bool] = Field(None, title="{self.title}")' + ) + return field_str + + def _map_date_to_field_str(self): + """Map DATE to a string representing the pydantic field""" + if self.subType is None or self.subType == "datetime": + field_type = "Optional[datetime]" + else: + field_type = "Optional[date]" + if self.timeZone is None: + field_str = ( + f"{self.name}: {field_type} = Field(None, " + f'title="{self.title}")' + ) + else: + field_str = ( + f"{self.name}: Optional[{field_type}] = Field(None, " + f'title="{self.title}", ' + f'description="timeZone: {self.timeZone}")' + ) + return field_str + + def _map_enum_to_field_str(self): + """Maps an enum to a field. There doesn't seem to be a way to retrieve + the enum values, so we're just mapping to a string for now.""" + field_str = ( + f'{self.name}: Optional[str] = Field(None, title="{self.title}")' + ) + return field_str + + def _map_float_to_field_str(self): + """Map FLOAT to a string representing the pydantic field""" + field_str = ( + f'{self.name}: Optional[float] = Field(None, title="{self.title}")' + ) + return field_str + + def _map_foreign_key_to_field_str(self): + """Map FOREIGN_KEY to a string representing the pydantic field""" + field_str = ( + f'{self.name}: Optional[str] = Field(None, title="{self.title}")' + ) + return field_str + + def _map_int_to_field_str(self): + """Map INTEGER to a string representing the pydantic field""" + field_str = ( + f'{self.name}: Optional[int] = Field(None, title="{self.title}")' + ) + return field_str + + def _map_quantity_to_field_str(self): + """Map QUANTITY to a string representing the pydantic field""" + if self.unit is not None: + field_str = ( + f"{self.name}: Optional[float] = Field(None, " + f'title={self.title}, description="Unit: {self.unit}")' + ) + else: + field_str = ( + f"{self.name}: Optional[float] = Field(None, " + f'title="{self.title}")' + ) + return field_str + + def _map_str_to_field_str(self): + """Map STRING to a string representing the pydantic field""" + field_str = ( + f'{self.name}: Optional[str] = Field(None, title="{self.title}")' + ) + return field_str + + def map_to_field_str(self): + """Map field to a string representing the pydantic field""" + if self.datatype == "BOOLEAN": + return self._map_bool_to_field_str() + elif self.datatype == "DATE": + return self._map_date_to_field_str() + elif self.datatype == "ENUM": + return self._map_enum_to_field_str() + elif self.datatype == "FLOAT": + return self._map_float_to_field_str() + elif self.datatype == "FOREIGN_KEY": + return self._map_foreign_key_to_field_str() + elif self.datatype == "INTEGER": + return self._map_int_to_field_str() + elif self.datatype == "QUANTITY": + return self._map_quantity_to_field_str() + else: + return self._map_str_to_field_str() + + +class ContentsTableRow(BaseModel): + """A record pulled from slims Contents Table.""" + + cntn_fk_originalContent: Optional[str] = Field( + None, title="Original Content" + ) + icon: Optional[str] = Field(None, title="Icon") + containerIcon: Optional[str] = Field(None, title="Container icon") + cntn_fk_category: Optional[str] = Field(None, title="Category") + cntn_fk_contentType: Optional[str] = Field(None, title="Type") + cntn_barCode: Optional[str] = Field(None, title="Barcode") + cntn_fk_containerContentType: Optional[str] = Field( + None, title="Container type" + ) + cntn_cf_mass: Optional[float] = Field( + None, title="Mass", description="Unit: μg" + ) + cntn_id: Optional[str] = Field(None, title="ID") + cntn_cf_contactPerson: Optional[str] = Field(None, title="Contact Person") + cntn_dilutionFactor: Optional[float] = Field(None, title="Dilution Factor") + cntn_status: Optional[str] = Field(None, title="Status") + cntn_fk_status: Optional[str] = Field(None, title="Status") + cntn_fk_user: Optional[str] = Field(None, title="User") + cntn_cf_lotNumber: Optional[str] = Field(None, title="Lot number") + cntn_fk_group: Optional[str] = Field(None, title="Group") + cntn_fk_source: Optional[str] = Field(None, title="Source") + cntn_position_row: Optional[str] = Field(None, title="Located at row") + cntn_fk_location: Optional[str] = Field(None, title="Location") + cntn_fk_location_recursive: Optional[str] = Field( + None, title="Location (including sublocations)" + ) + locationPath: Optional[str] = Field(None, title="Location path") + cntn_position_column: Optional[str] = Field( + None, title="Located at column" + ) + cntn_cf_dateOfBirth: Optional[date] = Field( + None, + title="Date of birth", + description="timeZone: America/Los_Angeles", + ) + cntn_cf_dateRangeStart: Optional[datetime] = Field( + None, + title="Date range start", + description="timeZone: America/Los_Angeles", + ) + cntn_cf_fk_fundingCode: Optional[str] = Field(None, title="Funding Code") + cntn_cf_genotype: Optional[str] = Field(None, title="Genotype") + cntn_cf_iacucProtocol: Optional[str] = Field(None, title="IACUC Protocol") + cntn_cf_institution: Optional[str] = Field(None, title="Institution") + cntn_cf_labtracksGroup: Optional[str] = Field( + None, title="LabTracks Group" + ) + cntn_cf_labtracksId: Optional[str] = Field(None, title="Labtracks ID") + cntn_cf_lightcycle: Optional[str] = Field(None, title="LightCycle") + cntn_cf_mouseAge: Optional[int] = Field(None, title="Mouse age (mo)") + cntn_cf_name: Optional[str] = Field(None, title="Name") + cntn_cf_parentBarcode: Optional[str] = Field(None, title="Parent barcode") + cntn_cf_pedigreeName: Optional[str] = Field(None, title="Pedigree Name") + cntn_cf_sex: Optional[str] = Field(None, title="Sex") + cntn_cf_slideBarcode: Optional[str] = Field(None, title="Slide barcode") + cntn_cf_strain: Optional[str] = Field(None, title="Strain") + cntn_fk_product_strain: Optional[str] = Field( + None, title="Product (filtering without version)" + ) + relationToProband: Optional[str] = Field(None, title="Relation to proband") + father: Optional[str] = Field(None, title="Father") + mother: Optional[str] = Field(None, title="Mother") + derivedCount: Optional[int] = Field(None, title="Derivation count") + ingredientCount: Optional[int] = Field(None, title="Ingredient count") + mixCount: Optional[int] = Field(None, title="Mix count") + cntn_createdBy: Optional[str] = Field(None, title="Created by") + cntn_createdOn: Optional[datetime] = Field( + None, title="Created on", description="timeZone: America/Los_Angeles" + ) + cntn_modifiedBy: Optional[str] = Field(None, title="Modified by") + cntn_modifiedOn: Optional[datetime] = Field( + None, title="Modified on", description="timeZone: America/Los_Angeles" + ) + flags: Optional[str] = Field(None, title="Flags") + previousFlags: Optional[str] = Field(None, title="Previous flags") + cntn_externalId: Optional[str] = Field(None, title="External Id") + lctn_barCode: Optional[str] = Field(None, title="Barcode") + diss_name: Optional[str] = Field(None, title="Name") + cntp_name: Optional[str] = Field(None, title="Name") + stts_name: Optional[str] = Field(None, title="Name") + father_display: Optional[str] = Field(None, title="father_display") + lctp_positionLess: Optional[bool] = Field( + None, title="Don't require positions" + ) + cntp_slimsGeneratesBarcode: Optional[bool] = Field( + None, title="SLIMS generates a barcode for content of this type" + ) + xprs_input: Optional[int] = Field(None, title="xprs_input") + grps_groupName: Optional[str] = Field(None, title="Name") + lctn_columns: Optional[str] = Field(None, title="Columns") + isNaFilter: Optional[str] = Field(None, title="Field is N/A") + xprs_output: Optional[int] = Field(None, title="xprs_output") + cntn_pk: Optional[int] = Field(None, title="cntn_pk") + cntp_useBarcodeAsId: Optional[bool] = Field( + None, title="Use barcode as id" + ) + lctn_rows: Optional[str] = Field(None, title="Rows") + prvd_name: Optional[str] = Field(None, title="Name") + cntp_canEnrollInStudy: Optional[bool] = Field( + None, title="Can enroll in a study" + ) + cntp_containerType: Optional[bool] = Field( + None, title="Is a container type" + ) + isNotNaFilter: Optional[str] = Field(None, title="Field is not N/A") + lctn_name: Optional[str] = Field(None, title="Name") + mother_display: Optional[str] = Field(None, title="mother_display") + attachmentCount: Optional[int] = Field(None, title="attachmentCount") + sorc_name: Optional[str] = Field(None, title="Name") + user_userName: Optional[str] = Field(None, title="User name") + cntn_originalContentBarCode: Optional[str] = Field( + None, title="Original content barcode" + ) + + @staticmethod + def map_record_to_model_string(record: Record) -> List[str]: + """ + Utility method to parse the information from a slims record into + pydantic fields. + Parameters + ---------- + record : Record + A record pulled from the slims db. Easiest way to pull a record is + using: + record = slims.fetch( + "Content", is_not_null("cntn_id"), start=0, end=1 + )[0] + + Returns + ------- + List[str] + A list of string representations of pydantic fields + + """ + columns_info = [ + ContentsTableColumnInfo.parse_obj(c) + for c in record.__dict__["json_entity"]["columns"] + ] + return [c.map_to_field_str() for c in columns_info] + + @classmethod + def from_record(cls, record: Record): + """Create a ContentsTableRow from a SLIMS Record""" + field_names = cls.__fields__ + field_values = {} + for field_name in field_names: + record_value = getattr(record, field_name, None) + if record_value is not None: + record_value = record_value.value + field_values[field_name] = record_value + return cls(**field_values) diff --git a/tests/labtracks/test_response_handler.py b/tests/labtracks/test_response_handler.py index 5e9f7b55..cb4400fa 100644 --- a/tests/labtracks/test_response_handler.py +++ b/tests/labtracks/test_response_handler.py @@ -241,7 +241,9 @@ def test_map_housing(self): housing3 = Housing.construct(cage_id="1234") housing4 = Housing.construct(room_id="000", cage_id="1234") - subject_housing1 = self.rh._map_housing(room_id="-99999999999", cage_id=None) + subject_housing1 = self.rh._map_housing( + room_id="-99999999999", cage_id=None + ) subject_housing2 = self.rh._map_housing(room_id="000", cage_id=None) subject_housing3 = self.rh._map_housing(room_id=None, cage_id="1234") subject_housing4 = self.rh._map_housing(room_id="000", cage_id="1234") From f325f6e09e908ed5cc86a541df32af17aee9237f Mon Sep 17 00:00:00 2001 From: jtyoung84 <104453205+jtyoung84@users.noreply.github.com> Date: Thu, 19 Oct 2023 15:58:48 -0700 Subject: [PATCH 2/5] feat: adds slims client --- src/aind_metadata_service/slims/client.py | 24 ++++-- tests/resources/slims/test_record.pkl | Bin 0 -> 15460 bytes tests/slims/__init__.py | 1 + tests/slims/test_client.py | 86 ++++++++++++++++++++++ tests/slims/test_models.py | 85 +++++++++++++++++++++ 5 files changed, 189 insertions(+), 7 deletions(-) create mode 100644 tests/resources/slims/test_record.pkl create mode 100644 tests/slims/__init__.py create mode 100644 tests/slims/test_client.py create mode 100644 tests/slims/test_models.py diff --git a/src/aind_metadata_service/slims/client.py b/src/aind_metadata_service/slims/client.py index b8b18902..21d8bb89 100644 --- a/src/aind_metadata_service/slims/client.py +++ b/src/aind_metadata_service/slims/client.py @@ -1,10 +1,13 @@ """Module for slims client""" +import logging from pydantic import BaseSettings, Extra, Field, SecretStr from slims.criteria import equals from slims.internal import Record from slims.slims import Slims +from aind_metadata_service.slims.models import ContentsTableRow + class SlimsSettings(BaseSettings): """Configuration class. Mostly a wrapper around smartsheet.Smartsheet @@ -49,10 +52,17 @@ async def get_record(self, subject_id: str) -> Record: A single slims Record """ - content_record = self.client.fetch( - "Content", - equals("cntn_cf_labtracksId", subject_id), - start=0, - end=1, - )[0] - return content_record + try: + content_record = self.client.fetch( + "Content", + equals( + ContentsTableRow.__fields__["cntn_cf_labtracksId"].name, + subject_id, + ), + start=0, + end=1, + )[0] + return content_record + except Exception as e: + logging.error(repr(e)) + raise Exception(e) diff --git a/tests/resources/slims/test_record.pkl b/tests/resources/slims/test_record.pkl new file mode 100644 index 0000000000000000000000000000000000000000..1225732e6765cc7d96e44b485c922493f3694c3e GIT binary patch literal 15460 zcmcIqd5|5)SzlSZt6lBwSy_iAdlbd8WP5ilIksZ?Sg-bw*0Y!P5SC--W#+xv?HSFR zd75J-B{5heCadHnB=O5laGW9>MK}smgd!B72t_DD5sFZlA{3zrML3F3grf*Y!LPe# z=5^1!eN{Xu`^UbS?%(hGzW)07x_iDC+dp#g@iz8X?GGHQ9E@6SX!@@1)T*7!X3_IY zwU=whuh(9v9jo@(f#()XH?+cQtl+lFrJA;*+8yeKV=m}rvsT?T=|O-S;;HC4QP~Y@ z&)1Goc2`Lc_3&B+pq`n<<>}e83k7ZZVy)WlGUnc*8@h${O9jujHY`|>O6q_@2eiS( zxJFH@x+-2^g_h^mw6?h4RXg8*}qM7R&*cep>DaR z&qWVa`zMnUm8(^>+sN<&mmAZi&VYe~Nk0_z(A@C+Yt-1T$@E3hc9LO;;kNCq_D~(@ z@m1Qjuyxmzxvob}SR)6jeTj?$>tk}T+70Oy{} zr|zT51yv0dcdo21&n}#$ozly68gZYZy}QuAMcThafv(|d*MspN?~@zb*nUv7o%Mv| z9H~RtRDk%-v_tZ(SwJ)T(_U@j#IEye6AP=es~797DXtgFdJwRoHqRauZC9$R+8((U zW@*RKa`f0nGD-!PqyrewEQV@Q zq-fo#L9s_WX66d3SrcI8iI^)o zSK>!S>p^pFJMm!LqjcmhnQqC#yxXRC`;*>nD6SX7@?bhEQayrLIwpfK4C?(@f7?bS zADCDaF{WAT+t=8GqV=V-U<>0WF5X|=y|5UWj$?))n*)86$H;7X#yrTB#CnDP8%p}m z@xq0uY;e81=e$sL<5xxN&pJH2iu5JE+VH)oLi?@bEPqn8fs_o(J6SRMW~Pt`lei~J%QKUD2pi60ehkg3FgrOryrnab|K^NT{4q;K1~CJ8wO-@C$w z^_)kSxss}fDl1a7p|pkDSeq`g4&~DL1$(=gAoikYx2Gatu~5Tpc8Z8D>6%)9+eY1i zq&E_E1>Y=2eqe1fM;%TStH&(2=tOi9s6k{T=-@b={>sf*Bn+_LMC_6t658&*RDFe& zDB5tMs=@j)%-WK60^em&F+)rY?}wyR(e@;wwqoD3Pfe^&*B2NpREWLi;`$lOkIk9F zyXvj01}{;xJ5m|d-4s0I`DGo8=k8KzY<_<18vYRR;V>4Ws_hh0t9FG}*?iV>@u*Cc zP2Vc&V{=|mm~b~t#|)BHCbj2|Wa3f!vJMp1Dj#uls5;0@@@bih#fKIu+TPSE^69El zH$^>n?sU0)nlAmXuw9o)4XKe=o0^fGz^OG@eAXkjF0u^|tm&QM@7RX%2)VCr$=<56 zY_6NW=@v~Gt;c&vKD+NqS}4mb#QU!r4by%pnT`4R$7c*z-4>%~h!ygVYn&FRJ}D^k-X^I#KEsX3{r*%CFU7jn30mB;TxO@!Q@bd6(ekg#~I zh%GW??4F~qll)cD?#fEoHM_7neRg`8&Zo3@*amiDg9+$o+c8zgz&%#>*gA4|R*1MZ zBxdln4ZA)b1{6(g!b*F#qGRBN>D~=JyTnRVZ0(Zm+~FpI_}YS=t>E0S;hX$W13&aF z#RY0&&l|F0GFuJ^3Cye1hdWp3uc95vGK6-gig5@lnL-2lR%n4}{~NPnQpG$3QpMU1jTP z%I}`HuGWbU9z+BxjKg^i&Sr zN-5eX8Gdi){f}RN?Om}mZbgU`S~9@asj^qH)~%!|J@fHv+?Fx4r9&^3=}c_c3|R=Zp6U2irSFRN5YtJ|2eIV> zzn5*V`Hqq~35x~4*#hxRS)w%E^f1(mq%2Oifn1rQJlX*F^d{a%T;c7*Dw3#@1s;Ly% zwI%j|rjJ!65JKn4mATpZ6%~6$UIlyOZGeh9J7zmNhHmsjVg+dnPB(KPcB;woU2x!I zSt})<*H8Qc<#8+y>3zet{lyxX+X_8F7nb;%YrCGV_?xA8VhUQHY+`7CHs)b+W{VVU zyom%pQQ+g`pdj9&J(VSj6J__+iXUJ*U5Qv^lU@~VB1;wBTR;0EB&NH?GfgOcRB1L<;i6z}^{NUP#t?cc~>_O4AEJa+9>2U)~ zqV7(+zUMfz?n)Sy*luRer0%LFD^)EQ3r@gm6m71)s+b6yy1Q0kw**`4WeOJpxH8zP zb~u)MDX7JtOm<=)jPE0X>8#_2_$GdyB4JnwPLGZ0*m{h(X5{P6XwmVa(r6Tn(t|N6 zj~2c17+V*8GYH4nzc_-AonZG4_GfmqwKcfvw?9kEr*2LMjS*uf1w9R~^lzQ2hXqyj zYe0U_%eA!XG=5n;cJj@F2Kh>s%YULphtWYlCYnDuQt@q%eg%tXV~s0VX;Hxvj%wKV zJnb3d8Np9T17T#{Suf|+uC}OlCUS64bm+!sJR>=y7h6X5(NAvfT8nz)LC&_R;~CL8 z#X*>F@J%Xa3jq{MqBDwp$8XT$r50Pf969E4gW^`W0*N=Q-FSMxhe5A8)z-s}n zb;dKYGn$LVN&`2yI-D1a#RzxAxNaEF2$t0LjNrqyLmM1=H#vdg5tUp$jMoKA8v9a> z>%Lcxb>8G)%ITz{C9Q)aLEski9sAZ6J9b6K{lW@jQIWbhZu)peN5%qN>NFV6tu}qp z$1O*qI3qf%__8$1VxPA>k8t`xtQ_}csglmF5d?m>fZZEklxJIYbCH-O%v&rJOq`@- zaOAS%Zshy4(4w;Qkr|oOj!DD{rhCX#0D;)HgtT;g$a@p$%Ouqu;TjE z3x*yRN$l`vTlB#yzJF9aL_)q{V_`{>FX?8pis>%$9dWE(Z&5oo5r*X;XNE-w!30b! zQBB5(%aPL{BDXrR#9=U=5uH(d)ejo1DXq4C`f3H&l+@)2Q!AlaNoIFOO~!~zR@lIZ zR^x_d;l7idZpJf$GiqJG(Q5X5i#<=j2l-Ll_QZgUii`n%;53Mri!B4 zaap9Kub=BnY>Cy)wb(pCT_RD^wvPvE)MYaFV!h9_s5hC$EOzImH?lz*`-lekAixLH zEwLdlU9#Z-_X9_zgI=_v)#)vHqLF;bj$I@CGBIxY*_(7f7nae$uYPWk1};p}fYW&e zH$UigtWGS;mG@(-tI`s_XPQfK2J0fb-W$(oko5P|za1p&k=CLg>ypOeexx@;W`qni zng(3ZD^`uPRl5uL{#*_w@Rp_l?~HuhN}Xy^F%o5b{Yrt!IQjpVfwUTY2|%?*aHI!1n?y zWbhi~J{fl$xch*+A2=yZmpnlBlM!-&jKZ2RC^#t-Pk?w5#D_qX5^TwvKzta)M?gFU z;%S-qD2S&)d<;Zn%96)fGj0GlE(4zga2&v=02Fgx$pm-P8OWamZAPX|gEj-&S^gyhDC}eILB#?1FZ~<^3aAJ}siFg-nLi`nof4fY(2HM*} zdk1J@q9u7Jr@a8$4}x}Gro9N-b}ZOvZf_xQ_w%ao~g;LxX%u z#(fgFPXYI1z}52z$*0MWlUK-R$gAYDu;Ftu=O@7V95_D-j+o^~ehQqQ2IptM`Mk{e zS#UlN&d-4(=Hro{=gs{EH1`)ldrhYO5@@f1_RF9N$#Vwzf{gnW;JyIduL39J#u?;` zGVa%a`yz0^4xEtxW{@w*xZeQoOThgma6)pMLB1^GehauS1NYm&3HfUV`HGDD9pJtK z-0uP>WT_eCt1|BQfcq+NzYm;{h-Q$l$+$lN?rXsPA#g(KnL)lTI0TeylU^k4}7!_KeD{N zw0?k9K;C}(rfA4ovJcS5#EwHb-a*cz72w@Pe-y1jqLO_#t$M{)>Cdb7Vf?_3H{wUO z;|Q%EU=@&e4}FvKkL;tgjAp*u_tK|>_A&fG*8TXAW!*>X2UrDUJwV^&j3Rr4mN8kQ z^y#2Ih9Af}i62?k30gnEDj@42`X*%x8TKPN&cmETE5JF$IYN?7(S$r4!!G4G zMb4oW;Fz2vWZoDy$#FI~hgN`NagLC5W7tlPbBS|k1vq8S5wdLzyOQI0oI@+Xxy(62 zx{YCnIZnViv;v%nbA