diff --git a/poetry.lock b/poetry.lock index 2c66296..8710d92 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,28 +725,46 @@ 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)"] +[[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" @@ -1360,6 +1378,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 +1535,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 +2189,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "1d56151d22038e6453e5ae6c48cf5b4c711628ebc6d6700871f2059c74183dbf" +content-hash = "871a7e84a57d37e04219e9fb9388a29678bff03ddf5ddb8bb2ac8acee74b4f17" diff --git a/pv_site_api/_db_helpers.py b/pv_site_api/_db_helpers.py index fd62a78..b6452ab 100644 --- a/pv_site_api/_db_helpers.py +++ b/pv_site_api/_db_helpers.py @@ -13,17 +13,26 @@ 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, 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, ) +from .session import get_session logger = structlog.stdlib.get_logger() @@ -229,9 +238,32 @@ 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 ( 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) + inverters = query.all() + + logger.info(f"Found {len(inverters)} inverters for site {site_uuid}") + + return inverters 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..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 @@ -43,6 +45,11 @@ def wrapper(*args, **kwargs): # noqa 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/fake.py b/pv_site_api/fake.py index 92fefcb..690ae47 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( @@ -87,3 +121,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 2da6677..780ffe5 100644 --- a/pv_site_api/main.py +++ b/pv_site_api/main.py @@ -1,6 +1,8 @@ """Main API Routes""" import os +from typing import Any +import httpx import pandas as pd import sentry_sdk import structlog @@ -9,10 +11,11 @@ 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 -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 @@ -22,6 +25,7 @@ does_site_exist, get_forecasts_by_sites, get_generation_by_sites, + get_inverters_for_site, get_sites_by_uuids, site_to_pydantic, ) @@ -29,7 +33,9 @@ from .cache import cache_response from .fake import ( fake_site_uuid, + make_fake_enode_link_url, make_fake_forecast, + make_fake_inverters, make_fake_pv_generation, make_fake_site, make_fake_status, @@ -37,6 +43,7 @@ from .pydantic_models import ( ClearskyEstimate, Forecast, + Inverters, MultiplePVActual, PVSiteAPIStatus, PVSiteMetadata, @@ -44,7 +51,7 @@ ) 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() @@ -107,6 +114,14 @@ def is_fake(): algorithm=os.getenv("AUTH0_ALGORITHM"), ) +enode_auth = OAuth2ClientCredentials( + token_url=os.getenv("ENODE_TOKEN_URL", "https://oauth.sandbox.enode.io/oauth2/token"), + 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") + # name the api # test that the routes are there on swagger # Following on from #1 now will be good to set out models @@ -120,7 +135,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. @@ -148,7 +163,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. @@ -180,28 +195,49 @@ 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_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: Any = Depends(auth), +): + """ + ### This route allows a user to update a site's information. + + """ + + if is_fake(): + print(f"Fake: would update site {site_uuid} with {site_info.dict()}") + return + + site = ( + 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") + + 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.inverter_capacity_kw = site_info.inverter_capacity_kw + site.module_capacity_kw = site_info.module_capacity_kw + + session.commit() + return site @app.post("/sites") 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. @@ -213,12 +249,8 @@ def post_site_info( print("Not doing anything with it (yet!)") return - # client uuid from name - 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, @@ -243,7 +275,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. @@ -252,7 +284,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]) @@ -260,7 +292,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. @@ -280,7 +312,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. @@ -302,7 +334,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") @@ -315,12 +347,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(): @@ -343,7 +374,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 @@ -353,7 +384,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] @@ -361,6 +392,7 @@ def get_pv_estimate_clearsky( def get_pv_estimate_clearsky_many_sites( site_uuids: str, session: Session = Depends(get_session), + auth: Any = Depends(auth), ): """ ### Gets a estimate of AC production under a clear sky for multiple sites. @@ -421,6 +453,91 @@ def get_pv_estimate_clearsky_many_sites( return res +@app.get("/enode/link") +def get_enode_link( + redirect_uri: str, + auth: Any = Depends(auth), +): + """ + ### Returns a URL from Enode that starts a user's Enode link flow. + """ + 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() + + return res["linkUrl"] + + +@app.get("/enode/inverters") +async def get_inverters( + session: Session = Depends(get_session), + auth: Any = Depends(auth), +): + if is_fake(): + return make_fake_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 = 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) + + +@app.get("/sites/{site_uuid}/inverters") +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() + + if inverters is None: + raise HTTPException(status_code=404) + + inverter_ids = [inverter.client_id for inverter in inverters] + + return await get_inverters_list(auth.client_uuid, inverter_ids, enode_auth, enode_api_base_url) + + +@app.put("/sites/{site_uuid}/inverters") +def put_inverters_for_site( + site_uuid: str, + client_ids: list[str], + session: Session = Depends(get_session), + 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 + + site = ( + 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") + + 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 c55b158..64d5230 100644 --- a/pv_site_api/pydantic_models.py +++ b/pv_site_api/pydantic_models.py @@ -44,6 +44,13 @@ class PVSiteMetadata(BaseModel): ) +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 @@ -112,3 +119,71 @@ 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: 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: Optional[float] = Field( + ..., description="The total lifetime production in kWh" + ) + lastUpdated: Optional[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.", + ) + installationDate: str = Field(..., description="Solar inverter installation date") + + +class InverterLocation(BaseModel): + """ "Longitude and Latitude of inverter""" + + longitude: Optional[float] = Field(..., description="Longitude in degrees") + latitude: Optional[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: Optional[str] = Field( + ..., + 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", + ) + 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") diff --git a/pv_site_api/utils.py b/pv_site_api/utils.py index 149d46c..5f77301 100644 --- a/pv_site_api/utils.py +++ b/pv_site_api/utils.py @@ -1,14 +1,33 @@ """ make fake intensity""" +import asyncio import math +import uuid from datetime import datetime, timedelta, timezone from typing import List +import httpx + +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: 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)} + inverters_raw = await asyncio.gather( + *[httpx_client.get(f"/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 + Make a fake intensity value based on the time of the day :param datetime_utc: :return: intensity, between 0 and 1 diff --git a/pyproject.toml b/pyproject.toml index 9435723..51b617f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,11 +14,12 @@ psycopg2-binary = "^2.9.5" sqlalchemy = "^1.4.46" pvsite-datamodel = "^0.1.34" 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" pyjwt = {extras = ["crypto"], version = "^2.6.0"} +httpx-auth = "^0.17.0" [tool.poetry.group.dev.dependencies] isort = "^5.12.0" @@ -29,6 +30,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 6c6b273..b4d3dc3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,7 @@ ForecastSQL, ForecastValueSQL, GenerationSQL, + InverterSQL, SiteSQL, StatusSQL, ) @@ -21,6 +22,14 @@ 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") + + +@pytest.fixture +def non_mocked_hosts() -> list: + """Prevent TestClient fixture from being mocked""" + return ["testserver"] + @pytest.fixture def _now(autouse=True): @@ -64,6 +73,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""" @@ -99,6 +118,21 @@ def sites(db_session, clients): return sites +@pytest.fixture() +def inverters(db_session, sites): + """Create some fake inverters for site 0""" + num_inverters = 3 + 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() + + return inverters + + @pytest.fixture() def generations(db_session, sites): """Create some fake generations""" @@ -188,7 +222,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 new file mode 100644 index 0000000..d49c63e --- /dev/null +++ b/tests/test_enode.py @@ -0,0 +1,30 @@ +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) + + assert response.status_code == 200 + assert len(response.json()) > 0 + + +def test_get_enode_link(client, clients, httpx_mock, mock_enode_auth): + test_enode_link_uri = "https://example.com" + + httpx_mock.add_response( + url=f"{enode_api_base_url}/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 == 200 + assert response.json() == test_enode_link_uri diff --git a/tests/test_inverters.py b/tests/test_inverters.py new file mode 100644 index 0000000..c1201f0 --- /dev/null +++ b/tests/test_inverters.py @@ -0,0 +1,116 @@ +""" 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 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.put(f"/sites/{sites[0].site_uuid}/inverters", json=json) + + assert response.status_code == 200 + + +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) + 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_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.put(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_enode_auth): + 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_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 + + response_inverters = Inverters(**response.json()) + assert len(response_inverters.inverters) > 0 + + +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) + + response = client.get("/enode/inverters") + assert response.status_code == 200 + + inverters = Inverters(**response.json()) + 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}", + json={ + "id": id, + "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}, + }, + ) diff --git a/tests/test_sites.py b/tests/test_sites.py index c0a3145..c89b3f8 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", @@ -46,10 +46,10 @@ 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", + client_name="test_client_1", client_site_id=1, client_site_name="the site name", region="the site's region", @@ -60,7 +60,7 @@ def test_put_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(), ) @@ -74,37 +74,90 @@ 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_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, + inverter_capacity_kw=1, + module_capacity_kw=1.3, + 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", + 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, + inverter_capacity_kw=1, + module_capacity_kw=1.3, + 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 + + +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, + inverter_capacity_kw=1, + module_capacity_kw=1.3, + 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