From 8daebecbbc4b4058db4cbba28d630c34325c7638 Mon Sep 17 00:00:00 2001 From: Nicholas Long <1907354+nllong@users.noreply.github.com> Date: Mon, 30 Sep 2024 10:44:49 -0600 Subject: [PATCH] Release 0.5.0 (#48) * Merge main into develop after release (#21) * Release 0.3.0 (#20) * precommit * Fix delete cycle progress key and race condition (#24) * Release 0.3.0 (#20) * precommit * add progress key to delete cycles * Remove deprecated APIs, fix typos (#23) * Update README.rst * update license (#26) * Updates to support 179d, includes creating property, downloading property reports from ESPM, and updating building search(#22) * adding create_building and update_building methods * modify search_buildings to provide appropriate cycle id * adding client methods for creating extra data columns * add pass throughs for file downloads * updates for audit template workflow * method to download property xlsx --------- Co-authored-by: Alex Swindler Co-authored-by: Nicholas Long Co-authored-by: Nicholas Long <1907354+nllong@users.noreply.github.com> * prep release * increase timeout for server start * increase timeout for server start (#31) * cleanup before release (#32) * configure seed to load small EEEJ dataset for integration test (#33) * add analysis retrieve methods (#34) * Added multiple cycle upload argument (#27) * Added multiple cycle upload argument * Responded to comments and fixed failing tests * Converted to using kwargs * Pre-commit * Cleanup README.rst (#35) * cleanup readme * populate description in pypi * precommit fix * Endpoint to download an Audit Template Report Submission and store in SEED (#36) * start of AT report download method * adding endpoint to download AT report submission and store in SEED * add analysis retrieve methods (#34) * Added multiple cycle upload argument (#27) * Added multiple cycle upload argument * Responded to comments and fixed failing tests * Converted to using kwargs * Pre-commit * Cleanup README.rst (#35) * cleanup readme * populate description in pypi * precommit fix * Endpoint to download an Audit Template Report Submission and store in SEED (#36) * start of AT report download method * adding endpoint to download AT report submission and store in SEED * prep 0.4.1 (#37) * Update CHANGELOG.rst * update release instruction * Update README.rst * Update README.rst * add new method to get AT submission metadata (#39) * Update CHANGELOG.rst * Update setup.cfg * Added ESPM functions to py-seed client (#28) * Added ESPM functions to py-seed client * Cleaned up the code and put the downloads into its own folder called "reports" * Added openpyxl to requirements * Fixing tests * First pass at adding test * First draft of finishing the test * Finished get report template names test * Started troubleshooting mypy errors * Fixed remaining conflict * Fixed integration test errors * Fix mypy errors * Fixed mypy for Python 3.10 * Precommit * Fix unnecessary typing * Fixed integration tests --------- Co-authored-by: Nicholas Long <1907354+nllong@users.noreply.github.com> Co-authored-by: Alex Swindler * prep version 0.4.3 (#41) * Add PyPi release action (#42) * add python 3.12 * update test versions * add release connection and update instructions * update precommit versions (#44) * Add create organization and retrieve property cross cycle data (#45) * add methods to support cross cycle property api * cleanup * cleanup * remove prints * fix mypy * comment cleanup * Add is_omitted column to column mapping profiles (#46) * add code to enable client to read the isOmitted field in the CSV file, if present, and to pass it to the backend * isOmitted -> is_omitted * linter * prep version 0.5.0 and added compatibility matrix to readme (#47) * prep version 0.4.4 and added compatibility matrix to readme * fix compatibility table * fix title * Update README.rst * Update setup.cfg --------- Co-authored-by: Nicholas Long <1907354+nllong@users.noreply.github.com> * Update CHANGELOG.rst --------- Co-authored-by: Alex Swindler Co-authored-by: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Co-authored-by: Alex Chapin Co-authored-by: Caleb Rutan --- .pre-commit-config.yaml | 6 +- CHANGELOG.rst | 13 ++ README.rst | 15 +++ pyseed/seed_client.py | 124 ++++++++++++++++-- pyseed/seed_client_base.py | 3 +- pyseed/utils.py | 21 +-- setup.cfg | 2 +- tests/data/test-seed-data-mappings.csv | 2 +- .../test-seed-data-with-multiple-cycles.xlsx | Bin 0 -> 14142 bytes tests/test_seed_client.py | 84 +++++++++++- tests/test_utils.py | 1 + 11 files changed, 244 insertions(+), 27 deletions(-) create mode 100755 tests/data/test-seed-data-with-multiple-cycles.xlsx diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bbd7acc..73157fe 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ exclude: | repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: trailing-whitespace - id: check-added-large-files @@ -35,12 +35,12 @@ repos: "--ignore=E501,E402,W503,W504,E731", ] - repo: https://github.com/pycqa/flake8 - rev: 6.1.0 + rev: 7.0.0 hooks: - id: flake8 args: ["--ignore=E501,E402,W503,W504,E731,F401"] - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.1.0 + rev: v4.0.0-alpha.8 hooks: - id: prettier types_or: [css, yaml, markdown, html, scss, javascript] diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cd0c5b8..91f6199 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,19 @@ Changelog ========= +0.5.0 +----- + +## What's Changed + +* Add PyPi release action by @nllong in https://github.com/SEED-platform/py-seed/pull/42 +* Update precommit versions by @nllong in https://github.com/SEED-platform/py-seed/pull/44 +* Add create organization and retrieve property cross cycle data by @nllong in https://github.com/SEED-platform/py-seed/pull/45 +* Add is_omitted column to column mapping profiles by @crutan in https://github.com/SEED-platform/py-seed/pull/46 + +**Full Changelog**: https://github.com/SEED-platform/py-seed/compare/v0.4.3...v0.5.0 + + 0.4.3 ----- diff --git a/README.rst b/README.rst index 9fd3e62..d96ed71 100644 --- a/README.rst +++ b/README.rst @@ -20,6 +20,20 @@ More information can be found here: * https://github.com/SEED-platform/pyseed-examples +Compatibility Matrix +------------- + +.. list-table:: + :widths: 50 50 + :header-rows: 1 + + * - py-SEED Version + - SEED Version + * - 0.5.0 + - 3.1.0 + * - 0.4.3 + - 2.21.0 - 3.0.0 + Stakeholders ------------- @@ -133,5 +147,6 @@ This project is configured with GitHub Actions to automatically release to PyPi * Once deployed to main, create a new tag in GitHub against main and copy the change log notes into the tag description * GitHub Actions will automatically prepare the release the new version to PyPi * Go to GitHub actions to approve the release +* After merging into main, then in the command line with the develop branch run `git merge origin main` and push the changes. This might have to be done with a person with elevated privileges to bypass the protected branch settings. The GitHub Action required updates to the GitHub repo to only release on tags (https://github.com/SEED-platform/py-seed/settings/environments) after approval and on PyPi to add an authorized publisher (https://pypi.org/manage/project/py-SEED/settings/publishing/). diff --git a/pyseed/seed_client.py b/pyseed/seed_client.py index a26bfe5..6b7b0e4 100644 --- a/pyseed/seed_client.py +++ b/pyseed/seed_client.py @@ -153,6 +153,18 @@ def instance_information(self) -> dict: info["username"] = self.client.username return info + def get_users(self) -> dict: + """Get a list of users visible to the current user + + Returns: + dict: { "users": [{ + "email": "abc@def.com", + "user_id": 1 + }]} + """ + users = self.client.list(endpoint="users") + return users + def get_organizations(self, brief: bool = True) -> dict: """Get a list organizations (that one is allowed to view) @@ -179,6 +191,67 @@ def get_organizations(self, brief: bool = True) -> dict: ) return orgs + def get_user_id(self, username: str) -> Union[None, int]: + """Get the user ID for the given username + + Args: + username (str): username to get the ID for + + Returns: + int: user ID + """ + for user in self.get_users()['users']: + # compare string case insensitive + if user["email"].lower() == username.lower(): + return user["user_id"] + + return None + + def create_organization(self, org_name: str) -> dict: + """Create an organization with the given name + + Args: + org_name (str): name of the organization to create + + Returns: + dict: { + 'status': 'success', + 'message': 'Organization created', + 'organization': { + 'name': 'NEW ORG', + 'org_id': 17, + 'id': 17, + 'number_of_users': 1, + 'user_is_owner': True, + 'user_role': 'owner', + 'owners': [...], + 'sub_orgs': [...], + 'is_parent': True, + 'parent_id': 17, + ... + 'display_units_eui': 'kBtu/ft**2/year', + 'cycles': [...], + 'created': '2024-06-13', + 'mapquest_api_key': '', + + } + } + """ + # see if the organization already exists + orgs = self.get_organizations() + for org in orgs: + if org["name"].lower() == org_name.lower(): + raise Exception(f"Organization '{org_name}' already exists") + + user_id = self.get_user_id(self.client.username) + + payload = { + "user_id": user_id, + "organization_name": org_name, + } + org = self.client.post(endpoint="organizations", json=payload) + return org + def get_buildings(self) -> list[dict]: total_qry = self.client.list(endpoint="properties", data_name="pagination", per_page=100) @@ -584,13 +657,14 @@ def get_or_create_cycle( end_date: date, set_cycle_id: bool = False, ) -> dict: - """Get or create a new cycle. If the cycle_name already exists, then it simply returns the existing cycle. However, if the cycle_name does not exist, then it will create a new cycle. + """Get or create a new cycle. If the cycle_name already exists, then it simply returns + the existing cycle. However, if the cycle_name does not exist, then it will create a new cycle. Args: cycle_name (str): name of the cycle to get or create start_date (date): MM/DD/YYYY of start date cycle end_date (date): MM/DD/YYYY of end data for cycle - set_cycle_id (str): Set the object's cycle_id to the resulting cycle that is returned (either existing or newly created) + set_cycle_id (str): Set the object's cycle_id to the resulting cycle that is returned (either existing or newly created) Returns: dict: { @@ -850,6 +924,7 @@ def create_or_update_column_mapping_profile( "from_units": null, "to_table_name": "PropertyState" "to_field": "address_line_1", + "is_omitted": False }, { "from_field": "address1", @@ -860,6 +935,7 @@ def create_or_update_column_mapping_profile( ... ] + The is_omitted mapping may be absent - it is treated as False if it is not present. Returns: dict: { 'id': 1 @@ -898,9 +974,9 @@ def create_or_update_column_mapping_profile_from_file( ) -> dict: """creates or updates a mapping profile. The format of the mapping file is a CSV with the following format: - Raw Columns, units, SEED Table, SEED Columns\n - PM Property ID, , PropertyState, pm_property_id\n - Building ID, , PropertyState, custom_id_1\n + Raw Columns, units, SEED Table, SEED Columns, Omit\n + PM Property ID, , PropertyState, pm_property_id, False\n + Building ID, , PropertyState, custom_id_1, False\n ...\n This only works for 'Normal' column mapping profiles, that is, it does not work for @@ -949,7 +1025,7 @@ def set_import_file_column_mappings( ) def get_columns(self) -> dict: - """get the list of columns. + """Get the list of columns Returns: dict: { @@ -961,7 +1037,8 @@ def get_columns(self) -> dict: 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 + """Create an extra data column. If column exists, skip + Args: 'column_name': 'project_type', 'display_name': 'Project Type', @@ -979,7 +1056,6 @@ def create_extra_data_column(self, column_name: str, display_name: str, inventor } } """ - # get extra data columns (only) result = self.client.list(endpoint="columns") columns = result['columns'] @@ -1005,7 +1081,8 @@ def create_extra_data_column(self, column_name: str, display_name: str, inventor 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. + """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, @@ -1156,11 +1233,13 @@ def get_meter_data(self, property_id, interval: str = 'Exact', excluded_meter_id def start_save_data(self, import_file_id: int, multiple_cycle_upload: bool = False) -> dict: """start the background process to save the data file to the database. - This is the state before the mapping. + This is the state before the mapping. If multiple_cycle_upload is set to True, then the + importing file's year_ending column will be used to determine the cycle. Note that the + cycles must be created in SEED for the multiple cycle upload to work correctly Args: import_file_id (int): id of the import file to save - multiple_cycle_upload (bool): whether to use multiple cycle upload + multiple_cycle_upload (bool): whether to use multiple cycle upload. Returns: dict: progress key @@ -1435,8 +1514,12 @@ def upload_and_match_datafile( column_mapping_profile_name (str): Name of the column mapping profile to use column_mappings_file (str): Mapping that will be uploaded to the column_mapping_profile_name import_meters_if_exist (bool): If true, will import meters from the meter tab if they exist in the datafile. Defaults to False. + + Kwargs: + datafile_type (str): Type of datafile multiple_cycle_upload (bool): Whether to use multiple cycle upload. Defaults to False. + Returns: dict: { matching summary @@ -1707,3 +1790,22 @@ def retrieve_analysis_result(self, analysis_id: int, analysis_view_id: int) -> d url_args={"PK": analysis_id, "ANALYSIS_VIEW_PK": analysis_view_id}, include_org_id_query_param=True, ) + + def get_cross_cycle_data(self, property_view_id: int) -> dict: + """Retrieve the cross cycle data for a property. This is the data that + is shared across all the cycles used to populate a property's cross + cycle view. + + Args: + property_view_id (int): Property view id + + Returns: + dict: Cross cycle data for the property view + """ + return self.client.get( + None, + required_pk=False, + endpoint="properties_cross_cycle_data", + url_args={"PK": property_view_id}, + include_org_id_query_param=True, + ) diff --git a/pyseed/seed_client_base.py b/pyseed/seed_client_base.py index 51bcbfa..107f28d 100644 --- a/pyseed/seed_client_base.py +++ b/pyseed/seed_client_base.py @@ -75,6 +75,7 @@ 'audit_template_building_xml': '/api/v3/audit_template/PK/get_building_xml', 'audit_template_submission': '/api/v3/audit_template/PK/get_submission', 'import_files_matching_results': '/api/v3/import_files/PK/matching_and_geocoding_results/', + 'properties_cross_cycle_data': '/api/v3/properties/PK/links/', 'progress': '/api/v3/progress/PROGRESS_KEY/', 'properties_analyses': '/api/v3/properties/PK/analyses/', 'properties_meter_usage': '/api/v3/properties/PK/meter_usage/', @@ -250,7 +251,7 @@ def _check_response(self, response, *args, **kwargs): # this is a system matching response, which is okay. return the success flag of this status_flag = response.json()['progress_data'].get('status', None) error = status_flag not in ['not-started', 'success', 'parsing'] - elif not any(key in ['results', 'readings', 'data', 'status', 'id', 'organizations', 'sha'] for key in response.json().keys()): + elif not any(key in ['results', 'readings', 'data', 'status', 'id', 'organizations', 'sha', 'users'] for key in response.json().keys()): # In some cases there is not a 'status' field, so check if there are # any other keys in the response that depict a success: # readings - this comes from meters diff --git a/pyseed/utils.py b/pyseed/utils.py index e10fd59..ee09ef4 100644 --- a/pyseed/utils.py +++ b/pyseed/utils.py @@ -113,15 +113,18 @@ def read_map_file(mapfile_path): # Open the mapping file and fill list maplist = list() - for rowitem in map_reader: - maplist.append( - { - 'from_field': rowitem[0], - 'from_units': rowitem[1], - 'to_table_name': rowitem[2], - 'to_field': rowitem[3], - } - ) + data = { + "from_field": rowitem[0], + "from_units": rowitem[1], + "to_table_name": rowitem[2], + "to_field": rowitem[3], + } + try: + data["is_omitted"] = True if rowitem[4].lower().strip() == "true" else False + except IndexError: + data["is_omitted"] = False + + maplist.append(data) return maplist diff --git a/setup.cfg b/setup.cfg index 5fee117..c77b0b7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name=py-seed -version=0.4.3 +version=0.5.0 description=A Python API client for the SEED Platform author=Nicholas Long, Katherine Fleming, Fable Turas, Paul Munday author_email=nicholas.long@nrel.gov, fable@raintechpdx.com, paul@paulmunday.net diff --git a/tests/data/test-seed-data-mappings.csv b/tests/data/test-seed-data-mappings.csv index 9e17232..d32fd0e 100644 --- a/tests/data/test-seed-data-mappings.csv +++ b/tests/data/test-seed-data-mappings.csv @@ -8,7 +8,7 @@ Sq. Ft,ft**2,PropertyState,gross_floor_area Total GHG Emissions Intensity,kgCO2e/ft**2/year,PropertyState,total_ghg_emissions_intensity Site EUI,kBtu/ft**2/year,PropertyState,site_eui PM Release Date,,PropertyState,release_date -Year Ending,,PropertyState,Year Ending Excel +Year Ending,,PropertyState,year_ending GHGI Target,,PropertyState,GHGI Target GHGI Target Year,,PropertyState,GHGI Target Year EUI Target,,PropertyState,EUI Target diff --git a/tests/data/test-seed-data-with-multiple-cycles.xlsx b/tests/data/test-seed-data-with-multiple-cycles.xlsx new file mode 100755 index 0000000000000000000000000000000000000000..b84c2ea262de7a029de559414ef4365bf316d2c3 GIT binary patch literal 14142 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