From 23e4aa84e00fb744bf538936f90717250a9590bf Mon Sep 17 00:00:00 2001 From: neha-vard Date: Thu, 13 Apr 2023 18:27:25 -0500 Subject: [PATCH 01/22] Create BaseModels for inverter data --- pv_site_api/pydantic_models.py | 66 ++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/pv_site_api/pydantic_models.py b/pv_site_api/pydantic_models.py index 8a09408..26c9a94 100644 --- a/pv_site_api/pydantic_models.py +++ b/pv_site_api/pydantic_models.py @@ -109,3 +109,69 @@ class ClearskyEstimate(BaseModel): clearsky_estimate: List[ClearskyEstimateValues] = Field( ..., description="List of times and clearsky estimate" ) + + +class InverterProductionState(BaseModel): + """Production State data for an inverter""" + + productionRate: float = Field(..., description="The current production rate in kW") + isProducing: bool = Field( + ..., description="Whether the solar inverter is actively producing energy or not" + ) + totalLifetimeProduction: float = Field(..., description="The total lifetime production in kWh") + lastUpdated: str = Field( + ..., description="ISO8601 UTC timestamp of last received production state update" + ) + + +class InverterInformation(BaseModel): + """ "Inverter information""" + + id: str = Field(..., description="Solar inverter vendor ID") + brand: str = Field(..., description="Solar inverter brand") + model: str = Field(..., description="Solar inverter model") + siteName: str = Field( + ..., + description="Name of the site, as set by the user on the device/vendor. If no user-specified name is available, we construct a fallback name using the vendor/device/model names.", + ) + installationDate: str = Field(..., description="Solar inverter installation date") + + +class InverterLocation(BaseModel): + """ "Longitude and Latitude of inverter""" + + longitude: float = Field(..., description="Longitude in degrees") + latitude: float = Field(..., description="Latitude in degrees") + + +class InverterValues(BaseModel): + """Inverter Data for a site""" + + id: str = Field(..., description="Solar Inverter ID") + vendor: str = Field( + ..., description="One of EMA ENPHASE FRONIUS GOODWE GROWATT HUAWEI SMA SOLAREDGE SOLIS" + ) + chargingLocationId: str = Field( + ..., + description="ID of the charging location the solar inverter is currently positioned at (if any).", + ) + lastSeen: str = Field( + ..., description="The last time the solar inverter was successfully communicated with" + ) + isReachable: str = Field( + ..., + description="Whether live data from the solar inverter is currently reachable from Enode's perspective. This 'reachability' may refer to reading from a cache operated by the solar inverter's cloud service if that service has determined that its cache is valid.", + ) + productionState: InverterProductionState = Field( + ..., description="Descriptive information about the production state" + ) + information: InverterInformation = Field( + ..., description="Descriptive information about the solar inverter" + ) + location: InverterLocation = Field(..., description="Solar inverter's GPS coordinates") + + +class Inverters(BaseModel): + """Return all Inverter Data""" + + inverters: List[InverterValues] = Field(..., description="List of inverter data") From cd99e2e7709cc12f663691afb54bb8d10e7c0738 Mon Sep 17 00:00:00 2001 From: neha-vard Date: Thu, 13 Apr 2023 18:42:06 -0500 Subject: [PATCH 02/22] Test --- pv_site_api/main.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pv_site_api/main.py b/pv_site_api/main.py index a805200..ee0179a 100644 --- a/pv_site_api/main.py +++ b/pv_site_api/main.py @@ -41,6 +41,7 @@ PVSiteAPIStatus, PVSiteMetadata, PVSites, + Inverters, ) from .redoc_theme import get_redoc_html_with_theme from .session import get_session @@ -417,6 +418,24 @@ def get_pv_estimate_clearsky_many_sites( return res +# @app.get("/inverters", response_model=Inverters) +# def get_inverters( +# site_uuids: str, +# session: Session = Depends(get_session), +# ): +# """ +# ### Get the actual power generation for a list of sites. +# """ +# site_uuids_list = site_uuids.split(",") + +# if int(os.environ["FAKE"]): +# return [make_fake_pv_generation(site_uuid) for site_uuid in site_uuids_list] + +# start_utc = get_yesterday_midnight() + +# return get_generation_by_sites(session, site_uuids=site_uuids_list, start_utc=start_utc) + + # get_status: get the status of the system @app.get("/api_status", response_model=PVSiteAPIStatus) def get_status(session: Session = Depends(get_session)): From db19834ab4f8c8d730128aafe6c6fa8ddfb52d2e Mon Sep 17 00:00:00 2001 From: anyaparekh Date: Mon, 17 Apr 2023 17:24:41 -0500 Subject: [PATCH 03/22] Add fake inverter test Fix lint errors Finish both endpoints Fix formatting and lint errors Update pv_site_api/main.py Co-authored-by: Andrew Lester Update pv_site_api/main.py Co-authored-by: Andrew Lester Update pv_site_api/main.py Co-authored-by: Andrew Lester Address PR comments Update pyproject.toml Fix lint errors Fix formatting Fix imports Fix bugs Remove unused import Fix formatting Fix import order Create enode_auth.py Define basic auth function Get api key from env Finish part 1 --- poetry.lock | 121 +++++++++++++++++---------------- pv_site_api/_db_helpers.py | 8 ++- pv_site_api/enode_auth.py | 27 ++++++++ pv_site_api/fake.py | 34 +++++++++ pv_site_api/main.py | 57 ++++++++++++---- pv_site_api/pydantic_models.py | 2 +- pv_site_api/utils.py | 26 +++++++ pyproject.toml | 5 +- tests/conftest.py | 23 +++++++ tests/test_inverters.py | 46 +++++++++++++ 10 files changed, 270 insertions(+), 79 deletions(-) create mode 100644 pv_site_api/enode_auth.py create mode 100644 tests/test_inverters.py diff --git a/poetry.lock b/poetry.lock index ad65443..5eebb3b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -649,14 +649,14 @@ numpy = ">=1.14.5" [[package]] name = "httpcore" -version = "0.16.3" +version = "0.17.0" description = "A minimal low-level HTTP client." category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "httpcore-0.16.3-py3-none-any.whl", hash = "sha256:da1fb708784a938aa084bde4feb8317056c55037247c787bd7e19eb2c2949dc0"}, - {file = "httpcore-0.16.3.tar.gz", hash = "sha256:c5d6f04e2fc530f39e0c077e6a30caa53f1451096120f1f38b954afd0b17c0cb"}, + {file = "httpcore-0.17.0-py3-none-any.whl", hash = "sha256:0fdfea45e94f0c9fd96eab9286077f9ff788dd186635ae61b312693e4d943599"}, + {file = "httpcore-0.17.0.tar.gz", hash = "sha256:cc045a3241afbf60ce056202301b4d8b6af08845e3294055eb26b09913ef903c"}, ] [package.dependencies] @@ -725,25 +725,25 @@ test = ["Cython (>=0.29.24,<0.30.0)"] [[package]] name = "httpx" -version = "0.23.3" +version = "0.24.0" description = "The next generation HTTP client." category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "httpx-0.23.3-py3-none-any.whl", hash = "sha256:a211fcce9b1254ea24f0cd6af9869b3d29aba40154e947d2a07bb499b3e310d6"}, - {file = "httpx-0.23.3.tar.gz", hash = "sha256:9818458eb565bb54898ccb9b8b251a28785dd4a55afbc23d0eb410754fe7d0f9"}, + {file = "httpx-0.24.0-py3-none-any.whl", hash = "sha256:447556b50c1921c351ea54b4fe79d91b724ed2b027462ab9a329465d147d5a4e"}, + {file = "httpx-0.24.0.tar.gz", hash = "sha256:507d676fc3e26110d41df7d35ebd8b3b8585052450f4097401c9be59d928c63e"}, ] [package.dependencies] certifi = "*" -httpcore = ">=0.15.0,<0.17.0" -rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]} +httpcore = ">=0.15.0,<0.18.0" +idna = "*" sniffio = "*" [package.extras] brotli = ["brotli", "brotlicffi"] -cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<13)"] +cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (>=1.0.0,<2.0.0)"] @@ -877,40 +877,40 @@ files = [ [[package]] name = "numpy" -version = "1.24.2" +version = "1.24.3" description = "Fundamental package for array computing in Python" category = "main" optional = false python-versions = ">=3.8" files = [ - {file = "numpy-1.24.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eef70b4fc1e872ebddc38cddacc87c19a3709c0e3e5d20bf3954c147b1dd941d"}, - {file = "numpy-1.24.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e8d2859428712785e8a8b7d2b3ef0a1d1565892367b32f915c4a4df44d0e64f5"}, - {file = "numpy-1.24.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6524630f71631be2dabe0c541e7675db82651eb998496bbe16bc4f77f0772253"}, - {file = "numpy-1.24.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a51725a815a6188c662fb66fb32077709a9ca38053f0274640293a14fdd22978"}, - {file = "numpy-1.24.2-cp310-cp310-win32.whl", hash = "sha256:2620e8592136e073bd12ee4536149380695fbe9ebeae845b81237f986479ffc9"}, - {file = "numpy-1.24.2-cp310-cp310-win_amd64.whl", hash = "sha256:97cf27e51fa078078c649a51d7ade3c92d9e709ba2bfb97493007103c741f1d0"}, - {file = "numpy-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7de8fdde0003f4294655aa5d5f0a89c26b9f22c0a58790c38fae1ed392d44a5a"}, - {file = "numpy-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4173bde9fa2a005c2c6e2ea8ac1618e2ed2c1c6ec8a7657237854d42094123a0"}, - {file = "numpy-1.24.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4cecaed30dc14123020f77b03601559fff3e6cd0c048f8b5289f4eeabb0eb281"}, - {file = "numpy-1.24.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a23f8440561a633204a67fb44617ce2a299beecf3295f0d13c495518908e910"}, - {file = "numpy-1.24.2-cp311-cp311-win32.whl", hash = "sha256:e428c4fbfa085f947b536706a2fc349245d7baa8334f0c5723c56a10595f9b95"}, - {file = "numpy-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:557d42778a6869c2162deb40ad82612645e21d79e11c1dc62c6e82a2220ffb04"}, - {file = "numpy-1.24.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d0a2db9d20117bf523dde15858398e7c0858aadca7c0f088ac0d6edd360e9ad2"}, - {file = "numpy-1.24.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c72a6b2f4af1adfe193f7beb91ddf708ff867a3f977ef2ec53c0ffb8283ab9f5"}, - {file = "numpy-1.24.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c29e6bd0ec49a44d7690ecb623a8eac5ab8a923bce0bea6293953992edf3a76a"}, - {file = "numpy-1.24.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2eabd64ddb96a1239791da78fa5f4e1693ae2dadc82a76bc76a14cbb2b966e96"}, - {file = "numpy-1.24.2-cp38-cp38-win32.whl", hash = "sha256:e3ab5d32784e843fc0dd3ab6dcafc67ef806e6b6828dc6af2f689be0eb4d781d"}, - {file = "numpy-1.24.2-cp38-cp38-win_amd64.whl", hash = "sha256:76807b4063f0002c8532cfeac47a3068a69561e9c8715efdad3c642eb27c0756"}, - {file = "numpy-1.24.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4199e7cfc307a778f72d293372736223e39ec9ac096ff0a2e64853b866a8e18a"}, - {file = "numpy-1.24.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:adbdce121896fd3a17a77ab0b0b5eedf05a9834a18699db6829a64e1dfccca7f"}, - {file = "numpy-1.24.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:889b2cc88b837d86eda1b17008ebeb679d82875022200c6e8e4ce6cf549b7acb"}, - {file = "numpy-1.24.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f64bb98ac59b3ea3bf74b02f13836eb2e24e48e0ab0145bbda646295769bd780"}, - {file = "numpy-1.24.2-cp39-cp39-win32.whl", hash = "sha256:63e45511ee4d9d976637d11e6c9864eae50e12dc9598f531c035265991910468"}, - {file = "numpy-1.24.2-cp39-cp39-win_amd64.whl", hash = "sha256:a77d3e1163a7770164404607b7ba3967fb49b24782a6ef85d9b5f54126cc39e5"}, - {file = "numpy-1.24.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:92011118955724465fb6853def593cf397b4a1367495e0b59a7e69d40c4eb71d"}, - {file = "numpy-1.24.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9006288bcf4895917d02583cf3411f98631275bc67cce355a7f39f8c14338fa"}, - {file = "numpy-1.24.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:150947adbdfeceec4e5926d956a06865c1c690f2fd902efede4ca6fe2e657c3f"}, - {file = "numpy-1.24.2.tar.gz", hash = "sha256:003a9f530e880cb2cd177cba1af7220b9aa42def9c4afc2a2fc3ee6be7eb2b22"}, + {file = "numpy-1.24.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3c1104d3c036fb81ab923f507536daedc718d0ad5a8707c6061cdfd6d184e570"}, + {file = "numpy-1.24.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:202de8f38fc4a45a3eea4b63e2f376e5f2dc64ef0fa692838e31a808520efaf7"}, + {file = "numpy-1.24.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8535303847b89aa6b0f00aa1dc62867b5a32923e4d1681a35b5eef2d9591a463"}, + {file = "numpy-1.24.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d926b52ba1367f9acb76b0df6ed21f0b16a1ad87c6720a1121674e5cf63e2b6"}, + {file = "numpy-1.24.3-cp310-cp310-win32.whl", hash = "sha256:f21c442fdd2805e91799fbe044a7b999b8571bb0ab0f7850d0cb9641a687092b"}, + {file = "numpy-1.24.3-cp310-cp310-win_amd64.whl", hash = "sha256:ab5f23af8c16022663a652d3b25dcdc272ac3f83c3af4c02eb8b824e6b3ab9d7"}, + {file = "numpy-1.24.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9a7721ec204d3a237225db3e194c25268faf92e19338a35f3a224469cb6039a3"}, + {file = "numpy-1.24.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d6cc757de514c00b24ae8cf5c876af2a7c3df189028d68c0cb4eaa9cd5afc2bf"}, + {file = "numpy-1.24.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76e3f4e85fc5d4fd311f6e9b794d0c00e7002ec122be271f2019d63376f1d385"}, + {file = "numpy-1.24.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1d3c026f57ceaad42f8231305d4653d5f05dc6332a730ae5c0bea3513de0950"}, + {file = "numpy-1.24.3-cp311-cp311-win32.whl", hash = "sha256:c91c4afd8abc3908e00a44b2672718905b8611503f7ff87390cc0ac3423fb096"}, + {file = "numpy-1.24.3-cp311-cp311-win_amd64.whl", hash = "sha256:5342cf6aad47943286afa6f1609cad9b4266a05e7f2ec408e2cf7aea7ff69d80"}, + {file = "numpy-1.24.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7776ea65423ca6a15255ba1872d82d207bd1e09f6d0894ee4a64678dd2204078"}, + {file = "numpy-1.24.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ae8d0be48d1b6ed82588934aaaa179875e7dc4f3d84da18d7eae6eb3f06c242c"}, + {file = "numpy-1.24.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecde0f8adef7dfdec993fd54b0f78183051b6580f606111a6d789cd14c61ea0c"}, + {file = "numpy-1.24.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4749e053a29364d3452c034827102ee100986903263e89884922ef01a0a6fd2f"}, + {file = "numpy-1.24.3-cp38-cp38-win32.whl", hash = "sha256:d933fabd8f6a319e8530d0de4fcc2e6a61917e0b0c271fded460032db42a0fe4"}, + {file = "numpy-1.24.3-cp38-cp38-win_amd64.whl", hash = "sha256:56e48aec79ae238f6e4395886b5eaed058abb7231fb3361ddd7bfdf4eed54289"}, + {file = "numpy-1.24.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4719d5aefb5189f50887773699eaf94e7d1e02bf36c1a9d353d9f46703758ca4"}, + {file = "numpy-1.24.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ec87a7084caa559c36e0a2309e4ecb1baa03b687201d0a847c8b0ed476a7187"}, + {file = "numpy-1.24.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea8282b9bcfe2b5e7d491d0bf7f3e2da29700cec05b49e64d6246923329f2b02"}, + {file = "numpy-1.24.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:210461d87fb02a84ef243cac5e814aad2b7f4be953b32cb53327bb49fd77fbb4"}, + {file = "numpy-1.24.3-cp39-cp39-win32.whl", hash = "sha256:784c6da1a07818491b0ffd63c6bbe5a33deaa0e25a20e1b3ea20cf0e43f8046c"}, + {file = "numpy-1.24.3-cp39-cp39-win_amd64.whl", hash = "sha256:d5036197ecae68d7f491fcdb4df90082b0d4960ca6599ba2659957aafced7c17"}, + {file = "numpy-1.24.3-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:352ee00c7f8387b44d19f4cada524586f07379c0d49270f87233983bc5087ca0"}, + {file = "numpy-1.24.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a7d6acc2e7524c9955e5c903160aa4ea083736fde7e91276b0e5d98e6332812"}, + {file = "numpy-1.24.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:35400e6a8d102fd07c71ed7dcadd9eb62ee9a6e84ec159bd48c28235bbb0f8e4"}, + {file = "numpy-1.24.3.tar.gz", hash = "sha256:ab344f1bf21f140adab8e47fdbc7c35a477dc01408791f8ba00d018dd0bc5155"}, ] [[package]] @@ -1202,14 +1202,14 @@ test = ["pytest", "pytest-cov", "pytest-mock", "pytest-remotedata", "pytest-reru [[package]] name = "pvsite-datamodel" -version = "0.1.32" +version = "0.1.33" description = "SDK for interacting with the PVSite database" category = "main" optional = false python-versions = ">=3.10,<4.0" files = [ - {file = "pvsite_datamodel-0.1.32-py3-none-any.whl", hash = "sha256:4d10e0151f5f00cb0cc5e9ef1069a61ef7be6922a2513e84c9cc8922ece43e7c"}, - {file = "pvsite_datamodel-0.1.32.tar.gz", hash = "sha256:fe138a2e89aa13326774a09ee2f14c232b8cacfc8baef244d14bb2c97d739c4e"}, + {file = "pvsite_datamodel-0.1.33-py3-none-any.whl", hash = "sha256:f24968bef98fe6702df264826f855e517fab7e1fd818aa5cbd00e19a9c645862"}, + {file = "pvsite_datamodel-0.1.33.tar.gz", hash = "sha256:fd78605f8fa3f9d6b3082db678d12dc4cdadf5b1a6ac195b56d73364de68af88"}, ] [package.dependencies] @@ -1360,6 +1360,25 @@ pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] +[[package]] +name = "pytest-httpx" +version = "0.22.0" +description = "Send responses to httpx." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest_httpx-0.22.0-py3-none-any.whl", hash = "sha256:cefb7dcf66a4cb0601b0de05e576cca423b6081f3245e7912a4d84c58fa3eae8"}, + {file = "pytest_httpx-0.22.0.tar.gz", hash = "sha256:3a82797f3a9a14d51e8c6b7fa97524b68b847ee801109c062e696b4744f4431c"}, +] + +[package.dependencies] +httpx = ">=0.24.0,<0.25.0" +pytest = ">=6.0,<8.0" + +[package.extras] +testing = ["pytest-asyncio (>=0.20.0,<0.21.0)", "pytest-cov (>=4.0.0,<5.0.0)"] + [[package]] name = "python-dateutil" version = "2.8.2" @@ -1498,24 +1517,6 @@ urllib3 = ">=1.21.1,<1.27" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] -[[package]] -name = "rfc3986" -version = "1.5.0" -description = "Validating URI References per RFC 3986" -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"}, - {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"}, -] - -[package.dependencies] -idna = {version = "*", optional = true, markers = "extra == \"idna2008\""} - -[package.extras] -idna2008 = ["idna"] - [[package]] name = "ruff" version = "0.0.253" @@ -2170,4 +2171,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "cf7caf9a42ce9529284ee8678784cd85f4352af179ef20ad54c13edd12c33113" +content-hash = "1e936fc43ca55ba8ec566e713b82c9002b7d71516d1965e548453d0cb3387e3c" diff --git a/pv_site_api/_db_helpers.py b/pv_site_api/_db_helpers.py index c1ce66f..cf1a983 100644 --- a/pv_site_api/_db_helpers.py +++ b/pv_site_api/_db_helpers.py @@ -14,7 +14,7 @@ import sqlalchemy as sa import structlog from pvsite_datamodel.read.generation import get_pv_generation_by_sites -from pvsite_datamodel.sqlmodels import ForecastSQL, ForecastValueSQL, SiteSQL +from pvsite_datamodel.sqlmodels import ForecastSQL, ForecastValueSQL, InverterSQL, SiteSQL from sqlalchemy.orm import Session, aliased from .pydantic_models import ( @@ -60,6 +60,12 @@ def _get_forecasts_for_horizon( return list(session.execute(stmt)) +def _get_inverters_by_site(session: Session, site_uuid: str) -> list[Row]: + query = session.query(InverterSQL).filter(InverterSQL.site_uuid.is_(site_uuid)) + + return query.all() + + def _get_latest_forecast_by_sites( session: Session, site_uuids: list[str], start_utc: Optional[dt.datetime] = None ) -> list[Row]: diff --git a/pv_site_api/enode_auth.py b/pv_site_api/enode_auth.py new file mode 100644 index 0000000..303fd15 --- /dev/null +++ b/pv_site_api/enode_auth.py @@ -0,0 +1,27 @@ +import os +import httpx + +def get_enode_access_token() -> str: + # Replace these with your actual credentials + your_client_id = os.getenv("CLIENT_ID") + your_client_secret = os.getenv("CLIENT_SECRET") + + url = "https://oauth.sandbox.enode.io/oauth2/token" + + # Encode the client_id and client_secret using Basic Auth + auth = httpx.BasicAuth(username=your_client_id, password=your_client_secret) + + data = { + "grant_type": "client_credentials" + } + + response = httpx.post(url, auth=auth, data=data) + + # Check if the request was successful + if response.status_code == 200: + print("Access token:", response.json()["access_token"]) + else: + print("Error:", response.status_code, response.text) + + return response.json()["access_token"] + diff --git a/pv_site_api/fake.py b/pv_site_api/fake.py index e5511f1..4d26b98 100644 --- a/pv_site_api/fake.py +++ b/pv_site_api/fake.py @@ -6,6 +6,11 @@ from .pydantic_models import ( Forecast, + InverterInformation, + InverterLocation, + InverterProductionState, + Inverters, + InverterValues, MultiplePVActual, PVActualValue, PVSiteAPIStatus, @@ -19,6 +24,35 @@ fake_client_uuid = "c97f68cd-50e0-49bb-a850-108d4a9f7b7e" +def make_fake_inverters() -> Inverters: + """Make fake inverters""" + inverter = InverterValues( + id="string", + vendor="EMA", + chargingLocationId="8d90101b-3f2f-462a-bbb4-1ed320d33bbe", + lastSeen="2020-04-07T17:04:26Z", + isReachable=True, + productionState=InverterProductionState( + productionRate=0, + isProducing=True, + totalLifetimeProduction=100152.56, + lastUpdated="2020-04-07T17:04:26Z", + ), + information=InverterInformation( + id="string", + brand="EMA", + model="Sunny Boy", + siteName="Sunny Plant", + installationDate="2020-04-07T17:04:26Z", + ), + location=InverterLocation(latitude=10.7197486, longitude=59.9173985), + ) + inverters_list = Inverters( + inverters=[inverter], + ) + return inverters_list + + def make_fake_site() -> PVSites: """Make a fake site""" pv_site = PVSiteMetadata( diff --git a/pv_site_api/main.py b/pv_site_api/main.py index ee0179a..6d02c45 100644 --- a/pv_site_api/main.py +++ b/pv_site_api/main.py @@ -1,6 +1,9 @@ """Main API Routes""" +import asyncio +import logging import os +import httpx import pandas as pd import sentry_sdk import structlog @@ -19,6 +22,7 @@ import pv_site_api from ._db_helpers import ( + _get_inverters_by_site, does_site_exist, get_forecasts_by_sites, get_generation_by_sites, @@ -30,6 +34,7 @@ from .fake import ( fake_site_uuid, make_fake_forecast, + make_fake_inverters, make_fake_pv_generation, make_fake_site, make_fake_status, @@ -41,11 +46,10 @@ PVSiteAPIStatus, PVSiteMetadata, PVSites, - Inverters, ) from .redoc_theme import get_redoc_html_with_theme from .session import get_session -from .utils import get_yesterday_midnight +from .utils import get_inverters_list, get_yesterday_midnight load_dotenv() @@ -418,22 +422,45 @@ def get_pv_estimate_clearsky_many_sites( return res -# @app.get("/inverters", response_model=Inverters) -# def get_inverters( -# site_uuids: str, -# session: Session = Depends(get_session), -# ): -# """ -# ### Get the actual power generation for a list of sites. -# """ -# site_uuids_list = site_uuids.split(",") +# @app.get("/enode_token") +# def get_enode_token(session: Session = Depends(get_session)): +# token = get_enode_access_token() +# return {"token": token} + + +@app.get("/inverters") +async def get_inverters( + session: Session = Depends(get_session), +): + if int(os.environ["FAKE"]): + return make_fake_inverters() -# if int(os.environ["FAKE"]): -# return [make_fake_pv_generation(site_uuid) for site_uuid in site_uuids_list] + client = session.query(ClientSQL).first() + assert client is not None + + async with httpx.AsyncClient() as httpxClient: + headers = {"Enode-User-Id": str(client.client_uuid)} + r = ( + await httpxClient.get( + "https://enode-api.production.enode.io/inverters", headers=headers + ) + ).json() + inverter_ids = [str(value) for value in r] + + return await get_inverters_list(session, inverter_ids) + + +@app.get("/sites/{site_uuid}/inverters") +async def get_inverters_by_site( + site_uuid: str, + session: Session = Depends(get_session), +): + if int(os.environ["FAKE"]): + return make_fake_inverters() -# start_utc = get_yesterday_midnight() + inverter_ids = [inverter.inverter_uuid for inverter in _get_inverters_by_site(site_uuid)] -# return get_generation_by_sites(session, site_uuids=site_uuids_list, start_utc=start_utc) + return await get_inverters_list(session, inverter_ids) # get_status: get the status of the system diff --git a/pv_site_api/pydantic_models.py b/pv_site_api/pydantic_models.py index 26c9a94..30e6c62 100644 --- a/pv_site_api/pydantic_models.py +++ b/pv_site_api/pydantic_models.py @@ -158,7 +158,7 @@ class InverterValues(BaseModel): lastSeen: str = Field( ..., description="The last time the solar inverter was successfully communicated with" ) - isReachable: str = Field( + isReachable: bool = Field( ..., description="Whether live data from the solar inverter is currently reachable from Enode's perspective. This 'reachability' may refer to reading from a cache operated by the solar inverter's cloud service if that service has determined that its cache is valid.", ) diff --git a/pv_site_api/utils.py b/pv_site_api/utils.py index 149d46c..9730c25 100644 --- a/pv_site_api/utils.py +++ b/pv_site_api/utils.py @@ -1,11 +1,37 @@ """ make fake intensity""" +import asyncio import math from datetime import datetime, timedelta, timezone from typing import List +import httpx +from pvsite_datamodel.sqlmodels import ClientSQL + +from .pydantic_models import Inverters, InverterValues + TOTAL_MINUTES_IN_ONE_DAY = 24 * 60 +async def get_inverters_list(session, inverter_ids): + client = session.query(ClientSQL).first() + assert client is not None + + async with httpx.AsyncClient() as httpxClient: + headers = {"Enode-User-Id": str(client.client_uuid)} + inverters_raw = await asyncio.gather( + *[ + httpxClient.get( + f"https://enode-api.production.enode.io/inverters/{id}", headers=headers + ) + for id in inverter_ids + ] + ) + + inverters = [InverterValues(**inverter_raw.json()) for inverter_raw in inverters_raw] + + return Inverters(inverters=inverters) + + def make_fake_intensity(datetime_utc: datetime) -> float: """ Make a fake intesnity value based on the time of the day diff --git a/pyproject.toml b/pyproject.toml index 6a3577a..0434375 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,9 +12,9 @@ pydantic = "^1.10.5" uvicorn = {extras = ["standard"], version = "^0.20.0"} psycopg2-binary = "^2.9.5" sqlalchemy = "^1.4.46" -pvsite-datamodel = "^0.1.30" +pvsite-datamodel = "^0.1.33" fastapi = "^0.92.0" -httpx = "^0.23.3" +httpx = "^0.24.0" sentry-sdk = "^1.16.0" pvlib = "^0.9.5" structlog = "^22.3.0" @@ -29,6 +29,7 @@ pytest-cov = "^4.0.0" testcontainers-postgres = "^0.0.1rc1" ipython = "^8.11.0" freezegun = "^1.2.2" +pytest-httpx = "^0.22.0" [build-system] requires = ["poetry-core"] diff --git a/tests/conftest.py b/tests/conftest.py index 8c05f04..ff115aa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,8 +11,10 @@ ForecastSQL, ForecastValueSQL, GenerationSQL, + InverterSQL, SiteSQL, StatusSQL, + InverterSQL, ) from sqlalchemy import create_engine from sqlalchemy.orm import Session @@ -29,6 +31,11 @@ def _now(autouse=True): return datetime.utcnow() +@pytest.fixture +def non_mocked_hosts() -> list: + return ["testserver"] + + @pytest.fixture(scope="session") def engine(): """Make database engine""" @@ -98,6 +105,22 @@ def sites(db_session, clients): return sites +@pytest.fixture() +def inverters(db_session, sites): + """Create some fake inverters""" + inverters = [] + num_inverters = 3 + for site in sites: + for j in range(num_inverters): + inverter = InverterSQL(site_uuid=site.site_uuid, client_id="test") + inverters.append(inverter) + + db_session.add_all(inverters) + db_session.commit() + + return inverters + + @pytest.fixture() def generations(db_session, sites): """Create some fake generations""" diff --git a/tests/test_inverters.py b/tests/test_inverters.py new file mode 100644 index 0000000..f6b2f09 --- /dev/null +++ b/tests/test_inverters.py @@ -0,0 +1,46 @@ +""" Test for main app """ + +from pv_site_api.pydantic_models import Inverters + + +def test_get_inverters_fake(client, fake): + response = client.get("/inverters") + assert response.status_code == 200 + + inverters = Inverters(**response.json()) + assert len(inverters.inverters) > 0 + + +def test_get_inverters(client, httpx_mock, clients): + httpx_mock.add_response(url="https://enode-api.production.enode.io/inverters", json=["id1"]) + + httpx_mock.add_response( + url="https://enode-api.production.enode.io/inverters/id1", + json={ + "id": "string", + "vendor": "EMA", + "chargingLocationId": "8d90101b-3f2f-462a-bbb4-1ed320d33bbe", + "lastSeen": "2020-04-07T17:04:26Z", + "isReachable": True, + "productionState": { + "productionRate": 0, + "isProducing": True, + "totalLifetimeProduction": 100152.56, + "lastUpdated": "2020-04-07T17:04:26Z", + }, + "information": { + "id": "string", + "brand": "EMA", + "model": "Sunny Boy", + "siteName": "Sunny Plant", + "installationDate": "2020-04-07T17:04:26Z", + }, + "location": {"longitude": 10.7197486, "latitude": 59.9173985}, + }, + ) + + response = client.get("/inverters") + assert response.status_code == 200 + + inverters = Inverters(**response.json()) + assert len(inverters.inverters) > 0 From 0eddcde285744ea813fb7be380277555872d2f92 Mon Sep 17 00:00:00 2001 From: Jacky Park Date: Thu, 13 Apr 2023 19:05:23 -0500 Subject: [PATCH 04/22] Create httpx auth class Remove test end point clean up Remove print messages Make getenv set variable to str make format lint and format Remove unecessary check Remove test files Use env var for enode token url Change get_enode_access_token to return request Refactor get_enode_access_token() into EnodeAuth class change 404 to 401 format Remove test code Remove print Address PR changes Update enode constructor to take in client_id, secret, and url Add type hints Add inverters test for site specific Fix lint errors Modularize code Fix lint errors --- pv_site_api/_db_helpers.py | 2 +- pv_site_api/enode_auth.py | 66 ++++++++++++++++++++-------------- pv_site_api/main.py | 15 ++++++-- pv_site_api/pydantic_models.py | 16 +++++---- pv_site_api/utils.py | 5 ++- tests/conftest.py | 9 +++-- tests/test_inverters.py | 39 +++++++++++++------- 7 files changed, 95 insertions(+), 57 deletions(-) diff --git a/pv_site_api/_db_helpers.py b/pv_site_api/_db_helpers.py index cf1a983..fd2a5b9 100644 --- a/pv_site_api/_db_helpers.py +++ b/pv_site_api/_db_helpers.py @@ -61,7 +61,7 @@ def _get_forecasts_for_horizon( def _get_inverters_by_site(session: Session, site_uuid: str) -> list[Row]: - query = session.query(InverterSQL).filter(InverterSQL.site_uuid.is_(site_uuid)) + query = session.query(InverterSQL).filter(InverterSQL.site_uuid == site_uuid) return query.all() diff --git a/pv_site_api/enode_auth.py b/pv_site_api/enode_auth.py index 303fd15..00f6bb4 100644 --- a/pv_site_api/enode_auth.py +++ b/pv_site_api/enode_auth.py @@ -1,27 +1,39 @@ -import os -import httpx - -def get_enode_access_token() -> str: - # Replace these with your actual credentials - your_client_id = os.getenv("CLIENT_ID") - your_client_secret = os.getenv("CLIENT_SECRET") - - url = "https://oauth.sandbox.enode.io/oauth2/token" - - # Encode the client_id and client_secret using Basic Auth - auth = httpx.BasicAuth(username=your_client_id, password=your_client_secret) - - data = { - "grant_type": "client_credentials" - } - - response = httpx.post(url, auth=auth, data=data) - - # Check if the request was successful - if response.status_code == 200: - print("Access token:", response.json()["access_token"]) - else: - print("Error:", response.status_code, response.text) - - return response.json()["access_token"] - +from typing import Optional + +import httpx + + +class EnodeAuth(httpx.Auth): + def __init__( + self, client_id: str, client_secret: str, token_url: str, access_token: Optional[str] = None + ): + self._client_id = client_id + self._client_secret = client_secret + self._token_url = token_url + self._access_token = access_token + + def auth_flow(self, request: httpx.Request): + # Add the Authorization header to the request using the current access token + request.headers["Authorization"] = f"Bearer {self.access_token}" + response = yield request + + if response.status_code == 401: + # The access token is no longer valid, refresh it + token_response = yield self._build_refresh_request() + self._update_access_token(token_response) + # Update the request's Authorization header with the new access token + request.headers["Authorization"] = f"Bearer {self.access_token}" + # Resend the request with the new access token + response = yield request + return response + + def _build_refresh_request(self): + basic_auth = httpx.BasicAuth(self._client_id, self._client_secret) + + data = {"grant_type": "client_credentials"} + request = next(basic_auth.auth_flow(httpx.Request("POST", self._token_url, data=data))) + return request + + def _update_access_token(self, response): + response.read() + self.access_token = response.json()["access_token"] diff --git a/pv_site_api/main.py b/pv_site_api/main.py index 6d02c45..f10cf4f 100644 --- a/pv_site_api/main.py +++ b/pv_site_api/main.py @@ -3,10 +3,10 @@ import logging import os -import httpx import pandas as pd import sentry_sdk import structlog +import httpx from dotenv import load_dotenv from fastapi import Depends, FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware @@ -31,6 +31,7 @@ ) from .auth import Auth from .cache import cache_response +from .enode_auth import EnodeAuth from .fake import ( fake_site_uuid, make_fake_forecast, @@ -50,6 +51,9 @@ from .redoc_theme import get_redoc_html_with_theme from .session import get_session from .utils import get_inverters_list, get_yesterday_midnight +from .enode_auth import ( + EnodeAuth, +) load_dotenv() @@ -111,6 +115,8 @@ def is_fake(): api_audience=os.getenv("AUTH0_API_AUDIENCE"), algorithm=os.getenv("AUTH0_ALGORITHM"), ) +auth = EnodeAuth() +enode_client = httpx.Client(auth=auth) # name the api # test that the routes are there on swagger @@ -422,6 +428,11 @@ def get_pv_estimate_clearsky_many_sites( return res +@app.get("/test_enode") +def test_enode(): + enode_client.get("https://enode-api.production.enode.io/random") + + # @app.get("/enode_token") # def get_enode_token(session: Session = Depends(get_session)): # token = get_enode_access_token() @@ -458,7 +469,7 @@ async def get_inverters_by_site( if int(os.environ["FAKE"]): return make_fake_inverters() - inverter_ids = [inverter.inverter_uuid for inverter in _get_inverters_by_site(site_uuid)] + inverter_ids = [inverter.client_id for inverter in _get_inverters_by_site(session, site_uuid)] return await get_inverters_list(session, inverter_ids) diff --git a/pv_site_api/pydantic_models.py b/pv_site_api/pydantic_models.py index 30e6c62..8681b8e 100644 --- a/pv_site_api/pydantic_models.py +++ b/pv_site_api/pydantic_models.py @@ -114,12 +114,14 @@ class ClearskyEstimate(BaseModel): class InverterProductionState(BaseModel): """Production State data for an inverter""" - productionRate: float = Field(..., description="The current production rate in kW") - isProducing: bool = Field( + productionRate: Optional[float] = Field(..., description="The current production rate in kW") + isProducing: Optional[bool] = Field( ..., description="Whether the solar inverter is actively producing energy or not" ) - totalLifetimeProduction: float = Field(..., description="The total lifetime production in kWh") - lastUpdated: str = Field( + totalLifetimeProduction: Optional[float] = Field( + ..., description="The total lifetime production in kWh" + ) + lastUpdated: Optional[str] = Field( ..., description="ISO8601 UTC timestamp of last received production state update" ) @@ -140,8 +142,8 @@ class InverterInformation(BaseModel): class InverterLocation(BaseModel): """ "Longitude and Latitude of inverter""" - longitude: float = Field(..., description="Longitude in degrees") - latitude: float = Field(..., description="Latitude in degrees") + longitude: Optional[float] = Field(..., description="Longitude in degrees") + latitude: Optional[float] = Field(..., description="Latitude in degrees") class InverterValues(BaseModel): @@ -151,7 +153,7 @@ class InverterValues(BaseModel): vendor: str = Field( ..., description="One of EMA ENPHASE FRONIUS GOODWE GROWATT HUAWEI SMA SOLAREDGE SOLIS" ) - chargingLocationId: str = Field( + chargingLocationId: Optional[str] = Field( ..., description="ID of the charging location the solar inverter is currently positioned at (if any).", ) diff --git a/pv_site_api/utils.py b/pv_site_api/utils.py index 9730c25..5a81766 100644 --- a/pv_site_api/utils.py +++ b/pv_site_api/utils.py @@ -26,15 +26,14 @@ async def get_inverters_list(session, inverter_ids): for id in inverter_ids ] ) - - inverters = [InverterValues(**inverter_raw.json()) for inverter_raw in inverters_raw] + inverters = [InverterValues(**(inverter_raw.json())) for inverter_raw in inverters_raw] return Inverters(inverters=inverters) def make_fake_intensity(datetime_utc: datetime) -> float: """ - Make a fake intesnity value based on the time of the day + Make a fake intensity value based on the time of the day :param datetime_utc: :return: intensity, between 0 and 1 diff --git a/tests/conftest.py b/tests/conftest.py index ff115aa..f6d1581 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -107,13 +107,12 @@ def sites(db_session, clients): @pytest.fixture() def inverters(db_session, sites): - """Create some fake inverters""" + """Create some fake inverters for site 0""" inverters = [] num_inverters = 3 - for site in sites: - for j in range(num_inverters): - inverter = InverterSQL(site_uuid=site.site_uuid, client_id="test") - inverters.append(inverter) + for j in range(num_inverters): + inverter = InverterSQL(site_uuid=sites[0].site_uuid, client_id=f"id{j+1}") + inverters.append(inverter) db_session.add_all(inverters) db_session.commit() diff --git a/tests/test_inverters.py b/tests/test_inverters.py index f6b2f09..a706434 100644 --- a/tests/test_inverters.py +++ b/tests/test_inverters.py @@ -3,19 +3,9 @@ from pv_site_api.pydantic_models import Inverters -def test_get_inverters_fake(client, fake): - response = client.get("/inverters") - assert response.status_code == 200 - - inverters = Inverters(**response.json()) - assert len(inverters.inverters) > 0 - - -def test_get_inverters(client, httpx_mock, clients): - httpx_mock.add_response(url="https://enode-api.production.enode.io/inverters", json=["id1"]) - +def add_response(id, httpx_mock): httpx_mock.add_response( - url="https://enode-api.production.enode.io/inverters/id1", + url=f"https://enode-api.production.enode.io/inverters/{id}", json={ "id": "string", "vendor": "EMA", @@ -39,6 +29,31 @@ def test_get_inverters(client, httpx_mock, clients): }, ) + +def test_get_inverters_from_site(client, sites, inverters, httpx_mock): + add_response("id1", httpx_mock) + add_response("id2", httpx_mock) + add_response("id3", httpx_mock) + + response = client.get(f"/sites/{sites[0].site_uuid}/inverters") + assert response.status_code == 200 + + response_inverters = Inverters(**response.json()) + assert len(inverters) == len(response_inverters.inverters) + + +def test_get_inverters_fake(client, fake): + response = client.get("/inverters") + assert response.status_code == 200 + + response_inverters = Inverters(**response.json()) + assert len(response_inverters.inverters) > 0 + + +def test_get_inverters(client, httpx_mock, clients): + httpx_mock.add_response(url="https://enode-api.production.enode.io/inverters", json=["id1"]) + add_response("id1", httpx_mock) + response = client.get("/inverters") assert response.status_code == 200 From e1dadaf8901082e637bf54ddae0cb57fb537d9ca Mon Sep 17 00:00:00 2001 From: Andrew Lester Date: Sun, 23 Apr 2023 12:24:12 -0500 Subject: [PATCH 05/22] Add enode link URL endpoint (#86) * Add enode link URL endpoint * Fix lint * Rename test var for clarity * Add comment to mock urls fixtuer * Use redirect response --- pv_site_api/fake.py | 5 +++++ pv_site_api/main.py | 26 ++++++++++++++++++-------- tests/conftest.py | 11 ++++++----- tests/test_enode.py | 25 +++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 13 deletions(-) create mode 100644 tests/test_enode.py diff --git a/pv_site_api/fake.py b/pv_site_api/fake.py index 4d26b98..da863e6 100644 --- a/pv_site_api/fake.py +++ b/pv_site_api/fake.py @@ -120,3 +120,8 @@ def make_fake_status() -> PVSiteAPIStatus: message="The API is up and running", ) return pv_api_status + + +def make_fake_enode_link_url() -> str: + """Make fake Enode link URL""" + return "https://enode.com" diff --git a/pv_site_api/main.py b/pv_site_api/main.py index f10cf4f..83b1601 100644 --- a/pv_site_api/main.py +++ b/pv_site_api/main.py @@ -11,7 +11,7 @@ from fastapi import Depends, FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.utils import get_openapi -from fastapi.responses import FileResponse, JSONResponse +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from pvlib import irradiance, location, pvsystem from pvsite_datamodel.read.site import get_all_sites from pvsite_datamodel.read.status import get_latest_status @@ -34,6 +34,7 @@ from .enode_auth import EnodeAuth from .fake import ( fake_site_uuid, + make_fake_enode_link_url, make_fake_forecast, make_fake_inverters, make_fake_pv_generation, @@ -428,15 +429,24 @@ def get_pv_estimate_clearsky_many_sites( return res -@app.get("/test_enode") -def test_enode(): - enode_client.get("https://enode-api.production.enode.io/random") +@app.get("/enode/link", response_class=RedirectResponse) +def get_enode_link(redirect_uri: str, session: Session = Depends(get_session)): + """ + ### Returns a URL from Enode that starts a user's Enode link flow. + """ + if int(os.environ["FAKE"]): + return make_fake_enode_link_url() + client = session.query(ClientSQL).first() + assert client is not None + + with httpx.Client() as httpx_client: + data = {"vendorType": "inverter", "redirectUri": redirect_uri} + res = httpx_client.post( + f"https://enode-api.production.enode.io/users/{client.client_uuid}/link", data=data + ).json() -# @app.get("/enode_token") -# def get_enode_token(session: Session = Depends(get_session)): -# token = get_enode_access_token() -# return {"token": token} + return res["linkUrl"] @app.get("/inverters") diff --git a/tests/conftest.py b/tests/conftest.py index f6d1581..5035078 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,11 +31,6 @@ def _now(autouse=True): return datetime.utcnow() -@pytest.fixture -def non_mocked_hosts() -> list: - return ["testserver"] - - @pytest.fixture(scope="session") def engine(): """Make database engine""" @@ -80,6 +75,12 @@ def clients(db_session): return clients +@pytest.fixture +def non_mocked_hosts() -> list: + """Prevent TestClient fixture from being mocked""" + return ["testserver"] + + @pytest.fixture() def sites(db_session, clients): """Create some fake sites""" diff --git a/tests/test_enode.py b/tests/test_enode.py new file mode 100644 index 0000000..03939df --- /dev/null +++ b/tests/test_enode.py @@ -0,0 +1,25 @@ +def test_get_enode_link_fake(client, fake): + params = {"redirect_uri": "https://example.org"} + response = client.get("/enode/link", params=params, follow_redirects=False) + + assert response.status_code == 307 + assert len(response.headers["location"]) > 0 + + +def test_get_enode_link(client, clients, httpx_mock): + test_enode_link_uri = "https://example.com" + + httpx_mock.add_response( + url=f"https://enode-api.production.enode.io/users/{clients[0].client_uuid}/link", + json={"linkUrl": test_enode_link_uri}, + ) + + params = {"redirect_uri": "https://example.org"} + response = client.get( + "/enode/link", + params=params, + follow_redirects=False, + ) + + assert response.status_code == 307 + assert response.headers["location"] == test_enode_link_uri From 3541f2f778af026718ebf2f52d0977c1a4c5f931 Mon Sep 17 00:00:00 2001 From: Eric Liu <62641231+ericcccsliu@users.noreply.github.com> Date: Sun, 23 Apr 2023 15:10:23 -0500 Subject: [PATCH 06/22] [WIP] Add put endpoint for editing site (#88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bump version: 0.0.36 → 0.0.37 * TDD: add failing test, looking at forecasts in the future * add start utc filter on forecast future qery * lint * lint * isort * add freeze gun to dev dependencies * self PR comments * Bump version: 0.0.37 → 0.0.38 * Make site_uuid field on PVSiteMetadata model optional * Make pydantic field optional * Move test site with real UUID to real db test * Don't use uuid in tests * Add part of check back * Ensure autogenerated site uuid is not null in database * Add WHERE statement on query for past forecasts This doesn't change the result but speeds up the query in general. * Bump version: 0.0.38 → 0.0.39 * Bump version: 0.0.39 → 0.0.40 * add logging * lint * run blacks * add extra logs * Bump version: 0.0.40 → 0.0.41 * add structlogging * add structlog to requiremwnts * add logging * Bump version: 0.0.41 → 0.0.42 * Add structlog initialisation * blacks * isort * Bump version: 0.0.42 → 0.0.43 * Uniformize check to FAKE and fix related test * Set codecov targets * Hard-code the "now" time for all tests This means that the same tests are run now matter the time at which we run them. * add caching * fix * lint * isort * fix for routes calling routes * lint * Bump version: 0.0.43 → 0.0.44 * PR comment * add args to cache * lint * Update pv_site_api/cache.py Co-authored-by: Simon Lemieux <1105380+simlmx@users.noreply.github.com> * tidy * Bump version: 0.0.44 → 0.0.45 * What I've got * Add basic authorization to the /sites* routes The caller of the routes must have a proper Bearer authorization header set. * refactor * add tests * fix error in import * update pvsite-datamodel (in line with main) * fix bug * fix incompatible types * lint and format * add site existence check * add back correct datamodel dependency * fix test name * allow tests to pass by adding fake condition * remove 404 check * use or instead of is not none * Bump version: 0.0.45 → 0.0.46 * add site existence check * format and lint * Bump version: 0.0.46 → 0.0.47 * add post endpoint * add test back in * fix errors and format * add site name to test * fix tiny error * fix another small error * run tests * fix bugs * lint and format * fix fake * :pleading-face: * yet another is_fake() addition * address comments --------- Co-authored-by: Peter Dudfield <34686298+peterdudfield@users.noreply.github.com> Co-authored-by: BumpVersion Action Co-authored-by: peterdudfield Co-authored-by: AndrewLester Co-authored-by: Simon Lemieux <1105380+simlmx@users.noreply.github.com> Co-authored-by: devsjc Fix some functions Fix Enode link endpoint Remove duplicate fixture Rename /inverters to /enode/inverters --- pv_site_api/enode_auth.py | 6 +-- pv_site_api/main.py | 106 ++++++++++++++++++++++++++------------ pv_site_api/utils.py | 23 ++++----- pyproject.toml | 1 + tests/conftest.py | 12 ++--- tests/test_inverters.py | 71 +++++++++++++------------ tests/test_sites.py | 74 +++++++++++++------------- 7 files changed, 168 insertions(+), 125 deletions(-) diff --git a/pv_site_api/enode_auth.py b/pv_site_api/enode_auth.py index 00f6bb4..c647c54 100644 --- a/pv_site_api/enode_auth.py +++ b/pv_site_api/enode_auth.py @@ -14,7 +14,7 @@ def __init__( def auth_flow(self, request: httpx.Request): # Add the Authorization header to the request using the current access token - request.headers["Authorization"] = f"Bearer {self.access_token}" + request.headers["Authorization"] = f"Bearer {self._access_token}" response = yield request if response.status_code == 401: @@ -22,7 +22,7 @@ def auth_flow(self, request: httpx.Request): token_response = yield self._build_refresh_request() self._update_access_token(token_response) # Update the request's Authorization header with the new access token - request.headers["Authorization"] = f"Bearer {self.access_token}" + request.headers["Authorization"] = f"Bearer {self._access_token}" # Resend the request with the new access token response = yield request return response @@ -36,4 +36,4 @@ def _build_refresh_request(self): def _update_access_token(self, response): response.read() - self.access_token = response.json()["access_token"] + self._access_token = response.json()["access_token"] diff --git a/pv_site_api/main.py b/pv_site_api/main.py index 83b1601..57730ab 100644 --- a/pv_site_api/main.py +++ b/pv_site_api/main.py @@ -60,6 +60,14 @@ logger = structlog.stdlib.get_logger() +enode_auth = EnodeAuth( + os.getenv("ENODE_CLIENT_ID", ""), + os.getenv("ENODE_CLIENT_SECRET", ""), + os.getenv("ENODE_TOKEN_URL", ""), +) + +enode_api_base_url = os.getenv("ENODE_API_BASE_URL", "https://enode-api.sandbox.enode.io") + def traces_sampler(sampling_context): """ @@ -117,7 +125,6 @@ def is_fake(): algorithm=os.getenv("AUTH0_ALGORITHM"), ) auth = EnodeAuth() -enode_client = httpx.Client(auth=auth) # name the api # test that the routes are there on swagger @@ -194,19 +201,48 @@ def post_pv_actual( # Comment this out, until we have security on this # # put_site_info: client can update a site -# @app.put("/sites/{site_uuid}") -# def put_site_info(site_info: PVSiteMetadata): -# """ -# ### This route allows a user to update site information for a single site. -# -# """ -# -# if is_fake(): -# print(f"Successfully updated {site_info.dict()} for site {site_info.client_site_name}") -# print("Not doing anything with it (yet!)") -# return -# -# raise Exception(NotImplemented) +@app.put("/sites/{site_uuid}") +def put_site_info( + site_uuid: str, + site_info: PVSiteMetadata, + session: Session = Depends(get_session), + auth: Auth = Depends(auth), +): + """ + ### This route allows a user to update a site's information. + + """ + + if is_fake(): + print(f"Successfully updated site {site_uuid} with {site_info.dict()}") + print("Not doing anything with it (yet!)") + return + + # @TODO: get client corresponding to auth + client = session.query(ClientSQL).first() + assert client is not None + + site = ( + session.query(SiteSQL) + .filter_by(client_uuid=client.client_uuid, site_uuid=site_uuid) + .first() + ) + if site is None: + raise HTTPException(status_code=404, detail="Site not found") + + site.client_site_id = site_info.client_site_id + site.client_site_name = site_info.client_site_name + site.region = site_info.region + site.dno = site_info.dno + site.gsp = site_info.gsp + site.orientation = site_info.orientation + site.tilt = site_info.tilt + site.latitude = site_info.latitude + site.longitude = site_info.longitude + site.capacity_kw = site_info.installed_capacity_kw + + session.commit() + return site @app.post("/sites") @@ -225,7 +261,7 @@ def post_site_info( print("Not doing anything with it (yet!)") return - # client uuid from name + # @TODO: get client corresponding to auth client = session.query(ClientSQL).first() assert client is not None @@ -434,41 +470,37 @@ def get_enode_link(redirect_uri: str, session: Session = Depends(get_session)): """ ### Returns a URL from Enode that starts a user's Enode link flow. """ - if int(os.environ["FAKE"]): + if is_fake(): return make_fake_enode_link_url() client = session.query(ClientSQL).first() assert client is not None - with httpx.Client() as httpx_client: + with httpx.Client(base_url=enode_api_base_url, auth=enode_auth) as httpx_client: data = {"vendorType": "inverter", "redirectUri": redirect_uri} - res = httpx_client.post( - f"https://enode-api.production.enode.io/users/{client.client_uuid}/link", data=data - ).json() + res = httpx_client.post(f"/users/{client.client_uuid}/link", data=data).json() return res["linkUrl"] -@app.get("/inverters") +@app.get("/enode/inverters") async def get_inverters( session: Session = Depends(get_session), ): - if int(os.environ["FAKE"]): + if is_fake(): return make_fake_inverters() client = session.query(ClientSQL).first() assert client is not None - async with httpx.AsyncClient() as httpxClient: + async with httpx.AsyncClient(base_url=enode_api_base_url, auth=enode_auth) as httpx_client: headers = {"Enode-User-Id": str(client.client_uuid)} - r = ( - await httpxClient.get( - "https://enode-api.production.enode.io/inverters", headers=headers - ) - ).json() - inverter_ids = [str(value) for value in r] + response_json = (await httpx_client.get("/inverters", headers=headers)).json() + inverter_ids = [str(inverter_id) for inverter_id in response_json] - return await get_inverters_list(session, inverter_ids) + return await get_inverters_list( + client.client_uuid, inverter_ids, enode_auth, enode_api_base_url + ) @app.get("/sites/{site_uuid}/inverters") @@ -476,12 +508,22 @@ async def get_inverters_by_site( site_uuid: str, session: Session = Depends(get_session), ): - if int(os.environ["FAKE"]): + if is_fake(): return make_fake_inverters() + site_exists = does_site_exist(session, site_uuid) + + if not site_exists: + raise HTTPException(status_code=404) + + client = session.query(ClientSQL).first() + assert client is not None + inverter_ids = [inverter.client_id for inverter in _get_inverters_by_site(session, site_uuid)] - return await get_inverters_list(session, inverter_ids) + return await get_inverters_list( + client.client_uuid, inverter_ids, enode_auth, enode_api_base_url + ) # get_status: get the status of the system diff --git a/pv_site_api/utils.py b/pv_site_api/utils.py index 5a81766..2b6f379 100644 --- a/pv_site_api/utils.py +++ b/pv_site_api/utils.py @@ -1,33 +1,28 @@ """ make fake intensity""" import asyncio import math +import uuid from datetime import datetime, timedelta, timezone from typing import List import httpx -from pvsite_datamodel.sqlmodels import ClientSQL +from .enode_auth import EnodeAuth from .pydantic_models import Inverters, InverterValues TOTAL_MINUTES_IN_ONE_DAY = 24 * 60 -async def get_inverters_list(session, inverter_ids): - client = session.query(ClientSQL).first() - assert client is not None - - async with httpx.AsyncClient() as httpxClient: - headers = {"Enode-User-Id": str(client.client_uuid)} +async def get_inverters_list( + client_uuid: uuid.UUID, inverter_ids: list[str], enode_auth: EnodeAuth, enode_api_base_url: str +) -> Inverters: + async with httpx.AsyncClient(base_url=enode_api_base_url, auth=enode_auth) as httpx_client: + headers = {"Enode-User-Id": str(client_uuid)} inverters_raw = await asyncio.gather( - *[ - httpxClient.get( - f"https://enode-api.production.enode.io/inverters/{id}", headers=headers - ) - for id in inverter_ids - ] + *[httpx_client.get(f"/inverters/{id}", headers=headers) for id in inverter_ids] ) - inverters = [InverterValues(**(inverter_raw.json())) for inverter_raw in inverters_raw] + inverters = [InverterValues(**(inverter_raw.json())) for inverter_raw in inverters_raw] return Inverters(inverters=inverters) diff --git a/pyproject.toml b/pyproject.toml index 0434375..57e1cd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ testcontainers-postgres = "^0.0.1rc1" ipython = "^8.11.0" freezegun = "^1.2.2" pytest-httpx = "^0.22.0" +freezegun = "^1.2.2" [build-system] requires = ["poetry-core"] diff --git a/tests/conftest.py b/tests/conftest.py index 5035078..92d43f9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,6 +24,12 @@ from pv_site_api.session import get_session +@pytest.fixture +def non_mocked_hosts() -> list: + """Prevent TestClient fixture from being mocked""" + return ["testserver"] + + @pytest.fixture def _now(autouse=True): """Hard-code the time for all tests to make the tests less flaky.""" @@ -75,12 +81,6 @@ def clients(db_session): return clients -@pytest.fixture -def non_mocked_hosts() -> list: - """Prevent TestClient fixture from being mocked""" - return ["testserver"] - - @pytest.fixture() def sites(db_session, clients): """Create some fake sites""" diff --git a/tests/test_inverters.py b/tests/test_inverters.py index a706434..d16885b 100644 --- a/tests/test_inverters.py +++ b/tests/test_inverters.py @@ -1,13 +1,47 @@ """ Test for main app """ +import os from pv_site_api.pydantic_models import Inverters +enode_api_base_url = os.getenv("ENODE_API_BASE_URL", "https://enode-api.sandbox.enode.io") -def add_response(id, httpx_mock): + +def test_get_inverters_from_site(client, sites, inverters, httpx_mock): + mock_inverter_response("id1", httpx_mock) + mock_inverter_response("id2", httpx_mock) + mock_inverter_response("id3", httpx_mock) + + response = client.get(f"/sites/{sites[0].site_uuid}/inverters") + assert response.status_code == 200 + + response_inverters = Inverters(**response.json()) + assert len(inverters) == len(response_inverters.inverters) + + +def test_get_enode_inverters_fake(client, fake): + response = client.get("/enode/inverters") + assert response.status_code == 200 + + response_inverters = Inverters(**response.json()) + assert len(response_inverters.inverters) > 0 + + +def test_get_enode_inverters(client, httpx_mock, clients): + httpx_mock.add_response(url=f"{enode_api_base_url}/inverters", json=["id1"]) + mock_inverter_response("id1", httpx_mock) + + response = client.get("/enode/inverters") + assert response.status_code == 200 + + inverters = Inverters(**response.json()) + assert len(inverters.inverters) > 0 + + +def mock_inverter_response(id, httpx_mock): httpx_mock.add_response( - url=f"https://enode-api.production.enode.io/inverters/{id}", + url=f"{enode_api_base_url}/inverters/{id}", json={ - "id": "string", + "id": id, "vendor": "EMA", "chargingLocationId": "8d90101b-3f2f-462a-bbb4-1ed320d33bbe", "lastSeen": "2020-04-07T17:04:26Z", @@ -28,34 +62,3 @@ def add_response(id, httpx_mock): "location": {"longitude": 10.7197486, "latitude": 59.9173985}, }, ) - - -def test_get_inverters_from_site(client, sites, inverters, httpx_mock): - add_response("id1", httpx_mock) - add_response("id2", httpx_mock) - add_response("id3", httpx_mock) - - response = client.get(f"/sites/{sites[0].site_uuid}/inverters") - assert response.status_code == 200 - - response_inverters = Inverters(**response.json()) - assert len(inverters) == len(response_inverters.inverters) - - -def test_get_inverters_fake(client, fake): - response = client.get("/inverters") - assert response.status_code == 200 - - response_inverters = Inverters(**response.json()) - assert len(response_inverters.inverters) > 0 - - -def test_get_inverters(client, httpx_mock, clients): - httpx_mock.add_response(url="https://enode-api.production.enode.io/inverters", json=["id1"]) - add_response("id1", httpx_mock) - - response = client.get("/inverters") - assert response.status_code == 200 - - inverters = Inverters(**response.json()) - assert len(inverters.inverters) > 0 diff --git a/tests/test_sites.py b/tests/test_sites.py index 6cd460c..fbb5195 100644 --- a/tests/test_sites.py +++ b/tests/test_sites.py @@ -23,7 +23,7 @@ def test_get_site_list(client, sites): assert len(pv_sites.site_list) > 0 -def test_put_site_fake(client, fake): +def test_post_site_fake(client, fake): pv_site = PVSiteMetadata( client_name="client_name_1", client_site_id="the site id used by the user", @@ -45,7 +45,7 @@ def test_put_site_fake(client, fake): assert response.status_code == 200, response.text -def test_put_site(db_session, client, clients): +def test_post_site(db_session, client, clients): # make site object pv_site = PVSiteMetadata( client_name="test_client", @@ -72,37 +72,39 @@ def test_put_site(db_session, client, clients): assert sites[0].site_uuid is not None -# Comment this out, until we have security on this -# def test_put_site_and_update(db_session): -# pv_site = PVSiteMetadata( -# site_uuid=str(uuid4()), -# client_uuid="eeee-eeee", -# client_site_id="the site id used by the user", -# client_site_name="the site name", -# region="the site's region", -# dno="the site's dno", -# gsp="the site's gsp", -# orientation=180, -# tilt=90, -# latitude=50, -# longitude=0, -# installed_capacity_kw=1, -# created_utc=datetime.now(timezone.utc).isoformat(), -# ) -# -# pv_site_dict = json.loads(pv_site.json()) -# -# response = client.post(f"sites/", json=pv_site_dict) -# assert response.status_code == 200, response.text -# -# pv_site.orientation = 100 -# pv_site_dict = json.loads(pv_site.json()) -# -# response = client.put(f"sites/{pv_site.site_uuid}", json=pv_site_dict) -# assert response.status_code == 200, response.text -# -# sites = db_session.query(SiteSQL).all() -# assert len(sites) == 1 -# assert sites[0].site_uuid == pv_site.site_uuid -# assert sites[0].orientation == pv_site.orientation -# +def test_post_site_and_update(db_session, client, clients): + pv_site = PVSiteMetadata( + client_name="test_client_1", + client_uuid="eeee-eeee", + client_site_id=34, + client_site_name="the site name", + region="the site's region", + dno="the site's dno", + gsp="the site's gsp", + orientation=180, + tilt=90, + latitude=50, + longitude=0, + installed_capacity_kw=1, + created_utc=datetime.now(timezone.utc).isoformat(), + ) + + pv_site_dict = json.loads(pv_site.json()) + + response = client.post("/sites", json=pv_site_dict) + assert response.status_code == 200, response.text + + sites = db_session.query(SiteSQL).all() + assert len(sites) == 1 + + pv_site.orientation = 97 + pv_site.tilt = 127 + pv_site_dict = json.loads(pv_site.json()) + + site_uuid = sites[0].site_uuid + response = client.put(f"/sites/{site_uuid}", json=pv_site_dict) + assert response.status_code == 200, response.text + + sites = db_session.query(SiteSQL).all() + assert sites[0].orientation == pv_site.orientation + assert sites[0].tilt == pv_site.tilt From f491656d731a48c7341c1ace68eb4d7fd21a583f Mon Sep 17 00:00:00 2001 From: AndrewLester Date: Sun, 23 Apr 2023 15:50:06 -0500 Subject: [PATCH 07/22] Add post inverters for site endpoint Move a few things Fix pyproject Fix Fix tests Fix import order Default the token URL Add some tests for other states Fix last missing coverage in main.py --- pv_site_api/main.py | 68 +++++++++++++++++++++++++--------- pv_site_api/pydantic_models.py | 6 +-- pyproject.toml | 1 - tests/conftest.py | 1 - tests/test_enode.py | 7 +++- tests/test_inverters.py | 42 ++++++++++++++++++++- tests/test_sites.py | 50 ++++++++++++++++++++++++- 7 files changed, 149 insertions(+), 26 deletions(-) diff --git a/pv_site_api/main.py b/pv_site_api/main.py index 57730ab..e3b3eb5 100644 --- a/pv_site_api/main.py +++ b/pv_site_api/main.py @@ -1,12 +1,10 @@ """Main API Routes""" -import asyncio -import logging import os +import httpx import pandas as pd import sentry_sdk import structlog -import httpx from dotenv import load_dotenv from fastapi import Depends, FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware @@ -15,7 +13,7 @@ from pvlib import irradiance, location, pvsystem from pvsite_datamodel.read.site import get_all_sites from pvsite_datamodel.read.status import get_latest_status -from pvsite_datamodel.sqlmodels import ClientSQL, SiteSQL +from pvsite_datamodel.sqlmodels import ClientSQL, InverterSQL, SiteSQL from pvsite_datamodel.write.generation import insert_generation_values from sqlalchemy.orm import Session @@ -52,22 +50,11 @@ from .redoc_theme import get_redoc_html_with_theme from .session import get_session from .utils import get_inverters_list, get_yesterday_midnight -from .enode_auth import ( - EnodeAuth, -) load_dotenv() logger = structlog.stdlib.get_logger() -enode_auth = EnodeAuth( - os.getenv("ENODE_CLIENT_ID", ""), - os.getenv("ENODE_CLIENT_SECRET", ""), - os.getenv("ENODE_TOKEN_URL", ""), -) - -enode_api_base_url = os.getenv("ENODE_API_BASE_URL", "https://enode-api.sandbox.enode.io") - def traces_sampler(sampling_context): """ @@ -124,7 +111,14 @@ def is_fake(): api_audience=os.getenv("AUTH0_API_AUDIENCE"), algorithm=os.getenv("AUTH0_ALGORITHM"), ) -auth = EnodeAuth() + +enode_auth = EnodeAuth( + os.getenv("ENODE_CLIENT_ID", ""), + os.getenv("ENODE_CLIENT_SECRET", ""), + os.getenv("ENODE_TOKEN_URL", "https://oauth.sandbox.enode.io/oauth2/token"), +) + +enode_api_base_url = os.getenv("ENODE_API_BASE_URL", "https://enode-api.sandbox.enode.io") # name the api # test that the routes are there on swagger @@ -408,6 +402,7 @@ def get_pv_estimate_clearsky( def get_pv_estimate_clearsky_many_sites( site_uuids: str, session: Session = Depends(get_session), + auth: Auth = Depends(auth), ): """ ### Gets a estimate of AC production under a clear sky for multiple sites. @@ -466,7 +461,9 @@ def get_pv_estimate_clearsky_many_sites( @app.get("/enode/link", response_class=RedirectResponse) -def get_enode_link(redirect_uri: str, session: Session = Depends(get_session)): +def get_enode_link( + redirect_uri: str, session: Session = Depends(get_session), auth: Auth = Depends(auth) +): """ ### Returns a URL from Enode that starts a user's Enode link flow. """ @@ -486,6 +483,7 @@ def get_enode_link(redirect_uri: str, session: Session = Depends(get_session)): @app.get("/enode/inverters") async def get_inverters( session: Session = Depends(get_session), + auth: Auth = Depends(auth), ): if is_fake(): return make_fake_inverters() @@ -504,9 +502,10 @@ async def get_inverters( @app.get("/sites/{site_uuid}/inverters") -async def get_inverters_by_site( +async def get_inverters_for_site( site_uuid: str, session: Session = Depends(get_session), + auth: Auth = Depends(auth), ): if is_fake(): return make_fake_inverters() @@ -526,6 +525,39 @@ async def get_inverters_by_site( ) +@app.post("/sites/{site_uuid}/inverters") +def post_inverters_for_site( + site_uuid: str, + client_ids: list[str], + session: Session = Depends(get_session), + auth: Auth = Depends(auth), +): + if is_fake(): + print(f"Successfully changed inverters for {site_uuid}") + print("Not doing anything with it (yet!)") + return + + # @TODO: get client corresponding to auth + client = session.query(ClientSQL).first() + assert client is not None + + site = ( + session.query(SiteSQL) + .filter_by(client_uuid=client.client_uuid, site_uuid=site_uuid) + .first() + ) + if site is None: + raise HTTPException(status_code=404, detail="Site not found") + + site.inverters.clear() + + for client_id in client_ids: + site.inverters.append(InverterSQL(site_uuid=site_uuid, client_id=client_id)) + + session.add(site) + session.commit() + + # get_status: get the status of the system @app.get("/api_status", response_model=PVSiteAPIStatus) def get_status(session: Session = Depends(get_session)): diff --git a/pv_site_api/pydantic_models.py b/pv_site_api/pydantic_models.py index 8681b8e..e1125f2 100644 --- a/pv_site_api/pydantic_models.py +++ b/pv_site_api/pydantic_models.py @@ -134,7 +134,7 @@ class InverterInformation(BaseModel): model: str = Field(..., description="Solar inverter model") siteName: str = Field( ..., - description="Name of the site, as set by the user on the device/vendor. If no user-specified name is available, we construct a fallback name using the vendor/device/model names.", + description="Name of the site, as set by the user on the device/vendor.", ) installationDate: str = Field(..., description="Solar inverter installation date") @@ -155,14 +155,14 @@ class InverterValues(BaseModel): ) chargingLocationId: Optional[str] = Field( ..., - description="ID of the charging location the solar inverter is currently positioned at (if any).", + description="ID of the charging location the solar inverter is currently positioned at.", ) lastSeen: str = Field( ..., description="The last time the solar inverter was successfully communicated with" ) isReachable: bool = Field( ..., - description="Whether live data from the solar inverter is currently reachable from Enode's perspective. This 'reachability' may refer to reading from a cache operated by the solar inverter's cloud service if that service has determined that its cache is valid.", + description="Whether live data from the solar inverter is currently reachable", ) productionState: InverterProductionState = Field( ..., description="Descriptive information about the production state" diff --git a/pyproject.toml b/pyproject.toml index 57e1cd8..0434375 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,6 @@ testcontainers-postgres = "^0.0.1rc1" ipython = "^8.11.0" freezegun = "^1.2.2" pytest-httpx = "^0.22.0" -freezegun = "^1.2.2" [build-system] requires = ["poetry-core"] diff --git a/tests/conftest.py b/tests/conftest.py index 92d43f9..d998e9c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,6 @@ InverterSQL, SiteSQL, StatusSQL, - InverterSQL, ) from sqlalchemy import create_engine from sqlalchemy.orm import Session diff --git a/tests/test_enode.py b/tests/test_enode.py index 03939df..4e79199 100644 --- a/tests/test_enode.py +++ b/tests/test_enode.py @@ -1,3 +1,8 @@ +import os + +enode_api_base_url = os.getenv("ENODE_API_BASE_URL", "https://enode-api.sandbox.enode.io") + + def test_get_enode_link_fake(client, fake): params = {"redirect_uri": "https://example.org"} response = client.get("/enode/link", params=params, follow_redirects=False) @@ -10,7 +15,7 @@ def test_get_enode_link(client, clients, httpx_mock): test_enode_link_uri = "https://example.com" httpx_mock.add_response( - url=f"https://enode-api.production.enode.io/users/{clients[0].client_uuid}/link", + url=f"{enode_api_base_url}/users/{clients[0].client_uuid}/link", json={"linkUrl": test_enode_link_uri}, ) diff --git a/tests/test_inverters.py b/tests/test_inverters.py index d16885b..3f70956 100644 --- a/tests/test_inverters.py +++ b/tests/test_inverters.py @@ -6,7 +6,41 @@ enode_api_base_url = os.getenv("ENODE_API_BASE_URL", "https://enode-api.sandbox.enode.io") -def test_get_inverters_from_site(client, sites, inverters, httpx_mock): +def test_post_inverters_for_site_fake(client, sites, fake): + test_inverter_client_id = "6c078ca2-2e75-40c8-9a7f-288bd0b70065" + json = [test_inverter_client_id] + response = client.post(f"/sites/{sites[0].site_uuid}/inverters", json=json) + + assert response.status_code == 200 + + +def test_post_inverters_for_site(client, sites, httpx_mock): + test_inverter_client_id = "6c078ca2-2e75-40c8-9a7f-288bd0b70065" + json = [test_inverter_client_id] + response = client.post(f"/sites/{sites[0].site_uuid}/inverters", json=json) + assert response.status_code == 200 + + mock_inverter_response(test_inverter_client_id, httpx_mock) + + response = client.get(f"/sites/{sites[0].site_uuid}/inverters") + + assert response.json()["inverters"][0]["id"] == test_inverter_client_id + + +def test_post_inverters_for_nonexistant_site(client, sites): + nonexistant_site_uuid = "1cd11139-790a-46c0-8849-0c7c8e810ba5" + test_inverter_client_id = "6c078ca2-2e75-40c8-9a7f-288bd0b70065" + json = [test_inverter_client_id] + response = client.post(f"/sites/{nonexistant_site_uuid}/inverters", json=json) + assert response.status_code == 404 + + +def test_get_inverters_for_site_fake(client, sites, inverters, fake): + response = client.get(f"/sites/{sites[0].site_uuid}/inverters") + assert response.status_code == 200 + + +def test_get_inverters_for_site(client, sites, inverters, httpx_mock): mock_inverter_response("id1", httpx_mock) mock_inverter_response("id2", httpx_mock) mock_inverter_response("id3", httpx_mock) @@ -18,6 +52,12 @@ def test_get_inverters_from_site(client, sites, inverters, httpx_mock): assert len(inverters) == len(response_inverters.inverters) +def test_get_inverters_for_nonexistant_site(client, sites, inverters, httpx_mock): + nonexistant_site_uuid = "1cd11139-790a-46c0-8849-0c7c8e810ba5" + response = client.get(f"/sites/{nonexistant_site_uuid}/inverters") + assert response.status_code == 404 + + def test_get_enode_inverters_fake(client, fake): response = client.get("/enode/inverters") assert response.status_code == 200 diff --git a/tests/test_sites.py b/tests/test_sites.py index fbb5195..116be31 100644 --- a/tests/test_sites.py +++ b/tests/test_sites.py @@ -48,7 +48,7 @@ def test_post_site_fake(client, fake): def test_post_site(db_session, client, clients): # make site object pv_site = PVSiteMetadata( - client_name="test_client", + client_name="test_client_1", client_site_id=1, client_site_name="the site name", region="the site's region", @@ -72,6 +72,30 @@ def test_post_site(db_session, client, clients): assert sites[0].site_uuid is not None +def test_update_site_fake(fake, sites, client, clients): + pv_site = PVSiteMetadata( + client_name="test_client_1", + client_uuid="eeee-eeee", + client_site_id=34, + client_site_name="the site name", + region="the site's region", + dno="the site's dno", + gsp="the site's gsp", + orientation=180, + tilt=90, + latitude=50, + longitude=0, + installed_capacity_kw=1, + created_utc=datetime.now(timezone.utc).isoformat(), + ) + + pv_site_dict = json.loads(pv_site.json()) + + site_uuid = sites[0].site_uuid + response = client.put(f"/sites/{site_uuid}", json=pv_site_dict) + assert response.status_code == 200, response.text + + def test_post_site_and_update(db_session, client, clients): pv_site = PVSiteMetadata( client_name="test_client_1", @@ -108,3 +132,27 @@ def test_post_site_and_update(db_session, client, clients): sites = db_session.query(SiteSQL).all() assert sites[0].orientation == pv_site.orientation assert sites[0].tilt == pv_site.tilt + + +def test_update_nonexistant_site(sites, client, clients): + pv_site = PVSiteMetadata( + client_name="test_client_1", + client_uuid="eeee-eeee", + client_site_id=34, + client_site_name="the site name", + region="the site's region", + dno="the site's dno", + gsp="the site's gsp", + orientation=180, + tilt=90, + latitude=50, + longitude=0, + installed_capacity_kw=1, + created_utc=datetime.now(timezone.utc).isoformat(), + ) + + pv_site_dict = json.loads(pv_site.json()) + + nonexistant_site_uuid = "1cd11139-790a-46c0-8849-0c7c8e810ba5" + response = client.put(f"/sites/{nonexistant_site_uuid}", json=pv_site_dict) + assert response.status_code == 404 From f4258e35b758d8ab0d2bcd5b4cf3e1be9fd934a7 Mon Sep 17 00:00:00 2001 From: AndrewLester Date: Mon, 24 Apr 2023 02:01:57 -0500 Subject: [PATCH 08/22] Add Enode auth test --- tests/test_enode_auth.py | 45 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 tests/test_enode_auth.py diff --git a/tests/test_enode_auth.py b/tests/test_enode_auth.py new file mode 100644 index 0000000..41b9be4 --- /dev/null +++ b/tests/test_enode_auth.py @@ -0,0 +1,45 @@ +""" +Test the Enode authentication HTTPX auth class. +""" +import pytest +import httpx +from pv_site_api.enode_auth import EnodeAuth + +TOKEN_URL = "https://example.com/token" +CLIENT_ID = "ocf" +CLIENT_SECRET = "secret" + +enode_base_url = "https://enode.com/api" + + +@pytest.fixture +def enode_auth(): + """An Enode Auth object""" + enode_auth = EnodeAuth(token_url=TOKEN_URL, client_id=CLIENT_ID, client_secret=CLIENT_SECRET) + return enode_auth + + +def test_enode_auth(enode_auth): + request = httpx.Request("GET", f"{enode_base_url}/inverters") + gen = enode_auth.auth_flow(request) + authenticated_request = next(gen) + assert authenticated_request.headers["Authorization"] == f"Bearer None" + + refresh_request = gen.send(httpx.Response(401)) + assert ( + refresh_request.method == "POST" + and refresh_request.url == httpx.URL(TOKEN_URL) + and refresh_request.content == b"grant_type=client_credentials" + ) + + test_access_token = "test_access_token" + authenticated_request = gen.send(httpx.Response(200, json={"access_token": test_access_token})) + assert authenticated_request.headers["Authorization"] == f"Bearer {test_access_token}" + + try: + gen.send(httpx.Response(200, json=["id1"])) + except StopIteration as e: + assert isinstance(e.value, httpx.Response) and e.value.json()[0] == "id1" + else: + # The generator should exit + assert False From ec1898c83b05fae64bfdd0dfa08c747353010143 Mon Sep 17 00:00:00 2001 From: AndrewLester Date: Mon, 24 Apr 2023 02:03:07 -0500 Subject: [PATCH 09/22] Fix format --- tests/test_enode_auth.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_enode_auth.py b/tests/test_enode_auth.py index 41b9be4..93c5569 100644 --- a/tests/test_enode_auth.py +++ b/tests/test_enode_auth.py @@ -1,8 +1,9 @@ """ Test the Enode authentication HTTPX auth class. """ -import pytest import httpx +import pytest + from pv_site_api.enode_auth import EnodeAuth TOKEN_URL = "https://example.com/token" @@ -23,7 +24,7 @@ def test_enode_auth(enode_auth): request = httpx.Request("GET", f"{enode_base_url}/inverters") gen = enode_auth.auth_flow(request) authenticated_request = next(gen) - assert authenticated_request.headers["Authorization"] == f"Bearer None" + assert authenticated_request.headers["Authorization"] == "Bearer None" refresh_request = gen.send(httpx.Response(401)) assert ( From 67152063dfbd87f4423fa25d64b47e516c4a4b96 Mon Sep 17 00:00:00 2001 From: AndrewLester Date: Mon, 24 Apr 2023 19:56:14 -0500 Subject: [PATCH 10/22] Change update inverters for site from post to put --- pv_site_api/main.py | 4 ++-- tests/test_inverters.py | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pv_site_api/main.py b/pv_site_api/main.py index e3b3eb5..2c2ce29 100644 --- a/pv_site_api/main.py +++ b/pv_site_api/main.py @@ -525,8 +525,8 @@ async def get_inverters_for_site( ) -@app.post("/sites/{site_uuid}/inverters") -def post_inverters_for_site( +@app.put("/sites/{site_uuid}/inverters") +def put_inverters_for_site( site_uuid: str, client_ids: list[str], session: Session = Depends(get_session), diff --git a/tests/test_inverters.py b/tests/test_inverters.py index 3f70956..85d3496 100644 --- a/tests/test_inverters.py +++ b/tests/test_inverters.py @@ -6,18 +6,18 @@ enode_api_base_url = os.getenv("ENODE_API_BASE_URL", "https://enode-api.sandbox.enode.io") -def test_post_inverters_for_site_fake(client, sites, fake): +def test_put_inverters_for_site_fake(client, sites, fake): test_inverter_client_id = "6c078ca2-2e75-40c8-9a7f-288bd0b70065" json = [test_inverter_client_id] - response = client.post(f"/sites/{sites[0].site_uuid}/inverters", json=json) + response = client.put(f"/sites/{sites[0].site_uuid}/inverters", json=json) assert response.status_code == 200 -def test_post_inverters_for_site(client, sites, httpx_mock): +def test_put_inverters_for_site(client, sites, httpx_mock): test_inverter_client_id = "6c078ca2-2e75-40c8-9a7f-288bd0b70065" json = [test_inverter_client_id] - response = client.post(f"/sites/{sites[0].site_uuid}/inverters", json=json) + response = client.put(f"/sites/{sites[0].site_uuid}/inverters", json=json) assert response.status_code == 200 mock_inverter_response(test_inverter_client_id, httpx_mock) @@ -27,11 +27,11 @@ def test_post_inverters_for_site(client, sites, httpx_mock): assert response.json()["inverters"][0]["id"] == test_inverter_client_id -def test_post_inverters_for_nonexistant_site(client, sites): +def test_put_inverters_for_nonexistant_site(client, sites): nonexistant_site_uuid = "1cd11139-790a-46c0-8849-0c7c8e810ba5" test_inverter_client_id = "6c078ca2-2e75-40c8-9a7f-288bd0b70065" json = [test_inverter_client_id] - response = client.post(f"/sites/{nonexistant_site_uuid}/inverters", json=json) + response = client.put(f"/sites/{nonexistant_site_uuid}/inverters", json=json) assert response.status_code == 404 From d904f3acbbd9a89f6680be7cf6d3af60f2ebee2b Mon Sep 17 00:00:00 2001 From: AndrewLester Date: Thu, 27 Apr 2023 01:04:35 -0500 Subject: [PATCH 11/22] Fix all review comments besides blocking client queries --- pv_site_api/main.py | 14 ++++++++++---- tests/conftest.py | 7 ++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/pv_site_api/main.py b/pv_site_api/main.py index 2c2ce29..a16d157 100644 --- a/pv_site_api/main.py +++ b/pv_site_api/main.py @@ -193,8 +193,6 @@ def post_pv_actual( session.commit() -# Comment this out, until we have security on this -# # put_site_info: client can update a site @app.put("/sites/{site_uuid}") def put_site_info( site_uuid: str, @@ -208,11 +206,11 @@ def put_site_info( """ if is_fake(): - print(f"Successfully updated site {site_uuid} with {site_info.dict()}") - print("Not doing anything with it (yet!)") + print(f"Fake: would update site {site_uuid} with {site_info.dict()}") return # @TODO: get client corresponding to auth + # See: https://github.com/openclimatefix/pv-site-api/issues/90 client = session.query(ClientSQL).first() assert client is not None @@ -256,6 +254,7 @@ def post_site_info( return # @TODO: get client corresponding to auth + # See: https://github.com/openclimatefix/pv-site-api/issues/90 client = session.query(ClientSQL).first() assert client is not None @@ -470,6 +469,8 @@ def get_enode_link( if is_fake(): return make_fake_enode_link_url() + # @TODO: get client corresponding to auth + # See: https://github.com/openclimatefix/pv-site-api/issues/90 client = session.query(ClientSQL).first() assert client is not None @@ -488,6 +489,8 @@ async def get_inverters( if is_fake(): return make_fake_inverters() + # @TODO: get client corresponding to auth + # See: https://github.com/openclimatefix/pv-site-api/issues/90 client = session.query(ClientSQL).first() assert client is not None @@ -515,6 +518,8 @@ async def get_inverters_for_site( if not site_exists: raise HTTPException(status_code=404) + # @TODO: get client corresponding to auth + # See: https://github.com/openclimatefix/pv-site-api/issues/90 client = session.query(ClientSQL).first() assert client is not None @@ -538,6 +543,7 @@ def put_inverters_for_site( return # @TODO: get client corresponding to auth + # See: https://github.com/openclimatefix/pv-site-api/issues/90 client = session.query(ClientSQL).first() assert client is not None diff --git a/tests/conftest.py b/tests/conftest.py index d998e9c..85b5731 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -110,9 +110,10 @@ def inverters(db_session, sites): """Create some fake inverters for site 0""" inverters = [] num_inverters = 3 - for j in range(num_inverters): - inverter = InverterSQL(site_uuid=sites[0].site_uuid, client_id=f"id{j+1}") - inverters.append(inverter) + inverters = [ + InverterSQL(site_uuid=sites[0].site_uuid, client_id=f"id{j+1}") + for j in range(num_inverters) + ] db_session.add_all(inverters) db_session.commit() From ab5a5c14857e4506b5a6bc63bdfddeca6d77f386 Mon Sep 17 00:00:00 2001 From: ericcccliu Date: Thu, 27 Apr 2023 17:14:40 -0500 Subject: [PATCH 12/22] remove redirectresponse class --- pv_site_api/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pv_site_api/main.py b/pv_site_api/main.py index a16d157..7e3157d 100644 --- a/pv_site_api/main.py +++ b/pv_site_api/main.py @@ -459,9 +459,9 @@ def get_pv_estimate_clearsky_many_sites( return res -@app.get("/enode/link", response_class=RedirectResponse) +@app.get("/enode/link") def get_enode_link( - redirect_uri: str, session: Session = Depends(get_session), auth: Auth = Depends(auth) + redirect_uri: str, session: Session = Depends(get_session), auth: Auth = Depends(auth), ): """ ### Returns a URL from Enode that starts a user's Enode link flow. From e4c0922ef2208055087c2d6672d3b64c5fdd01be Mon Sep 17 00:00:00 2001 From: ericcccliu Date: Thu, 27 Apr 2023 17:16:22 -0500 Subject: [PATCH 13/22] lint and format --- pv_site_api/main.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pv_site_api/main.py b/pv_site_api/main.py index 7e3157d..7379393 100644 --- a/pv_site_api/main.py +++ b/pv_site_api/main.py @@ -9,7 +9,7 @@ from fastapi import Depends, FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.utils import get_openapi -from fastapi.responses import FileResponse, JSONResponse, RedirectResponse +from fastapi.responses import FileResponse, JSONResponse from pvlib import irradiance, location, pvsystem from pvsite_datamodel.read.site import get_all_sites from pvsite_datamodel.read.status import get_latest_status @@ -461,7 +461,9 @@ def get_pv_estimate_clearsky_many_sites( @app.get("/enode/link") def get_enode_link( - redirect_uri: str, session: Session = Depends(get_session), auth: Auth = Depends(auth), + redirect_uri: str, + session: Session = Depends(get_session), + auth: Auth = Depends(auth), ): """ ### Returns a URL from Enode that starts a user's Enode link flow. From 94c376709090d1deb3cf277ec11d11e5cb002d4a Mon Sep 17 00:00:00 2001 From: AndrewLester Date: Fri, 28 Apr 2023 14:06:42 -0500 Subject: [PATCH 14/22] Add client fetch to auth dependency and create new dependency for async stuff --- pv_site_api/_db_helpers.py | 19 +++++-- pv_site_api/auth.py | 21 ++++++- pv_site_api/cache.py | 2 +- pv_site_api/enode_auth.py | 22 ++++++-- pv_site_api/main.py | 110 ++++++++++++------------------------- tests/conftest.py | 5 +- tests/test_auth.py | 6 +- tests/test_enode.py | 8 +-- tests/test_enode_auth.py | 14 ++--- 9 files changed, 102 insertions(+), 105 deletions(-) diff --git a/pv_site_api/_db_helpers.py b/pv_site_api/_db_helpers.py index fd2a5b9..e6cfb2b 100644 --- a/pv_site_api/_db_helpers.py +++ b/pv_site_api/_db_helpers.py @@ -13,6 +13,7 @@ import sqlalchemy as sa import structlog +from fastapi import Depends from pvsite_datamodel.read.generation import get_pv_generation_by_sites from pvsite_datamodel.sqlmodels import ForecastSQL, ForecastValueSQL, InverterSQL, SiteSQL from sqlalchemy.orm import Session, aliased @@ -24,6 +25,7 @@ PVSiteMetadata, SiteForecastValues, ) +from .session import get_session logger = structlog.stdlib.get_logger() @@ -60,12 +62,6 @@ def _get_forecasts_for_horizon( return list(session.execute(stmt)) -def _get_inverters_by_site(session: Session, site_uuid: str) -> list[Row]: - query = session.query(InverterSQL).filter(InverterSQL.site_uuid == site_uuid) - - return query.all() - - def _get_latest_forecast_by_sites( session: Session, site_uuids: list[str], start_utc: Optional[dt.datetime] = None ) -> list[Row]: @@ -240,3 +236,14 @@ def does_site_exist(session: Session, site_uuid: str) -> bool: session.execute(sa.select(SiteSQL).where(SiteSQL.site_uuid == site_uuid)).one_or_none() is not None ) + + +def get_inverters_for_site( + site_uuid: str, session: Session = Depends(get_session) +) -> list[Row] | None: + """Path dependency to get a list of inverters for a site, or None if the site doesn't exist""" + if not does_site_exist(session, site_uuid): + return None + + query = session.query(InverterSQL).filter(InverterSQL.site_uuid == site_uuid) + return query.all() diff --git a/pv_site_api/auth.py b/pv_site_api/auth.py index a795830..2b80ac8 100644 --- a/pv_site_api/auth.py +++ b/pv_site_api/auth.py @@ -1,6 +1,10 @@ import jwt from fastapi import Depends, HTTPException from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from pvsite_datamodel.sqlmodels import ClientSQL +from sqlalchemy.orm import Session + +from .session import get_session token_auth_scheme = HTTPBearer() @@ -15,7 +19,11 @@ def __init__(self, domain: str, api_audience: str, algorithm: str): self._jwks_client = jwt.PyJWKClient(f"https://{domain}/.well-known/jwks.json") - def __call__(self, auth_credentials: HTTPAuthorizationCredentials = Depends(token_auth_scheme)): + def __call__( + self, + auth_credentials: HTTPAuthorizationCredentials = Depends(token_auth_scheme), + session: Session = Depends(get_session), + ): token = auth_credentials.credentials try: @@ -24,7 +32,7 @@ def __call__(self, auth_credentials: HTTPAuthorizationCredentials = Depends(toke raise HTTPException(status_code=401, detail=str(e)) try: - payload = jwt.decode( + jwt.decode( token, signing_key, algorithms=self._algorithm, @@ -34,4 +42,11 @@ def __call__(self, auth_credentials: HTTPAuthorizationCredentials = Depends(toke except Exception as e: raise HTTPException(status_code=401, detail=str(e)) - return payload + if session is None: + return None + + # @TODO: get client corresponding to auth + # See: https://github.com/openclimatefix/pv-site-api/issues/90 + client = session.query(ClientSQL).first() + assert client is not None + return client diff --git a/pv_site_api/cache.py b/pv_site_api/cache.py index f9583fa..fec8618 100644 --- a/pv_site_api/cache.py +++ b/pv_site_api/cache.py @@ -39,7 +39,7 @@ def wrapper(*args, **kwargs): # noqa route_variables = kwargs.copy() # drop session and user - for var in ["session", "user"]: + for var in ["session", "user", "auth"]: if var in route_variables: route_variables.pop(var) diff --git a/pv_site_api/enode_auth.py b/pv_site_api/enode_auth.py index c647c54..76aea97 100644 --- a/pv_site_api/enode_auth.py +++ b/pv_site_api/enode_auth.py @@ -12,7 +12,7 @@ def __init__( self._token_url = token_url self._access_token = access_token - def auth_flow(self, request: httpx.Request): + def sync_auth_flow(self, request: httpx.Request): # Add the Authorization header to the request using the current access token request.headers["Authorization"] = f"Bearer {self._access_token}" response = yield request @@ -20,12 +20,27 @@ def auth_flow(self, request: httpx.Request): if response.status_code == 401: # The access token is no longer valid, refresh it token_response = yield self._build_refresh_request() + token_response.read() self._update_access_token(token_response) # Update the request's Authorization header with the new access token request.headers["Authorization"] = f"Bearer {self._access_token}" # Resend the request with the new access token - response = yield request - return response + yield request + + async def async_auth_flow(self, request: httpx.Request): + # Add the Authorization header to the request using the current access token + request.headers["Authorization"] = f"Bearer {self._access_token}" + response = yield request + + if response.status_code == 401: + # The access token is no longer valid, refresh it + token_response = yield self._build_refresh_request() + await token_response.aread() + self._update_access_token(token_response) + # Update the request's Authorization header with the new access token + request.headers["Authorization"] = f"Bearer {self._access_token}" + # Resend the request with the new access token + yield request def _build_refresh_request(self): basic_auth = httpx.BasicAuth(self._client_id, self._client_secret) @@ -35,5 +50,4 @@ def _build_refresh_request(self): return request def _update_access_token(self, response): - response.read() self._access_token = response.json()["access_token"] diff --git a/pv_site_api/main.py b/pv_site_api/main.py index 7379393..acce48d 100644 --- a/pv_site_api/main.py +++ b/pv_site_api/main.py @@ -1,9 +1,9 @@ """Main API Routes""" import os +from typing import Any import httpx import pandas as pd -import sentry_sdk import structlog from dotenv import load_dotenv from fastapi import Depends, FastAPI, HTTPException @@ -20,10 +20,10 @@ import pv_site_api from ._db_helpers import ( - _get_inverters_by_site, does_site_exist, get_forecasts_by_sites, get_generation_by_sites, + get_inverters_for_site, get_sites_by_uuids, site_to_pydantic, ) @@ -82,11 +82,11 @@ def is_fake(): return int(os.environ.get("FAKE", 0)) -sentry_sdk.init( - dsn=os.getenv("SENTRY_DSN"), - environment=os.getenv("ENVIRONMENT", "local"), - traces_sampler=traces_sampler, -) +# sentry_sdk.init( +# dsn=os.getenv("SENTRY_DSN"), +# environment=os.getenv("ENVIRONMENT", "local"), +# traces_sampler=traces_sampler, +# ) app = FastAPI(docs_url="/swagger", redoc_url=None) @@ -133,7 +133,7 @@ def is_fake(): @app.get("/sites", response_model=PVSites) def get_sites( session: Session = Depends(get_session), - auth: Auth = Depends(auth), + auth: Any = Depends(auth), ): """ ### This route returns a list of the user's PV Sites with metadata for each site. @@ -161,7 +161,7 @@ def post_pv_actual( site_uuid: str, pv_actual: MultiplePVActual, session: Session = Depends(get_session), - auth: Auth = Depends(auth), + auth: Any = Depends(auth), ): """### This route is used to input actual PV generation. @@ -198,7 +198,7 @@ def put_site_info( site_uuid: str, site_info: PVSiteMetadata, session: Session = Depends(get_session), - auth: Auth = Depends(auth), + auth: Any = Depends(auth), ): """ ### This route allows a user to update a site's information. @@ -209,15 +209,8 @@ def put_site_info( print(f"Fake: would update site {site_uuid} with {site_info.dict()}") return - # @TODO: get client corresponding to auth - # See: https://github.com/openclimatefix/pv-site-api/issues/90 - client = session.query(ClientSQL).first() - assert client is not None - site = ( - session.query(SiteSQL) - .filter_by(client_uuid=client.client_uuid, site_uuid=site_uuid) - .first() + session.query(SiteSQL).filter_by(client_uuid=auth.client_uuid, site_uuid=site_uuid).first() ) if site is None: raise HTTPException(status_code=404, detail="Site not found") @@ -241,7 +234,7 @@ def put_site_info( def post_site_info( site_info: PVSiteMetadata, session: Session = Depends(get_session), - auth: Auth = Depends(auth), + auth: Any = Depends(auth), ): """ ### This route allows a user to add a site. @@ -253,13 +246,8 @@ def post_site_info( print("Not doing anything with it (yet!)") return - # @TODO: get client corresponding to auth - # See: https://github.com/openclimatefix/pv-site-api/issues/90 - client = session.query(ClientSQL).first() - assert client is not None - site = SiteSQL( - client_uuid=client.client_uuid, + client_uuid=auth.client_uuid, client_site_id=site_info.client_site_id, client_site_name=site_info.client_site_name, region=site_info.region, @@ -283,7 +271,7 @@ def post_site_info( def get_pv_actual( site_uuid: str, session: Session = Depends(get_session), - auth: Auth = Depends(auth), + auth: Any = Depends(auth), ): """### This route returns PV readings from a single PV site. @@ -300,7 +288,7 @@ def get_pv_actual( def get_pv_actual_many_sites( site_uuids: str, session: Session = Depends(get_session), - auth: Auth = Depends(auth), + auth: Any = Depends(auth), ): """ ### Get the actual power generation for a list of sites. @@ -320,7 +308,7 @@ def get_pv_actual_many_sites( def get_pv_forecast( site_uuid: str, session: Session = Depends(get_session), - auth: Auth = Depends(auth), + auth: Any = Depends(auth), ): """ ### This route is where you might say the magic happens. @@ -355,12 +343,11 @@ def get_pv_forecast( def get_pv_forecast_many_sites( site_uuids: str, session: Session = Depends(get_session), - auth: Auth = Depends(auth), + auth: ClientSQL = Depends(auth), ): """ ### Get the forecasts for multiple sites. """ - logger.info(f"Getting forecasts for {site_uuids}") if is_fake(): @@ -383,7 +370,7 @@ def get_pv_forecast_many_sites( def get_pv_estimate_clearsky( site_uuid: str, session: Session = Depends(get_session), - auth: Auth = Depends(auth), + auth: Any = Depends(auth), ): """ ### Gets a estimate of AC production under a clear sky @@ -401,7 +388,7 @@ def get_pv_estimate_clearsky( def get_pv_estimate_clearsky_many_sites( site_uuids: str, session: Session = Depends(get_session), - auth: Auth = Depends(auth), + auth: Any = Depends(auth), ): """ ### Gets a estimate of AC production under a clear sky for multiple sites. @@ -462,8 +449,7 @@ def get_pv_estimate_clearsky_many_sites( @app.get("/enode/link") def get_enode_link( redirect_uri: str, - session: Session = Depends(get_session), - auth: Auth = Depends(auth), + auth: Any = Depends(auth), ): """ ### Returns a URL from Enode that starts a user's Enode link flow. @@ -471,14 +457,9 @@ def get_enode_link( if is_fake(): return make_fake_enode_link_url() - # @TODO: get client corresponding to auth - # See: https://github.com/openclimatefix/pv-site-api/issues/90 - client = session.query(ClientSQL).first() - assert client is not None - with httpx.Client(base_url=enode_api_base_url, auth=enode_auth) as httpx_client: data = {"vendorType": "inverter", "redirectUri": redirect_uri} - res = httpx_client.post(f"/users/{client.client_uuid}/link", data=data).json() + res = httpx_client.post(f"/users/{auth.client_uuid}/link", data=data).json() return res["linkUrl"] @@ -486,50 +467,33 @@ def get_enode_link( @app.get("/enode/inverters") async def get_inverters( session: Session = Depends(get_session), - auth: Auth = Depends(auth), + auth: Any = Depends(auth), ): if is_fake(): return make_fake_inverters() - # @TODO: get client corresponding to auth - # See: https://github.com/openclimatefix/pv-site-api/issues/90 - client = session.query(ClientSQL).first() - assert client is not None - async with httpx.AsyncClient(base_url=enode_api_base_url, auth=enode_auth) as httpx_client: - headers = {"Enode-User-Id": str(client.client_uuid)} + headers = {"Enode-User-Id": str(auth.client_uuid)} response_json = (await httpx_client.get("/inverters", headers=headers)).json() inverter_ids = [str(inverter_id) for inverter_id in response_json] - return await get_inverters_list( - client.client_uuid, inverter_ids, enode_auth, enode_api_base_url - ) + return await get_inverters_list(auth.client_uuid, inverter_ids, enode_auth, enode_api_base_url) @app.get("/sites/{site_uuid}/inverters") -async def get_inverters_for_site( - site_uuid: str, - session: Session = Depends(get_session), - auth: Auth = Depends(auth), +async def get_inverters_data_for_site( + inverters: list[Any] | None = Depends(get_inverters_for_site), + auth: Any = Depends(auth), ): if is_fake(): return make_fake_inverters() - site_exists = does_site_exist(session, site_uuid) - - if not site_exists: + if inverters is None: raise HTTPException(status_code=404) - # @TODO: get client corresponding to auth - # See: https://github.com/openclimatefix/pv-site-api/issues/90 - client = session.query(ClientSQL).first() - assert client is not None + inverter_ids = [inverter.client_id for inverter in inverters] - inverter_ids = [inverter.client_id for inverter in _get_inverters_by_site(session, site_uuid)] - - return await get_inverters_list( - client.client_uuid, inverter_ids, enode_auth, enode_api_base_url - ) + return await get_inverters_list(auth.client_uuid, inverter_ids, enode_auth, enode_api_base_url) @app.put("/sites/{site_uuid}/inverters") @@ -537,22 +501,18 @@ def put_inverters_for_site( site_uuid: str, client_ids: list[str], session: Session = Depends(get_session), - auth: Auth = Depends(auth), + auth: Any = Depends(auth), ): + """ + ### Updates a site's inverters with a list of inverter client ids (`client_ids`) + """ if is_fake(): print(f"Successfully changed inverters for {site_uuid}") print("Not doing anything with it (yet!)") return - # @TODO: get client corresponding to auth - # See: https://github.com/openclimatefix/pv-site-api/issues/90 - client = session.query(ClientSQL).first() - assert client is not None - site = ( - session.query(SiteSQL) - .filter_by(client_uuid=client.client_uuid, site_uuid=site_uuid) - .first() + session.query(SiteSQL).filter_by(client_uuid=auth.client_uuid, site_uuid=site_uuid).first() ) if site is None: raise HTTPException(status_code=404, detail="Site not found") diff --git a/tests/conftest.py b/tests/conftest.py index 85b5731..bb55e3d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -108,7 +108,6 @@ def sites(db_session, clients): @pytest.fixture() def inverters(db_session, sites): """Create some fake inverters for site 0""" - inverters = [] num_inverters = 3 inverters = [ InverterSQL(site_uuid=sites[0].site_uuid, client_id=f"id{j+1}") @@ -210,7 +209,7 @@ def forecast_values(db_session, sites): @pytest.fixture() -def client(db_session): +def client(db_session, clients): app.dependency_overrides[get_session] = lambda: db_session - app.dependency_overrides[auth] = lambda: None + app.dependency_overrides[auth] = lambda: clients[0] return TestClient(app) diff --git a/tests/test_auth.py b/tests/test_auth.py index bb72625..5cc98e4 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -10,6 +10,7 @@ from fastapi.testclient import TestClient from pv_site_api.auth import Auth +from pv_site_api.session import get_session # Use symetric algo for simplicity. ALGO = "HS256" @@ -37,10 +38,11 @@ def get_signing_key_from_jwt(self, token): @pytest.fixture -def trivial_client(auth): +def trivial_client(db_session, auth): """A client with only one restricted route.""" app = FastAPI() + app.dependency_overrides[get_session] = lambda: db_session # Add a route that depends on authorization. @app.get("/route", dependencies=[Depends(auth)]) @@ -54,7 +56,7 @@ def _make_header(token): return {"Authorization": f"Bearer {token}"} -def test_auth_happy_path(trivial_client): +def test_auth_happy_path(clients, trivial_client): token = jwt.encode( {"aud": API_AUDIENCE, "iss": f"https://{DOMAIN}/"}, SECRET, diff --git a/tests/test_enode.py b/tests/test_enode.py index 4e79199..7721d93 100644 --- a/tests/test_enode.py +++ b/tests/test_enode.py @@ -7,8 +7,8 @@ def test_get_enode_link_fake(client, fake): params = {"redirect_uri": "https://example.org"} response = client.get("/enode/link", params=params, follow_redirects=False) - assert response.status_code == 307 - assert len(response.headers["location"]) > 0 + assert response.status_code == 200 + assert len(response.json()) > 0 def test_get_enode_link(client, clients, httpx_mock): @@ -26,5 +26,5 @@ def test_get_enode_link(client, clients, httpx_mock): follow_redirects=False, ) - assert response.status_code == 307 - assert response.headers["location"] == test_enode_link_uri + assert response.status_code == 200 + assert response.json() == test_enode_link_uri diff --git a/tests/test_enode_auth.py b/tests/test_enode_auth.py index 93c5569..a13831b 100644 --- a/tests/test_enode_auth.py +++ b/tests/test_enode_auth.py @@ -10,7 +10,7 @@ CLIENT_ID = "ocf" CLIENT_SECRET = "secret" -enode_base_url = "https://enode.com/api" +test_enode_base_url = "https://enode.com/api" @pytest.fixture @@ -20,9 +20,9 @@ def enode_auth(): return enode_auth -def test_enode_auth(enode_auth): - request = httpx.Request("GET", f"{enode_base_url}/inverters") - gen = enode_auth.auth_flow(request) +def test_enode_auth_sync(enode_auth): + request = httpx.Request("GET", f"{test_enode_base_url}/inverters") + gen = enode_auth.sync_auth_flow(request) authenticated_request = next(gen) assert authenticated_request.headers["Authorization"] == "Bearer None" @@ -38,9 +38,9 @@ def test_enode_auth(enode_auth): assert authenticated_request.headers["Authorization"] == f"Bearer {test_access_token}" try: - gen.send(httpx.Response(200, json=["id1"])) - except StopIteration as e: - assert isinstance(e.value, httpx.Response) and e.value.json()[0] == "id1" + next(gen) + except StopIteration: + pass else: # The generator should exit assert False From 6329cb1dc090948872fcd8ac9e9919defd537191 Mon Sep 17 00:00:00 2001 From: AndrewLester Date: Fri, 28 Apr 2023 22:01:19 -0500 Subject: [PATCH 15/22] Use a pydantic model for client to serialize it in auth --- pv_site_api/_db_helpers.py | 17 ++++++++++++++++- pv_site_api/cache.py | 9 ++++++++- pv_site_api/main.py | 6 +++--- pv_site_api/pydantic_models.py | 7 +++++++ 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/pv_site_api/_db_helpers.py b/pv_site_api/_db_helpers.py index e6cfb2b..ad025e8 100644 --- a/pv_site_api/_db_helpers.py +++ b/pv_site_api/_db_helpers.py @@ -15,13 +15,20 @@ import structlog from fastapi import Depends from pvsite_datamodel.read.generation import get_pv_generation_by_sites -from pvsite_datamodel.sqlmodels import ForecastSQL, ForecastValueSQL, InverterSQL, SiteSQL +from pvsite_datamodel.sqlmodels import ( + ClientSQL, + ForecastSQL, + ForecastValueSQL, + InverterSQL, + SiteSQL, +) from sqlalchemy.orm import Session, aliased from .pydantic_models import ( Forecast, MultiplePVActual, PVActualValue, + PVClientMetadata, PVSiteMetadata, SiteForecastValues, ) @@ -230,6 +237,14 @@ def site_to_pydantic(site: SiteSQL) -> PVSiteMetadata: return pv_site +def client_to_pydantic(client: ClientSQL) -> PVClientMetadata: + """Converts a ClientSQL object into a PVClientMetadata object.""" + pv_client = PVClientMetadata( + client_uuid=str(client.client_uuid), client_name=client.client_name + ) + return pv_client + + def does_site_exist(session: Session, site_uuid: str) -> bool: """Checks if a site exists.""" return ( diff --git a/pv_site_api/cache.py b/pv_site_api/cache.py index fec8618..5ddc75c 100644 --- a/pv_site_api/cache.py +++ b/pv_site_api/cache.py @@ -6,6 +6,8 @@ import structlog +from ._db_helpers import client_to_pydantic + logger = structlog.stdlib.get_logger() CACHE_TIME_SECONDS = 120 @@ -39,10 +41,15 @@ def wrapper(*args, **kwargs): # noqa route_variables = kwargs.copy() # drop session and user - for var in ["session", "user", "auth"]: + for var in ["session", "user"]: if var in route_variables: route_variables.pop(var) + # translate authenticated client to serializable type + route_variables["auth"] = ( + route_variables["auth"] and client_to_pydantic(route_variables["auth"]).json() + ) + # make into string route_variables = json.dumps(route_variables) args_as_json = json.dumps(args) diff --git a/pv_site_api/main.py b/pv_site_api/main.py index acce48d..525cb76 100644 --- a/pv_site_api/main.py +++ b/pv_site_api/main.py @@ -280,7 +280,7 @@ def get_pv_actual( To test the route, you can input any number for the site_uuid (ex. 567) to generate a list of datetimes and actual kw generation for that site. """ - return (get_pv_actual_many_sites(site_uuids=site_uuid, session=session))[0] + return (get_pv_actual_many_sites(site_uuids=site_uuid, session=session, auth=auth))[0] @app.get("/sites/pv_actual", response_model=list[MultiplePVActual]) @@ -330,7 +330,7 @@ def get_pv_forecast( if not site_exists: raise HTTPException(status_code=404) - forecasts = get_pv_forecast_many_sites(site_uuids=site_uuid, session=session) + forecasts = get_pv_forecast_many_sites(site_uuids=site_uuid, session=session, auth=auth) if len(forecasts) == 0: return JSONResponse(status_code=204, content="no data") @@ -380,7 +380,7 @@ def get_pv_estimate_clearsky( if not site_exists: raise HTTPException(status_code=404) - clearsky_estimates = get_pv_estimate_clearsky_many_sites(site_uuid, session) + clearsky_estimates = get_pv_estimate_clearsky_many_sites(site_uuid, session, auth=auth) return clearsky_estimates[0] diff --git a/pv_site_api/pydantic_models.py b/pv_site_api/pydantic_models.py index e1125f2..e5c5589 100644 --- a/pv_site_api/pydantic_models.py +++ b/pv_site_api/pydantic_models.py @@ -41,6 +41,13 @@ class PVSiteMetadata(BaseModel): installed_capacity_kw: float = Field(..., description="The site's capacity in kw", ge=0) +class PVClientMetadata(BaseModel): + """Client metadata""" + + client_uuid: str = Field(..., description="Unique internal ID for client.") + client_name: str = Field(..., description="Name for the client.") + + # post_pv_actual # get_pv_actual_date # posting data too the database From 4a04fd53af6dff3b9817ba5d96e22f2cde68bc1b Mon Sep 17 00:00:00 2001 From: AndrewLester Date: Thu, 4 May 2023 11:22:11 -0500 Subject: [PATCH 16/22] Add sentry init back and fix tests --- pv_site_api/main.py | 14 ++++++++------ tests/test_sites.py | 11 +++++++---- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/pv_site_api/main.py b/pv_site_api/main.py index 2835c7b..a19c976 100644 --- a/pv_site_api/main.py +++ b/pv_site_api/main.py @@ -4,6 +4,7 @@ import httpx import pandas as pd +import sentry_sdk import structlog from dotenv import load_dotenv from fastapi import Depends, FastAPI, HTTPException @@ -82,11 +83,11 @@ def is_fake(): return int(os.environ.get("FAKE", 0)) -# sentry_sdk.init( -# dsn=os.getenv("SENTRY_DSN"), -# environment=os.getenv("ENVIRONMENT", "local"), -# traces_sampler=traces_sampler, -# ) +sentry_sdk.init( + dsn=os.getenv("SENTRY_DSN"), + environment=os.getenv("ENVIRONMENT", "local"), + traces_sampler=traces_sampler, +) app = FastAPI(docs_url="/swagger", redoc_url=None) @@ -224,7 +225,8 @@ def put_site_info( site.tilt = site_info.tilt site.latitude = site_info.latitude site.longitude = site_info.longitude - site.capacity_kw = site_info.installed_capacity_kw + site.inverter_capacity_kw = site_info.inverter_capacity_kw + site.module_capacity_kw = site_info.module_capacity_kw session.commit() return site diff --git a/tests/test_sites.py b/tests/test_sites.py index 9eb6a17..c89b3f8 100644 --- a/tests/test_sites.py +++ b/tests/test_sites.py @@ -60,7 +60,7 @@ def test_post_site(db_session, client, clients): latitude=50, longitude=0, inverter_capacity_kw=1, - module_capacity_kw=1.2, + module_capacity_kw=1.3, created_utc=datetime.now(timezone.utc).isoformat(), ) @@ -87,7 +87,8 @@ def test_update_site_fake(fake, sites, client, clients): tilt=90, latitude=50, longitude=0, - installed_capacity_kw=1, + inverter_capacity_kw=1, + module_capacity_kw=1.3, created_utc=datetime.now(timezone.utc).isoformat(), ) @@ -111,7 +112,8 @@ def test_post_site_and_update(db_session, client, clients): tilt=90, latitude=50, longitude=0, - installed_capacity_kw=1, + inverter_capacity_kw=1, + module_capacity_kw=1.3, created_utc=datetime.now(timezone.utc).isoformat(), ) @@ -149,7 +151,8 @@ def test_update_nonexistant_site(sites, client, clients): tilt=90, latitude=50, longitude=0, - installed_capacity_kw=1, + inverter_capacity_kw=1, + module_capacity_kw=1.3, created_utc=datetime.now(timezone.utc).isoformat(), ) From 381f8491fc16fa44df2df4a1976ad1b355896eef Mon Sep 17 00:00:00 2001 From: AndrewLester Date: Thu, 4 May 2023 11:25:28 -0500 Subject: [PATCH 17/22] Add logs for enode link and site inverters --- pv_site_api/_db_helpers.py | 6 +++++- pv_site_api/main.py | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/pv_site_api/_db_helpers.py b/pv_site_api/_db_helpers.py index 1f96934..b6452ab 100644 --- a/pv_site_api/_db_helpers.py +++ b/pv_site_api/_db_helpers.py @@ -262,4 +262,8 @@ def get_inverters_for_site( return None query = session.query(InverterSQL).filter(InverterSQL.site_uuid == site_uuid) - return query.all() + inverters = query.all() + + logger.info(f"Found {len(inverters)} inverters for site {site_uuid}") + + return inverters diff --git a/pv_site_api/main.py b/pv_site_api/main.py index a19c976..0bcd1ca 100644 --- a/pv_site_api/main.py +++ b/pv_site_api/main.py @@ -463,6 +463,8 @@ def get_enode_link( if is_fake(): return make_fake_enode_link_url() + logger.info(f"Getting Enode Link URL for {auth.client_uuid}") + with httpx.Client(base_url=enode_api_base_url, auth=enode_auth) as httpx_client: data = {"vendorType": "inverter", "redirectUri": redirect_uri} res = httpx_client.post(f"/users/{auth.client_uuid}/link", data=data).json() From 5d622a211c4c06aee0a2a968253f4614f879f3cc Mon Sep 17 00:00:00 2001 From: AndrewLester Date: Thu, 4 May 2023 12:30:43 -0500 Subject: [PATCH 18/22] Use httpx-auth to remove Enode auth code --- poetry.lock | 20 ++++++++++++++- pv_site_api/enode_auth.py | 53 --------------------------------------- pv_site_api/main.py | 10 ++++---- pv_site_api/utils.py | 3 +-- pyproject.toml | 1 + tests/conftest.py | 14 +++++++++++ tests/test_enode.py | 2 +- tests/test_enode_auth.py | 46 --------------------------------- tests/test_inverters.py | 6 ++--- 9 files changed, 44 insertions(+), 111 deletions(-) delete mode 100644 pv_site_api/enode_auth.py delete mode 100644 tests/test_enode_auth.py diff --git a/poetry.lock b/poetry.lock index 2d56edd..8710d92 100644 --- a/poetry.lock +++ b/poetry.lock @@ -747,6 +747,24 @@ cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (>=1.0.0,<2.0.0)"] +[[package]] +name = "httpx-auth" +version = "0.17.0" +description = "Authentication for HTTPX" +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx_auth-0.17.0-py3-none-any.whl", hash = "sha256:5358f2938f8843179dc681cea34626d3589b312bb021425f2cd4a4fbc316e92c"}, + {file = "httpx_auth-0.17.0.tar.gz", hash = "sha256:4e297113804ac3ee316d12a9596bc05e4dd592d2bf0809e5b4dab496d8a35b13"}, +] + +[package.dependencies] +httpx = ">=0.24.0,<0.25.0" + +[package.extras] +testing = ["pyjwt (>=2.0.0,<3.0.0)", "pytest-cov (>=4.0.0,<5.0.0)", "pytest-httpx (>=0.22.0,<0.23.0)"] + [[package]] name = "idna" version = "3.4" @@ -2171,4 +2189,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "10e0de9302cd41354256267e337c7355c47dd8a30123480d24e0a4766d35f967" +content-hash = "871a7e84a57d37e04219e9fb9388a29678bff03ddf5ddb8bb2ac8acee74b4f17" diff --git a/pv_site_api/enode_auth.py b/pv_site_api/enode_auth.py deleted file mode 100644 index 76aea97..0000000 --- a/pv_site_api/enode_auth.py +++ /dev/null @@ -1,53 +0,0 @@ -from typing import Optional - -import httpx - - -class EnodeAuth(httpx.Auth): - def __init__( - self, client_id: str, client_secret: str, token_url: str, access_token: Optional[str] = None - ): - self._client_id = client_id - self._client_secret = client_secret - self._token_url = token_url - self._access_token = access_token - - def sync_auth_flow(self, request: httpx.Request): - # Add the Authorization header to the request using the current access token - request.headers["Authorization"] = f"Bearer {self._access_token}" - response = yield request - - if response.status_code == 401: - # The access token is no longer valid, refresh it - token_response = yield self._build_refresh_request() - token_response.read() - self._update_access_token(token_response) - # Update the request's Authorization header with the new access token - request.headers["Authorization"] = f"Bearer {self._access_token}" - # Resend the request with the new access token - yield request - - async def async_auth_flow(self, request: httpx.Request): - # Add the Authorization header to the request using the current access token - request.headers["Authorization"] = f"Bearer {self._access_token}" - response = yield request - - if response.status_code == 401: - # The access token is no longer valid, refresh it - token_response = yield self._build_refresh_request() - await token_response.aread() - self._update_access_token(token_response) - # Update the request's Authorization header with the new access token - request.headers["Authorization"] = f"Bearer {self._access_token}" - # Resend the request with the new access token - yield request - - def _build_refresh_request(self): - basic_auth = httpx.BasicAuth(self._client_id, self._client_secret) - - data = {"grant_type": "client_credentials"} - request = next(basic_auth.auth_flow(httpx.Request("POST", self._token_url, data=data))) - return request - - def _update_access_token(self, response): - self._access_token = response.json()["access_token"] diff --git a/pv_site_api/main.py b/pv_site_api/main.py index 0bcd1ca..8d66d7a 100644 --- a/pv_site_api/main.py +++ b/pv_site_api/main.py @@ -3,6 +3,7 @@ from typing import Any import httpx +from httpx_auth import OAuth2ClientCredentials import pandas as pd import sentry_sdk import structlog @@ -30,7 +31,6 @@ ) from .auth import Auth from .cache import cache_response -from .enode_auth import EnodeAuth from .fake import ( fake_site_uuid, make_fake_enode_link_url, @@ -113,10 +113,10 @@ def is_fake(): algorithm=os.getenv("AUTH0_ALGORITHM"), ) -enode_auth = EnodeAuth( - os.getenv("ENODE_CLIENT_ID", ""), - os.getenv("ENODE_CLIENT_SECRET", ""), - os.getenv("ENODE_TOKEN_URL", "https://oauth.sandbox.enode.io/oauth2/token"), +enode_auth = OAuth2ClientCredentials( + token_url=os.getenv("ENODE_TOKEN_URL", "https://oauth.sandbox.enode.io/oauth2/token"), + client_id=os.getenv("ENODE_CLIENT_ID", ""), + client_secret=os.getenv("ENODE_CLIENT_SECRET", ""), ) enode_api_base_url = os.getenv("ENODE_API_BASE_URL", "https://enode-api.sandbox.enode.io") diff --git a/pv_site_api/utils.py b/pv_site_api/utils.py index 2b6f379..5f77301 100644 --- a/pv_site_api/utils.py +++ b/pv_site_api/utils.py @@ -7,14 +7,13 @@ import httpx -from .enode_auth import EnodeAuth from .pydantic_models import Inverters, InverterValues TOTAL_MINUTES_IN_ONE_DAY = 24 * 60 async def get_inverters_list( - client_uuid: uuid.UUID, inverter_ids: list[str], enode_auth: EnodeAuth, enode_api_base_url: str + client_uuid: uuid.UUID, inverter_ids: list[str], enode_auth: httpx.Auth, enode_api_base_url: str ) -> Inverters: async with httpx.AsyncClient(base_url=enode_api_base_url, auth=enode_auth) as httpx_client: headers = {"Enode-User-Id": str(client_uuid)} diff --git a/pyproject.toml b/pyproject.toml index c773402..51b617f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ sentry-sdk = "^1.16.0" pvlib = "^0.9.5" structlog = "^22.3.0" pyjwt = {extras = ["crypto"], version = "^2.6.0"} +httpx-auth = "^0.17.0" [tool.poetry.group.dev.dependencies] isort = "^5.12.0" diff --git a/tests/conftest.py b/tests/conftest.py index e6a7b71..b71e585 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ import os from datetime import datetime, timedelta +import pytest_httpx import freezegun import pytest from fastapi.testclient import TestClient @@ -23,6 +24,9 @@ from pv_site_api.session import get_session +enode_token_url = os.getenv("ENODE_TOKEN_URL", "https://oauth.sandbox.enode.io/oauth2/token") + + @pytest.fixture def non_mocked_hosts() -> list: """Prevent TestClient fixture from being mocked""" @@ -71,6 +75,16 @@ def db_session(engine): engine.dispose() +@pytest.fixture() +def mock_enode_auth(httpx_mock): + """Adds mocked response for Enode authentication""" + httpx_mock.add_response( + url=enode_token_url, + # Ensure token expires immediately so that every test must go through Enode auth + json={"access_token": "test.test", "expires_in": 1, "scope": "", "token_type": "bearer"}, + ) + + @pytest.fixture() def clients(db_session): """Make fake client sql""" diff --git a/tests/test_enode.py b/tests/test_enode.py index 7721d93..d49c63e 100644 --- a/tests/test_enode.py +++ b/tests/test_enode.py @@ -11,7 +11,7 @@ def test_get_enode_link_fake(client, fake): assert len(response.json()) > 0 -def test_get_enode_link(client, clients, httpx_mock): +def test_get_enode_link(client, clients, httpx_mock, mock_enode_auth): test_enode_link_uri = "https://example.com" httpx_mock.add_response( diff --git a/tests/test_enode_auth.py b/tests/test_enode_auth.py deleted file mode 100644 index a13831b..0000000 --- a/tests/test_enode_auth.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -Test the Enode authentication HTTPX auth class. -""" -import httpx -import pytest - -from pv_site_api.enode_auth import EnodeAuth - -TOKEN_URL = "https://example.com/token" -CLIENT_ID = "ocf" -CLIENT_SECRET = "secret" - -test_enode_base_url = "https://enode.com/api" - - -@pytest.fixture -def enode_auth(): - """An Enode Auth object""" - enode_auth = EnodeAuth(token_url=TOKEN_URL, client_id=CLIENT_ID, client_secret=CLIENT_SECRET) - return enode_auth - - -def test_enode_auth_sync(enode_auth): - request = httpx.Request("GET", f"{test_enode_base_url}/inverters") - gen = enode_auth.sync_auth_flow(request) - authenticated_request = next(gen) - assert authenticated_request.headers["Authorization"] == "Bearer None" - - refresh_request = gen.send(httpx.Response(401)) - assert ( - refresh_request.method == "POST" - and refresh_request.url == httpx.URL(TOKEN_URL) - and refresh_request.content == b"grant_type=client_credentials" - ) - - test_access_token = "test_access_token" - authenticated_request = gen.send(httpx.Response(200, json={"access_token": test_access_token})) - assert authenticated_request.headers["Authorization"] == f"Bearer {test_access_token}" - - try: - next(gen) - except StopIteration: - pass - else: - # The generator should exit - assert False diff --git a/tests/test_inverters.py b/tests/test_inverters.py index 85d3496..cab9b73 100644 --- a/tests/test_inverters.py +++ b/tests/test_inverters.py @@ -14,7 +14,7 @@ def test_put_inverters_for_site_fake(client, sites, fake): assert response.status_code == 200 -def test_put_inverters_for_site(client, sites, httpx_mock): +def test_put_inverters_for_site(client, sites, httpx_mock, mock_enode_auth): test_inverter_client_id = "6c078ca2-2e75-40c8-9a7f-288bd0b70065" json = [test_inverter_client_id] response = client.put(f"/sites/{sites[0].site_uuid}/inverters", json=json) @@ -40,7 +40,7 @@ def test_get_inverters_for_site_fake(client, sites, inverters, fake): assert response.status_code == 200 -def test_get_inverters_for_site(client, sites, inverters, httpx_mock): +def test_get_inverters_for_site(client, sites, inverters, httpx_mock, mock_enode_auth): mock_inverter_response("id1", httpx_mock) mock_inverter_response("id2", httpx_mock) mock_inverter_response("id3", httpx_mock) @@ -66,7 +66,7 @@ def test_get_enode_inverters_fake(client, fake): assert len(response_inverters.inverters) > 0 -def test_get_enode_inverters(client, httpx_mock, clients): +def test_get_enode_inverters(client, httpx_mock, clients, mock_enode_auth): httpx_mock.add_response(url=f"{enode_api_base_url}/inverters", json=["id1"]) mock_inverter_response("id1", httpx_mock) From e185d933c079d34254d511b86b0417bf06dc63d6 Mon Sep 17 00:00:00 2001 From: AndrewLester Date: Thu, 4 May 2023 12:31:05 -0500 Subject: [PATCH 19/22] Fix format --- pv_site_api/main.py | 2 +- tests/conftest.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/pv_site_api/main.py b/pv_site_api/main.py index 8d66d7a..cb689c0 100644 --- a/pv_site_api/main.py +++ b/pv_site_api/main.py @@ -3,7 +3,6 @@ from typing import Any import httpx -from httpx_auth import OAuth2ClientCredentials import pandas as pd import sentry_sdk import structlog @@ -12,6 +11,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.utils import get_openapi from fastapi.responses import FileResponse, JSONResponse +from httpx_auth import OAuth2ClientCredentials from pvlib import irradiance, location, pvsystem from pvsite_datamodel.read.site import get_all_sites from pvsite_datamodel.read.status import get_latest_status diff --git a/tests/conftest.py b/tests/conftest.py index b71e585..b4d3dc3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,6 @@ import os from datetime import datetime, timedelta -import pytest_httpx import freezegun import pytest from fastapi.testclient import TestClient @@ -23,7 +22,6 @@ from pv_site_api.main import app, auth from pv_site_api.session import get_session - enode_token_url = os.getenv("ENODE_TOKEN_URL", "https://oauth.sandbox.enode.io/oauth2/token") From 246724dc220c9a444e2e79345c4c32fda7cfdf48 Mon Sep 17 00:00:00 2001 From: AndrewLester Date: Tue, 16 May 2023 16:02:16 -0400 Subject: [PATCH 20/22] Add test default values for Enode auth --- pv_site_api/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pv_site_api/main.py b/pv_site_api/main.py index cb689c0..c484dd1 100644 --- a/pv_site_api/main.py +++ b/pv_site_api/main.py @@ -115,8 +115,8 @@ def is_fake(): enode_auth = OAuth2ClientCredentials( token_url=os.getenv("ENODE_TOKEN_URL", "https://oauth.sandbox.enode.io/oauth2/token"), - client_id=os.getenv("ENODE_CLIENT_ID", ""), - client_secret=os.getenv("ENODE_CLIENT_SECRET", ""), + client_id=os.getenv("ENODE_CLIENT_ID", "test_id"), + client_secret=os.getenv("ENODE_CLIENT_SECRET", "test_secret"), ) enode_api_base_url = os.getenv("ENODE_API_BASE_URL", "https://enode-api.sandbox.enode.io") From 7e80ee43254ea53e4e65c839900338e0d5a86d28 Mon Sep 17 00:00:00 2001 From: AndrewLester Date: Thu, 18 May 2023 16:57:37 -0400 Subject: [PATCH 21/22] Handle case where no Enode user exists --- pv_site_api/main.py | 8 ++++++-- tests/test_inverters.py | 12 ++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/pv_site_api/main.py b/pv_site_api/main.py index c484dd1..cf8327c 100644 --- a/pv_site_api/main.py +++ b/pv_site_api/main.py @@ -47,6 +47,7 @@ PVSiteAPIStatus, PVSiteMetadata, PVSites, + Inverters, ) from .redoc_theme import get_redoc_html_with_theme from .session import get_session @@ -482,8 +483,11 @@ async def get_inverters( async with httpx.AsyncClient(base_url=enode_api_base_url, auth=enode_auth) as httpx_client: headers = {"Enode-User-Id": str(auth.client_uuid)} - response_json = (await httpx_client.get("/inverters", headers=headers)).json() - inverter_ids = [str(inverter_id) for inverter_id in response_json] + response = await httpx_client.get("/inverters", headers=headers) + if response.status_code == 401: + return Inverters(inverters=[]) + + inverter_ids = [str(inverter_id) for inverter_id in response.json()] return await get_inverters_list(auth.client_uuid, inverter_ids, enode_auth, enode_api_base_url) diff --git a/tests/test_inverters.py b/tests/test_inverters.py index cab9b73..c1201f0 100644 --- a/tests/test_inverters.py +++ b/tests/test_inverters.py @@ -77,6 +77,18 @@ def test_get_enode_inverters(client, httpx_mock, clients, mock_enode_auth): assert len(inverters.inverters) > 0 +def test_get_enode_inverters_for_nonexistant_user(client, httpx_mock, clients, mock_enode_auth): + httpx_mock.add_response( + url=f"{enode_api_base_url}/inverters", status_code=401, json={"error": "err"} + ) + + response = client.get("/enode/inverters") + assert response.status_code == 200 + + inverters = Inverters(**response.json()) + assert len(inverters.inverters) == 0 + + def mock_inverter_response(id, httpx_mock): httpx_mock.add_response( url=f"{enode_api_base_url}/inverters/{id}", From 819f55f316c030163cd2a9f318e09aa410b360ed Mon Sep 17 00:00:00 2001 From: AndrewLester Date: Thu, 18 May 2023 16:57:55 -0400 Subject: [PATCH 22/22] Fix import order --- pv_site_api/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pv_site_api/main.py b/pv_site_api/main.py index cf8327c..780ffe5 100644 --- a/pv_site_api/main.py +++ b/pv_site_api/main.py @@ -43,11 +43,11 @@ from .pydantic_models import ( ClearskyEstimate, Forecast, + Inverters, MultiplePVActual, PVSiteAPIStatus, PVSiteMetadata, PVSites, - Inverters, ) from .redoc_theme import get_redoc_html_with_theme from .session import get_session