diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2818df..6bd3f6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,9 +45,19 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install -r requirements-test.txt - - name: Build and run stack + - name: Install more recent docker compose + uses: ndeloof/install-compose-action@v0.0.1 + with: + version: v2.20.0 + legacy: true + - name: Print versions run: | printenv + docker --version + docker-compose --version + which docker-compose + - name: Build and run stack + run: | docker volume create --name=pyseed_media # verify that the stack wasn't cached docker-compose -f tests/integration/docker-compose.yml stop @@ -65,13 +75,16 @@ jobs: with: time: "30s" - name: Dump docker logs before tests - uses: jwalton/gh-docker-logs@v1 + uses: jwalton/gh-docker-logs@v2 - name: Extract API credentials from SEED docker instance run: | - docker exec seed_web ./manage.py create_test_user_json --username user@seed-platform.org --host http://localhost:8000 --pyseed > seed-config.json + docker exec pyseed_web ./manage.py create_test_user_json --username user@seed-platform.org --host http://localhost:8000 --pyseed > seed-config.json - name: Run tests with pytest + env: + SEED_PM_UN: ${{ secrets.SEED_PM_UN }} + SEED_PM_PW: ${{ secrets.SEED_PM_PW }} run: | pytest -m "integration" -s - name: Dump docker logs on failure if: failure() - uses: jwalton/gh-docker-logs@v1 + uses: jwalton/gh-docker-logs@v2 diff --git a/.gitignore b/.gitignore index 4311468..01f60a9 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ coverage.xml *,cover .hypothesis/ coverage-report +tests/output # Translations *.mo @@ -99,3 +100,6 @@ ENV/ # Seed config files seed-config*.json + +*.DS_Store +*credentials.json diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 78584cf..1a5376c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,8 +4,10 @@ Changelog 0.3.0 ----- -## What's Changed -* Add instance info and fix a couple bugs by @nllong in https://github.com/SEED-platform/py-seed/pull/16 +What's Changed +************** + +* Add instance info and fix a couple of bugs by @nllong in https://github.com/SEED-platform/py-seed/pull/16 * Fix building list and client information by @nllong in https://github.com/SEED-platform/py-seed/pull/17 * get and create meters and meter readings by @nllong in https://github.com/SEED-platform/py-seed/pull/18 * Add GeoJSON Area Calc by @nllong in https://github.com/SEED-platform/py-seed/pull/19 diff --git a/README.rst b/README.rst index 8c81cf1..b3dbe64 100644 --- a/README.rst +++ b/README.rst @@ -9,6 +9,16 @@ Py-SEED A python API client for the SEED Platform. This is an updated version of the Client. It is compatible with the latest version of the SEED Platform (>2.17.4). This client still has access to the previous format of generating a lower level API client by accessing `seed_client_base.SEEDOAuthReadOnlyClient`, `seed_client_base.SEEDOAuthReadWriteClient`, `seed_client_base.SEEDReadOnlyClient`, and `seed_client_base.SEEDReadWriteClient`. This lower level API is documented below under the `Low-Level Documentation` +Stakeholders +------------- + +The following list of stakeholders should be considered when making changes to this module + +- 179D Tax Deduction Web Application +- Earth Advantage Green Building Registry +- User scripts for managing building data +- ECAM + Documentation ------------- The SEED client is a read-write client. To install the client run: @@ -97,6 +107,19 @@ Usage: # get a single property seed_client.get(property_pk, endpoint='properties') +Local Testing +------------- + +Tests can be run via the `pytest` command. + +You will need to export environment variables for a test portfolio manager account to test integration. Environment variables should be named: +```bash +SEED_PM_UN +SEED_PM_PW +``` + + + License ------- Full details in LICENSE file. diff --git a/cspell.json b/cspell.json new file mode 100644 index 0000000..54219fa --- /dev/null +++ b/cspell.json @@ -0,0 +1,27 @@ +{ + "version": "0.2", + "ignorePaths": [], + "dictionaryDefinitions": [], + "dictionaries": [], + "words": [ + "apibase", + "buildingsync", + "codeauthor", + "dname", + "durl", + "ESPM", + "excpt", + "geocoded", + "JSONAPI", + "Munday", + "officedocument", + "openxmlformats", + "pyseed", + "seedrecords", + "spreadsheetml", + "taxlot", + "taxlots" + ], + "ignoreWords": [], + "import": [] +} diff --git a/pyseed/seed_client.py b/pyseed/seed_client.py index 0f770fd..66a21da 100644 --- a/pyseed/seed_client.py +++ b/pyseed/seed_client.py @@ -12,6 +12,7 @@ import logging import time from collections import Counter +from csv import DictReader from datetime import date from pathlib import Path from urllib.parse import _NetlocResultMixinStr @@ -84,7 +85,7 @@ def read_connection_config_file(cls, filepath: Path) -> dict: "api_key": "1b5ea1ee220c8628789c61d66253d90398e6ad03", "port": 8000, "use_ssl": false, - "seed_org_name: "test-org" + "seed_org_name": "test-org" } Args: @@ -181,7 +182,6 @@ def get_organizations(self, brief: bool = True) -> Dict: def get_buildings(self) -> List[dict]: total_qry = self.client.list(endpoint="properties", data_name="pagination", per_page=100) - # print(f" total: {total_qry}") # step through each page of the results buildings: List[dict] = [] for i in range(1, total_qry['num_pages'] + 1): @@ -241,10 +241,12 @@ def get_property(self, property_id: int) -> dict: ) def search_buildings( - self, identifier_filter: str = None, identifier_exact: str = None + self, identifier_filter: str = None, identifier_exact: str = None, cycle_id: int = None ) -> dict: - payload = { - "cycle": self.cycle_id, + if not cycle_id: + cycle_id = self.cycle_id + payload: Dict[str, Any] = { + "cycle": cycle_id, } if identifier_filter is not None: payload["identifier"] = identifier_filter @@ -462,6 +464,46 @@ def update_labels_of_buildings( ) return result + def create_building(self, params: dict) -> list: + """ + Creates a building with unique ID (either pm_property_id or custom_id_1 for now) + Expects params to contain a state dictionary and a cycle id + Optionally pass in a cycle ID + + Returns the created property_view id + """ + # first try matching on custom_id_1 + matching_id = params.get('state', {}).get('custom_id_1', None) + + if not matching_id: + # then try on pm_property_id + matching_id = params.get('state', {}).get('pm_property_id', None) + + if not matching_id: + raise Exception( + "This property does not have a pm_property_id or a custom_id_1 for matching...cannot create." + ) + + cycle_id = params.get('cycle_id', None) + # include appropriate cycle in search (if not using the default cycle set on the class) + buildings = self.search_buildings(identifier_exact=matching_id, cycle_id=cycle_id) + + if len(buildings) > 0: + raise Exception( + "A property matching the provided matching ID (pm_property_id or custom_id_1) already exists." + ) + + results = self.client.post(endpoint="properties", json=params) + return results + + def update_building(self, id, params: dict) -> list: + """ + Updates a building's property_view + Expects id and params to contain a state dictionary + """ + results = self.client.put(id, endpoint="properties", json=params) + return results + def get_cycles(self) -> list: """Return a list of all the cycles for the organization. @@ -898,6 +940,93 @@ def set_import_file_column_mappings( json={"mappings": mappings}, ) + def get_columns(self) -> dict: + """get the list of columns. + + Returns: + dict: { + "status": "success", + "columns: [{...}] + } + """ + result = self.client.list(endpoint="columns") + return result + + def create_extra_data_column(self, column_name: str, display_name: str, inventory_type: str, column_description: str, data_type: str) -> dict: + """ create an extra data column. If column exists, skip + Args: + 'column_name': 'project_type', + 'display_name': 'Project Type', + 'inventory_type': 'Property' or 'Taxlot', + 'column_description': 'Project Type (New or Retrofit)', + 'data_type': 'string', + + Returns: + dict:{ + "status": "success", + "column": { + "id": 151, + "name": "project_type_151", + ... + } + } + """ + + # get extra data columns (only) + result = self.client.list(endpoint="columns") + columns = result['columns'] + extra_data_cols = [item for item in columns if item['is_extra_data']] + + # see if extra data column already exists (for now don't update it, just skip it) + res = list(filter(lambda extra_data_cols: extra_data_cols['column_name'] == column_name, extra_data_cols)) + if res: + # column already exists + result = {"status": "noop", "message": "column already exists"} + else: + # create + payload = { + "column_name": column_name, + "display_name": display_name, + "table_name": "PropertyState" if inventory_type == "Property" else "TaxlotState", + "column_description": column_description, + "data_type": data_type, + "organization_id": self.get_org_id() + } + result = self.client.post(endpoint="columns", json=payload) + + return result + + def create_extra_data_columns_from_file(self, columns_csv_filepath: str) -> list: + """ create extra data columns from a csv file. if column exist, skip. + Args: + 'columns_csv_filepath': 'path/to/file' + file is expected to have headers: column_name, display_name, column_description, + inventory_type (Property or Taxlot), and data_type (SEED column data_types) + + See example file at tests/data/test-seed-create-columns.csv + + Returns: + list:[{ + "status": "success", + "column": { + "id": 151, + "name": "project_type_151", + ... + } + }] + """ + # open file in read mode + with open(columns_csv_filepath, 'r') as f: + dict_reader = DictReader(f) + columns = list(dict_reader) + + results = [] + for col in columns: + result = self.create_extra_data_column(**col) + results.append(result) + + return results + def get_meters(self, property_id: int) -> list: """Return the list of meters assigned to a property (the property view id). Note that meters are attached to the property (not the state nor the property view). @@ -1249,3 +1378,98 @@ def upload_and_match_datafile( result = self.track_progress_result(progress_key) return matching_results + + def retrieve_at_building_and_update(self, audit_template_building_id: int, cycle_id: int, seed_id: int) -> dict: + """Connect to audit template and retrieve audit XML by building ID + + Args: + audit_template_building_id (int): ID of the building in the audit template + cycle_id (int): Cycle ID in SEED + seed_id (int): PropertyView ID in SEED + + Returns: + dict: Response from the SEED API + """ + + # api/v3/audit_template/pk/get_building_xml + response = self.client.get( + None, + required_pk=False, + endpoint="audit_template_building_xml", + url_args={"PK": audit_template_building_id} + ) + + if response['status'] == 'success': + # now post to api/v3/properties/PK/update_with_buildingsync + xml_file = response['content'] + filename = 'at_' + str(int(time.time() * 1000)) + '.xml' + files = [ + ('file', (filename, xml_file)), + ('file_type', (None, 1)) + ] + + response = self.client.put( + None, + required_pk=False, + endpoint="properties_update_with_buildingsync", + url_args={"PK": seed_id}, + files=files, + cycle_id=cycle_id + ) + + return response + + def retrieve_portfolio_manager_property(self, username: str, password: str, pm_property_id: int, save_file_name: Path) -> dict: + """Connect to portfolio manager and download an individual properties data in Excel format + + Args: + username (str): ESPM login username + password (str): ESPM password + pm_property_id (int): ESPM ID of the property to download + save_file_name (Path): Location to save the file, preferably an absolute path + + Returns: + bool: Did the file download? + """ + if save_file_name.exists(): + raise Exception(f"Save filename already exists, save to a new file name: {save_file_name}") + + response = self.client.post( + "portfolio_manager_property_download", + json={"username": username, "password": password}, + url_args={"PK": pm_property_id} + ) + result = {'status': 'error'} + # save the file to the location that was passed + # note that the data are returned directly (the ESPM URL directly downloads the file) + if isinstance(response, bytes): + with open(save_file_name, 'wb') as f: + f.write(response) + result['status'] = 'success' + return result + + def import_portfolio_manager_property(self, seed_id: int, cycle_id: int, mapping_profile_id: int, file_path: str) -> dict: + """Import the downloaded xlsx file into SEED on a specific propertyID + Args: + seed_id (int): Property view ID to update with the ESPM file + cycle_id (int): Cycle ID + mapping_profile_id (int): Column Mapping Profile ID + file: path to file downloaded from the retrieve_portfolio_manager_report method above + ESPM file will have meter data that we want to handle (electricity and natural gas) + in the 'Meter Entries' tab""" + + files_params = [ + ("file", (Path(file_path).name, open(Path(file_path).resolve(), "rb"))), + ] + + response = self.client.put( + None, + required_pk=False, + endpoint="property_update_with_espm", + url_args={"PK": seed_id}, + files=files_params, + cycle_id=cycle_id, + mapping_profile_id=mapping_profile_id + ) + + return response diff --git a/pyseed/seed_client_base.py b/pyseed/seed_client_base.py index 93b3b3b..ebbc527 100644 --- a/pyseed/seed_client_base.py +++ b/pyseed/seed_client_base.py @@ -64,11 +64,16 @@ 'import_files_start_matching_pk': '/api/v3/import_files/PK/start_system_matching_and_geocoding/', 'import_files_check_meters_tab_exists_pk': '/api/v3/import_files/PK/check_meters_tab_exists/', 'org_column_mapping_import_file': 'api/v3/organizations/ORG_ID/column_mappings/', + 'portfolio_manager_property_download': '/api/v3/portfolio_manager/PK/download/', + # PUTs with replaceable keys: + 'properties_update_with_buildingsync': 'api/v3/properties/PK/update_with_building_sync/', + 'property_update_with_espm': 'api/v3/properties/PK/update_with_espm/', # GETs with replaceable keys 'import_files_matching_results': '/api/v3/import_files/PK/matching_and_geocoding_results/', 'progress': '/api/v3/progress/PROGRESS_KEY/', 'properties_meters': '/api/v3/properties/PK/meters/', 'properties_meter_usage': '/api/v3/properties/PK/meter_usage/', + 'audit_template_building_xml': '/api/v3/audit_template/PK/get_building_xml', # GET & POST with replaceable keys 'properties_meters_reading': '/api/v3/properties/PK/meters/METER_PK/readings/', } @@ -194,15 +199,28 @@ def _check_response(self, response, *args, **kwargs): """ error = False error_msg = 'Unknown error from SEED API' - # OK, Created, Accepted - if response.status_code not in [200, 201, 202]: + + # grab the response content type to determine json, spreadsheet, or text + response_content_types = response.headers.get('Content-Type', []) + + # OK, Created, Accepted, No-Content + if response.status_code not in [200, 201, 202, 204]: error = True error_msg = 'SEED returned status code: {}'.format(response.status_code) # SEED adds a status key to the response to indicate success/error # This is superfluous as status codes should be used to indicate an # error, but they are not always set correctly. + elif response.status_code == 204: + # there will not be response content with a 204 + error = False + elif 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' in response_content_types: + # spreadsheet response + error = False + elif 'application/json' not in response_content_types: + # get as text + if not response.content: + error = True elif isinstance(response.json(), dict): - status_field = response.json().get('status', None) has_progress_key = 'progress_key' in response.json().keys() if status_field: @@ -258,6 +276,15 @@ def _get_result(self, response, data_name=None, **kwargs): tries to determine what the first element of the resulting JSON is which is then used as the base for the rest of the response. This is not always desired, so pass data_name='all' if you want to get the entire response back.""" + + # grab the response content type to determine json, spreadsheet, or text + response_content_types = response.headers.get('Content-Type', []) + + # pass through for spreadsheet (?) + if 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' in response_content_types: + return response.content + if 'application/json' not in response_content_types: + return {'status': 'success', 'content': response.content} if not data_name: url = response.request.url # take the last part of the url unless it's a digit @@ -268,7 +295,12 @@ def _get_result(self, response, data_name=None, **kwargs): else: data_name = durl[1] # actual results should be under data_name or the fallbacks - result = response.json() + # handle a 204 + result = None + if response.status_code == 204: + result = {'status': 'success'} + else: + result = response.json() if result is None: error_msg = 'No results returned' self._raise_error(response, error_msg, stack_pos=2, **kwargs) diff --git a/pyseed/utils.py b/pyseed/utils.py index 4732eca..e10fd59 100644 --- a/pyseed/utils.py +++ b/pyseed/utils.py @@ -17,10 +17,9 @@ def _rad(value): def _ring_area(coordinates): - """ - Calculate the approximate total_area of the polygon were it projected onto - the earth. Note that this _area will be positive if ring is oriented - clockwise, otherwise it will be negative. + """Calculate the approximate total_area of the polygon were it projected onto + the earth. Note that this _area will be positive if ring is oriented + clockwise, otherwise it will be negative. Reference: Robert. G. Chamberlain and William H. Duquette, "Some Algorithms for diff --git a/tests/data/portfolio-manager-single-22482007.xlsx b/tests/data/portfolio-manager-single-22482007.xlsx new file mode 100644 index 0000000..0317d2c Binary files /dev/null and b/tests/data/portfolio-manager-single-22482007.xlsx differ diff --git a/tests/data/test-seed-create-columns.csv b/tests/data/test-seed-create-columns.csv new file mode 100644 index 0000000..2310d91 --- /dev/null +++ b/tests/data/test-seed-create-columns.csv @@ -0,0 +1,33 @@ +column_name,display_name,column_description,inventory_type,data_type +completion_date,Completion Date,Completion Date,Property,string +pathway,Pathway,Pathway (Traditional or Alternative),Property,string +project_type,Project Type,Project Type (New or Retrofit),Property,string +number_of_floors,Number of Floors,Number of Floors,Property,integer +aspect_ratio,Aspect Ratio,Aspect Ratio,Property,number +window_wall_ratio,Window to Wall Ratio,Window to Wall Ratio,Property,number +window_u_factor,Window U Factor,Window U Factor,Property,number +window_shgc,Window SHGC,Window SHGC,Property,number +has_skylights,Has Skylights?,Does the building have skylights?,Property,boolean +hvac_system,HVAC System,HVAC System Type,Property,string +skylights_u_factor,Skylights U-Factor,Skylights U-Factor value,Property,number +skylights_shgc,Skylights SHGC,Skylights SHGC value,Property,number +roof_thermal_perf_type,Roof Thermal Performance Type,U-Factor or R-Value,Property,string +roof_thermal_perf_value,Roof Thermal Performance Value,Value of U-Factor or R-Value depending on what was selected as Roof Thermal Performance Type,Property,number +roof_surface_property_type,Roof Surface Property Type,Solar Reflectance or Solar Absorptance,Property,string +roof_surface_property_value,Roof Surface Property Value,Value of Solar Reflectance or Solar Absorptance depending on what was selected in Roof Surface Property Type,Property,number +wall_thermal_perf_type,Wall Thermal Performance Type,U-Factor or R-Value,Property,string +wall_thermal_perf_value,Wall Thermal Performance Value,Value of U-Factor or R-Value depending on what was selected as Roof Thermal Performance Type,Property,number +occupancy_sensors_used,Occupancy Sensors Used?,True/False whether Occupancy sensors are used,Property,boolean +percentage_area_using_sensors,Percentage of Area using Occupancy Sensors,"If sensors are used, what percentage of the area do they cover?",Property,number +water_heating_energy_factor,Water Heating Energy Factor,Water Heating Energy Factor,Property,number +proposed_lpd,LPD,LPD,Property,number +air_system_fan_total_efficiency,Air System Fan Total Efficiency,Air System Fan Total Efficiency,Property,number +boiler_average_efficiency,Boiler Average Efficiency,Boiler Average Efficiency,Property,number +dx_cooling_cop,DX Cooling COP,DX Cooling COP,Property,number +zone_hvac_fan_total_efficiency,Zone HVAC Fan Total Efficiency,Zone HVAC Fan Total Efficiency,Property,number +dx_heating_cop,DX Heating COP,DX Heating COP,Property,number +gas_coil_average_efficiency,Gas Coil Average Efficiency,Gas Coil Average Efficiency,Property,number +chiller_average_cop,Chiller Average COP,Chiller Average COP,Property,number +electricity_savings,Electricity Savings,Electricity Savings,Property,number +natural_gas_savings,Natural Gas Savings,Natural Gas Savings,Property,number +deduction,Deduction,Deduction,Property,number diff --git a/tests/integration/docker-compose.yml b/tests/integration/docker-compose.yml index 3df3b41..c1b002a 100644 --- a/tests/integration/docker-compose.yml +++ b/tests/integration/docker-compose.yml @@ -1,7 +1,7 @@ version: "3.4" services: db-postgres: - container_name: seed_postgres + container_name: pyseed_postgres image: timescale/timescaledb-postgis:latest-pg12 environment: - POSTGRES_DB=seed @@ -14,10 +14,10 @@ services: max-size: 50m max-file: "5" db-redis: - container_name: seed_redis + container_name: pyseed_redis image: redis:5.0.1 web: - container_name: seed_web + container_name: pyseed_web image: seedplatform/seed:develop environment: - AWS_ACCESS_KEY_ID @@ -52,7 +52,7 @@ services: max-size: 50m max-file: "5" web-celery: - container_name: seed_celery + container_name: pyseed_celery image: seedplatform/seed:develop build: . command: /seed/docker/start_celery_docker.sh diff --git a/tests/test_seed_base.py b/tests/test_seed_base.py index 72cb678..80c8d75 100644 --- a/tests/test_seed_base.py +++ b/tests/test_seed_base.py @@ -104,6 +104,27 @@ def test_get_or_create_dataset(self): assert dataset['super_organization'] == self.seed_client.client.org_id assert dataset is not None + def test_get_columns(self): + result = self.seed_client.get_columns() + assert result['status'] == 'success' + assert len(result['columns']) >= 1 + + def test_create_column(self): + result = self.seed_client.create_extra_data_column( + column_name='test_col', + display_name='A Test Column', + inventory_type="Property", + column_description="this is a test column", + data_type="string") + assert result['status'] == 'success' + assert 'id' in result['column'] + + def test_create_columns_from_file(self): + cols_filepath = 'tests/data/test-seed-create-columns.csv' + result = self.seed_client.create_extra_data_columns_from_file(cols_filepath) + assert len(result) + assert result[0]['status'] + def test_get_column_mapping_profiles(self): result = self.seed_client.get_column_mapping_profiles() assert len(result) >= 1 diff --git a/tests/test_seed_client.py b/tests/test_seed_client.py index c0f17df..1bf2766 100644 --- a/tests/test_seed_client.py +++ b/tests/test_seed_client.py @@ -4,6 +4,7 @@ """ # Imports from Third Party Modules +import os import pytest import unittest from datetime import date @@ -59,10 +60,15 @@ def test_seed_client_info(self): assert set(("version", "sha")).issubset(info.keys()) def test_seed_buildings(self): + # set cycle before retrieving (just in case) + self.seed_client.get_cycle_by_name('pyseed-api-test', set_cycle_id=True) buildings = self.seed_client.get_buildings() + # ESPM test creates a building now too, assert building count is 10 or 11? assert len(buildings) == 10 def test_search_buildings(self): + # set cycle + self.seed_client.get_cycle_by_name('pyseed-api-test', set_cycle_id=True) properties = self.seed_client.search_buildings(identifier_exact="B-1") assert len(properties) == 1 @@ -73,7 +79,6 @@ def test_search_buildings(self): # test the property view (same as previous, just less data). It # is recommended to use `get_property` instead. prop = self.seed_client.get_property_view(properties[0]["id"]) - print(prop) assert prop["id"] == properties[0]["id"] assert prop["cycle"]["name"] == "pyseed-api-test" @@ -81,6 +86,66 @@ def test_search_buildings(self): properties = self.seed_client.search_buildings(identifier_filter="B-1") assert len(properties) == 2 + def test_create_update_building(self): + # create a new building (property, propertyState, propertyView) + # Update the building + completion_date = "02/02/2023" + year = '2023' + cycle = self.seed_client.get_or_create_cycle( + "pyseed-api-integration-test", + date(int(year), 1, 1), + date(int(year), 12, 31), + set_cycle_id=True, + ) + + state = { + "organization_id": self.organization_id, + "custom_id_1": "123456", + "address_line_1": "123 Testing St", + "city": "Beverly Hills", + "state": "CA", + "postal_code": "90210", + "property_name": "Test Building", + "property_type": None, + "gross_floor_area": None, + "conditioned_floor_area": None, + "occupied_floor_area": None, + "site_eui": None, + "site_eui_modeled": None, + "source_eui_weather_normalized": None, + "source_eui": None, + "source_eui_modeled": None, + "site_eui_weather_normalized": None, + "total_ghg_emissions": None, + "total_marginal_ghg_emissions": None, + "total_ghg_emissions_intensity": None, + "total_marginal_ghg_emissions_intensity": None, + "generation_date": None, + "recent_sale_date": None, + "release_date": None, + "extra_data": { + "pathway": "new", + "completion_date": completion_date + } + } + + params = {'state': state, 'cycle_id': cycle["id"]} + + result = self.seed_client.create_building(params=params) + assert result["status"] == "success" + assert result["view"]["id"] is not None + view_id = result["view"]["id"] + + # update that property (by ID) + state['property_name'] = 'New Name Building' + + properties = self.seed_client.search_buildings(identifier_exact=state['custom_id_1']) + assert len(properties) == 1 + + params2 = {'state': state} + result2 = self.seed_client.update_building(view_id, params=params2) + assert result2["status"] == "success" + def test_add_label_to_buildings(self): # get seed buildings prop_ids = [] @@ -252,9 +317,68 @@ def test_upload_single_method_with_meters(self): meter_data = self.seed_client.get_meter_data(building[0]["id"]) assert len(meter_data['readings']) == 24 - # def test_get_buildings_with_labels(self): - # buildings = self.seed_client.get_view_ids_with_label(['In Violation', 'Compliant', 'Email']) - # for building in buildings: - # print(building) + def test_download_espm_property(self): + # For testing, read in the ESPM username and password from + # environment variables. - # assert len(buildings) == 3 + save_file = self.output_dir / "espm_test_22178850.xlsx" + if save_file.exists(): + save_file.unlink() + + self.seed_client.retrieve_portfolio_manager_property( + username=os.environ.get('SEED_PM_UN'), + password=os.environ.get('SEED_PM_PW'), + pm_property_id=22178850, + save_file_name=save_file + ) + + self.assertTrue(save_file.exists()) + + # redownload and show an error + with self.assertRaises(Exception) as excpt: + self.seed_client.retrieve_portfolio_manager_property( + username=os.environ.get('SEED_PM_UN'), + password=os.environ.get('SEED_PM_PW'), + pm_property_id=22178850, + save_file_name=save_file + ) + + self.assertEqual( + str(excpt.exception), + f'Save filename already exists, save to a new file name: {str(save_file)}' + ) + + def test_upload_espm_property_to_seed(self): + + file = Path("tests/data/portfolio-manager-single-22482007.xlsx") + + # need a building + buildings = self.seed_client.get_buildings() + building = None + if buildings: + building = buildings[0] + self.assertTrue(building) + + # need a column mapping profile + mapping_file = Path("tests/data/test-seed-data-mappings.csv") + mapping_profile = self.seed_client.create_or_update_column_mapping_profile_from_file('ESPM Test', mapping_file) + self.assertTrue('id' in mapping_profile) + + response = self.seed_client.import_portfolio_manager_property(building['id'], self.seed_client.cycle_id, mapping_profile['id'], file) + self.assertTrue(response['status'] == 'success') + + # def test_retrieve_at_building_and_update(self): + # # NOTE: commenting this out as we cannot set the AT credentials in SEED from py-seed + + # # need a building + # buildings = self.seed_client.get_buildings() + # building = None + # if buildings: + # building = buildings[0] + # self.assertTrue(building) + + # # need an Audit Template Building ID (use envvar for this) + # at_building_id=os.environ.get('SEED_AT_BUILDING_ID'), + + # response = self.seed_client.retrieve_at_building_and_update(self, at_building_id, self.cycle_id, building['id']) + # self.assertTrue(response['status'] == 'success') diff --git a/tests/test_seed_client_base.py b/tests/test_seed_client_base.py index 681f9d5..276bcf3 100644 --- a/tests/test_seed_client_base.py +++ b/tests/test_seed_client_base.py @@ -94,6 +94,8 @@ def get_mock_response(data=None, data_name='data', error=False, mock_response = mock.MagicMock() mock_response.status_code = status_code mock_response.request = mock_request + mock_response.headers = {'Content-Type': 'application/json'} + # SEED old style if content: if error: @@ -309,7 +311,6 @@ def test_post(self, mock_requests): mock_requests.post.return_value = get_mock_response(data="Llama!") result = self.client.post(endpoint='test1', json={'foo': 'bar', 'not_org': 1}) self.assertEqual('Llama!', result) - expected = { 'headers': {'Authorization': 'Bearer dfghjk'}, 'params': {