V1 Sites (GB) β implementation spec
What this is
Bring GB site-level forecasting into the v1 API, replacing pv-site-api (api-site.quartz.solar), which is deprecated. Sites become a first-class noun under the existing v1 prefix, backed by the Data Platform through StorageInterface like everything else in v1.
Design for India as well as GB. The same scaffold will serve Indian sites on its own separate deployment after this ships, so:
country and source are path parameters. Nothing should hardcode GB or solar, including helpers, cache keys and config lookups.
source will include wind. Anything keyed on energy type needs to work for both, even though only solar is reachable today.
- Site forecasts are 15-minute resolution; region forecasts are either 15 or 30-minute. Never assume a fixed step β always read the timestamps.
- Per-country values (forecast model, generation observer) come from config, not constants. This is simple right now but will be in place for future multi-model site forecasting.
Anything that would need rewriting to add a second country is worth raising before building it.
Conventions
These are the v1 conventions and are not negotiable, because clients read every route the same way.
|
Rule |
Example |
| Field naming |
snake_case |
client_site_name |
| Power / energy |
kW, capital W in the suffix |
power_kW, capacity_kW |
| Timestamps |
_utc suffix, timezone-aware ISO 8601 |
time_utc, last_updated_utc |
| Identity |
UUID |
site_id |
| Shared metadata |
hoisted onto the response wrapper, never repeated per value |
capacity_kW at the top |
| List responses |
always an object, never a bare array |
{"sites": [...]} |
Copy field names from service/v1/endpoint_types.py, which is the source of truth for the region models the site models sit beside.
Routes
# Site CRUD
GET /v1/{country}/{source}/sites β SiteList
POST /v1/{country}/{source}/sites β SiteDetail (201)
GET /v1/{country}/{source}/sites/{site_id} β SiteDetail
PUT /v1/{country}/{source}/sites/{site_id} β SiteDetail
# Per-site time series
GET /v1/{country}/{source}/sites/{site_id}/forecast β SiteForecastResponse
GET /v1/{country}/{source}/sites/{site_id}/generation β SiteGenerationResponse
POST /v1/{country}/{source}/sites/{site_id}/generation β 202
GET /v1/{country}/{source}/sites/{site_id}/clearsky β SiteClearskyResponse
# Multi-site
GET /v1/{country}/{source}/sites/forecasts/period β SiteForecastMatrix
GET /v1/{country}/{source}/sites/generation/period β SiteGenerationMatrix
GET /v1/{country}/{source}/sites/forecasts/snapshot β SiteForecastSnapshot
GET /v1/{country}/{source}/sites/generation/snapshot β SiteGenerationSnapshot
GET /v1/{country}/{source}/sites/clearsky β SiteClearskyMatrix
Out of scope for this piece: DELETE /sites/{site_id} and the aggregate=total|dno|gsp parameter. Both are specified separately, we'll circle back to these when needed and/or confirmed on DP side.
Response shapes
SiteDetail / SiteList
status is one of "active", "inactive", "commissioning" β a string, so new values can be added later without a schema change. A site with no stored status reads as "active". Only "active" sites are forecast.
The /sites response is enveloped deliberately in case we need to add e.g. pagination later with a next_cursor, without changing the overall response shape.
POST and PUT take a SiteInput body β the same fields minus site_id, capacity_kW and metadata. PUT is a partial update: absent fields are left unchanged.
Time series
Site forecasts carry no probabilistic levels; omit plevels_kW entirely rather than returning an empty object. If this changes in future, we can easily add to this response shape.
POST /sites/{site_id}/generation takes a flat list and returns 202 with no body:
Multi-site forecasts ("2D", forecast over period, up to 10* sites)
One shared times_utc array with parallel value arrays per site β no per-row timestamps.
Snapshots (one timestamp, many sites)
Default timestamp is now, floored to 30 minutes.
Behaviour
Parameters
Every parameter on every route. Nothing accepts a parameter not listed here β an unknown one should 422 rather than be ignored, so a typo surfaces instead of silently returning the default.
| Route |
Parameters |
Default window |
GET /sites |
status, name, min_latitude, max_latitude, min_longitude, max_longitude |
β |
POST /sites |
body only (SiteInput) |
β |
GET /sites/{site_id} |
β |
β |
PUT /sites/{site_id} |
body only (SiteInput, partial) |
β |
GET /sites/{site_id}/forecast |
start_utc, end_utc, horizon_minutes, creation_limit_utc |
now β +48h |
GET /sites/{site_id}/generation |
start_utc, end_utc |
last 24h |
POST /sites/{site_id}/generation |
body only |
β |
GET /sites/{site_id}/clearsky |
start_utc, end_utc |
now β +48h |
GET /sites/forecasts/period |
site_ids, start_utc, end_utc, horizon_minutes, creation_limit_utc |
now floored to 6h, Β±2 days |
GET /sites/generation/period |
site_ids, start_utc, end_utc |
as above |
GET /sites/clearsky |
site_ids, start_utc, end_utc |
as above |
GET /sites/forecasts/snapshot |
time_utc |
now, floored to 30 min |
GET /sites/generation/snapshot |
time_utc |
now, floored to 30 min |
Notes:
horizon_minutes filters to a single forecast horizon β 60 returns only the 1-hour-ahead value for each target timestep. Same name and meaning as the region forecast routes. Not available on the snapshot routes, which are already a single point in time.
status filters the site list by lifecycle state. The platform has no metadata filter, so this is applied in the API after fetching.
name is a case-insensitive substring match on client_site_name, matching the behaviour of ?name= on GET /regions.
- The bounding box is four separate typed floats. pv-site-api took
latitude_longitude_min / max as comma-joined strings parsed by hand; four floats let FastAPI validate them. All four are required together or none β a partial box is a 400.
- No
model_name or observer parameters, deliberately. Both are resolved from config and reported in the response. See Forecast model selection and Generation observer below.
creation_limit_utc returns the forecast as it was known at a point in time, by excluding runs created after it β the same name and meaning as on the region forecast route. It matters more for sites than for regions, since site customers schedule and trade against a specific forecast and later need to show what it said at the time, per site. created_cutoff is already a parameter on get_predicted_generation in StorageInterface, so this is a query param wired to an argument on a call the route makes anyway.
History is available up to one year back; earlier start_utc is a 400.
The multi-site cap
/sites/forecasts/period, /sites/generation/period and /sites/clearsky serve at most 10 sites per request
* MAX_SITES_PER_PERIOD_REQUEST β a named constant, not a literal, so it can move to config, and not 100% what the best number is yet for the DP so we'll have to test this.
site_ids is an optional repeated query param:
| Request |
Behaviour |
| omitted, caller has β€ 10 sites |
return all of them |
| omitted, caller has > 10 sites |
400, naming the cap and how to fix it |
| given, β€ 10 entries |
return those sites |
| given, > 10 entries |
422 |
Never return an arbitrary subset. Truncating to "the first 10" is silently wrong twice: the caller cannot tell data is missing, and the platform guarantees no ordering, so "the first 10" need not be the same ten between two identical requests β a dashboard would show a different slice of a portfolio on each refresh with nothing to explain it. Make the 400 self-service:
You have 14 sites; this endpoint returns at most 10 per request. Pass site_ids to choose which, e.g. ?site_ids=<uuid>&site_ids=<uuid>. Site UUIDs come from GET /sites.
These endpoints fan out one platform call per site, so bound the concurrency of the fan-out independently of the request cap β ten sites each from twenty callers is still two hundred concurrent calls.
The snapshot routes are a single platform call for the whole set of user's sites and are not capped.
Ordering
The platform gives no ordering guarantee. Impose a deterministic order on every list response β client_site_name, tie-broken on site_id. Without it, a cached list reshuffles between refreshes for no visible reason, and pagination could never be added.
Caching
Site data is per-company, so a cached response must never be served to a different company. The existing v1 cache key builder deliberately shares entries between users whose access is equivalent, which is correct for public region data and wrong here. Treat this as the highest-risk part of the work.
Suggested TTLs, to tune: site list and per-site series and snapshots 60s. The period endpoints are live platform reads and should be cached briefly or not at all.
Forecast model selection
Per-site, in this order:
metadata["forecast_name"] on the site, if set β a per-site override stored in the platform.
- The country/source default from
country_config.py.
Always report the resolved model as model_name in the response. Do not add a model_name request parameter yet β GB runs one site model, and reporting it now means the parameter can be added later without changing the response.
Generation observer
Site generation reads and writes use the observer name from config (site_api for GB today). Report it as observer_name. Observer names are validated ^[a-z0-9_]+$ by the platform.
Clearsky
Pure pvlib compute from latitude, longitude, tilt, orientation and module capacity β no platform call. pvlib is a new dependency; add it to pyproject.toml.
If a site is missing module_capacity_kW or inverter_capacity_kW, return a 400 naming the missing field. Do not infer one from the other.
Errors
| Status |
When |
| 400 |
unusable but well-formed request β window out of range, over the site cap, partial bounding box, clearsky without capacities |
| 401 |
missing or invalid token, from the auth layer |
| 403 |
the caller has no company β they can never see any site |
| 404 |
the site does not exist, or belongs to another company |
| 422 |
schema validation, from FastAPI |
| 502 |
platform gRPC failure, via the existing handler in cmd/main.py |
403 and 404 divide on whether the answer depends on the resource.
A caller whose token carries no hubspot_company_id and is not ocf:admin can never see any site, on any route. That is a property of the caller, so it is a 403 on every site route including the list ones β not an empty list. An empty list says "you have no sites", which is a different and probably false statement, and it sends someone to the wrong place to debug it. Make the message actionable: the account is not linked to a company, and support can link it.
Once a caller does have a company, every failure to find a site is a 404, whether the UUID belongs to another company or to nothing at all. A 403 there would confirm the site exists, which leaks the existence of another company's sites to anyone who can guess or obtain a UUID. The two cases must be indistinguishable in status code, body and timing.
Decide this once in the scoping layer rather than per route. The storage layer is not consistent about it today β depending on the call it returns an empty list, a 404, or a 403 for the same no-company caller β so a route that leans on whatever the backend happens to do will not match this table.
Platform constraints
Facts about the Data Platform that shape the contract. They explain requirements that look arbitrary otherwise.
location_name is validated ^[a-z0-9_|]+$, 2β100 characters. Customer-supplied names cannot be stored there, which is why client_site_name lives in the location's metadata and why sites are addressed by UUID and never by name.
- A location carries one capacity field,
effective_capacity_watts, which maps to capacity_kW. inverter_capacity_kW, module_capacity_kW, client_site_id, tilt, orientation and status are all stored in the free-form metadata struct. Metadata is flat and its values are strings, ints or floats.
- No pagination on any platform RPC.
ListLocations returns everything matching the filter in one message. This is why GET /sites is unpaginated for now.
- No metadata filter on
ListLocations. Any filtering on status happens in the API after fetching.
- No
DeleteLocation RPC. Relevant only to the deferred DELETE route; nothing here should assume a location can be removed.
- Updating a location replaces its metadata wholesale. A partial update has to merge against what is already stored, or unrelated fields are lost.
enclosed_location_uuid_filter answers "which GSP/DNO encloses this site", if site-to-region linkage is needed.
Authorization
Sites are owned. Every site route is scoped to the caller's company, and getting this wrong means one customer reading another's portfolio β the most serious failure available in this work compared to the /regions section of the v1 API.
What exists already:
- The JWT carries
app_metadata.hubspot_company_id.
get_org_id_from_authdata() in internal/middleware/auth.py turns the claims into an organisation identifier, returning None for ocf:admin (all companies) and a sentinel when the caller has no company at all.
- The platform filters server-side on
organisation_id_filter.
- The existing India sites router (
internal/service/sites/router.py) uses this path today and is a working reference.
No new Auth0 permission is required. Company membership alone grants access to that company's sites; there is no country-permission check on site routes. We might add a site-specific role later, but this will be easy to insert an extra check before the ownership logic.
Two hazards worth knowing before you start, both of which fail silently rather than loudly:
- The region routes pass an empty auth dict to the storage layer, which for those routes means "no filter" β correct, since region data is public. The same value on a site query also means no filter, so copying a region route as the basis for a site route produces an endpoint that returns every company's sites and raises no error.
- Cached site responses can cross company boundaries if the cache key does not distinguish callers. See Caching above.
Do not use pv-site-api as a reference for authorization. It has a bug, which is known, tracked separately, and deliberately not being fixed given that API is being retired. It is mentioned only so the pattern is not carried across.
Migration from pv-site-api
| pv-site-api |
v1 |
Notes |
GET /sites |
GET /v1/GB/solar/sites |
latitude_longitude_min/max comma-strings become typed float params |
POST /sites |
POST /v1/GB/solar/sites |
PVSiteInputMetadata β SiteInput |
PUT /sites/{uuid} |
PUT /v1/GB/solar/sites/{site_id} |
|
GET /sites/{uuid}/pv_actual |
GET /v1/GB/solar/sites/{site_id}/generation |
gains start_utc / end_utc |
POST /sites/{uuid}/pv_actual |
POST /v1/GB/solar/sites/{site_id}/generation |
body flattens from MultiplePVActual to a list |
GET /sites/pv_actual?site_uuids=β¦ |
GET /v1/GB/solar/sites/generation/period?site_ids=β¦ |
|
GET /sites/{uuid}/pv_forecast |
GET /v1/GB/solar/sites/{site_id}/forecast |
|
GET /sites/pv_forecast?site_uuids=β¦ |
GET /v1/GB/solar/sites/forecasts/period?site_ids=β¦ |
horizon_minutes keeps its name |
GET /sites/{uuid}/clearsky_estimate |
GET /v1/GB/solar/sites/{site_id}/clearsky |
|
GET /sites/clearsky_estimate?site_uuids=β¦ |
GET /v1/GB/solar/sites/clearsky?site_ids=β¦ |
the v0 route has no auth dependency; the v1 one does |
GET /api_status |
β |
not ported |
Deliberate omissions:
compact β the columnar matrix is the only multi-site format, and it is already compact.
sum_by β see aggregate, specified separately.
- The
/delete/ path segment in DELETE /sites/delete/{uuid}.
Breaking changes for clients (but this is a "new" API, so this is fine!)
snake_case throughout; kw β kW
expected_generation_kw / actual_generation_kw β power_kW
target_datetime_utc / datetime_utc β time_utc
- UUIDs are
UUID-typed, not strings
- country and source now in the path, so every URL changes
- 15-minute site data where a client may have assumed 30
What must be provably true
These are testable things we'll want to ensure are the case before we deploy, and there are def more than listed here!
Tenancy (the ones that matter most):
- A caller whose token carries no company claim gets a 403 from every site route, never an empty list.
- A caller requesting another company's site by UUID cannot distinguish the response from one for a UUID that does not exist anywhere.
- Two callers from different companies making the identical request never receive each other's data, including on a second request when the first was cached.
- An admin's response is never served to a non-admin from cache, and the reverse.
- No site route can reach the storage layer unscoped β including a route added later by someone who has not read this document.
Behaviour:
- Over-cap requests return 400 and never a truncated list.
- List responses are in the same order for the same input.
- A partial
PUT leaves unmentioned fields, including metadata, unchanged.
- Posting generation and reading it back returns the values that were sent.
- A site with no stored status behaves as
"active".
Reference
|
|
| v1 region routes |
src/quartz_api/internal/service/v1/routes/ |
| v1 response models and param types |
src/quartz_api/internal/service/v1/endpoint_types.py |
| Country / model / observer config |
src/quartz_api/internal/service/v1/country_config.py |
| Cache key builder and pre-warming |
src/quartz_api/internal/service/v1/cache.py |
| Storage interface |
src/quartz_api/internal/models/db_interface.py |
| Data Platform client |
src/quartz_api/internal/backends/dataplatform/client.py |
| Auth claims handling |
src/quartz_api/internal/middleware/auth.py |
| Working tenant-scoped example |
src/quartz_api/internal/service/sites/router.py |
| Tests and fixtures |
src/quartz_api/internal/service/v1/test_router.py |
uv run quartz-api # /v1/docs for the rendered schema
pytest src/ --ignore=src/quartz_api/internal/backends/quartzdb/ \
--ignore=src/quartz_api/tests/integration/
ruff check src/
Anything here that seems wrong, or that would need rewriting to add a second country, is worth raising before building around it.
V1 Sites (GB) β implementation spec
What this is
Bring GB site-level forecasting into the v1 API, replacing
pv-site-api(api-site.quartz.solar), which is deprecated. Sites become a first-class noun under the existing v1 prefix, backed by the Data Platform throughStorageInterfacelike everything else in v1.Design for India as well as GB. The same scaffold will serve Indian sites on its own separate deployment after this ships, so:
countryandsourceare path parameters. Nothing should hardcodeGBorsolar, including helpers, cache keys and config lookups.sourcewill includewind. Anything keyed on energy type needs to work for both, even though onlysolaris reachable today.Anything that would need rewriting to add a second country is worth raising before building it.
Conventions
These are the v1 conventions and are not negotiable, because clients read every route the same way.
snake_caseclient_site_namepower_kW,capacity_kW_utcsuffix, timezone-aware ISO 8601time_utc,last_updated_utcsite_idcapacity_kWat the top{"sites": [...]}Copy field names from
service/v1/endpoint_types.py, which is the source of truth for the region models the site models sit beside.Routes
Out of scope for this piece:
DELETE /sites/{site_id}and theaggregate=total|dno|gspparameter. Both are specified separately, we'll circle back to these when needed and/or confirmed on DP side.Response shapes
SiteDetail / SiteList
statusis one of"active","inactive","commissioning"β a string, so new values can be added later without a schema change. A site with no stored status reads as"active". Only"active"sites are forecast.The
/sitesresponse is enveloped deliberately in case we need to add e.g. pagination later with anext_cursor, without changing the overall response shape.POSTandPUTtake aSiteInputbody β the same fields minussite_id,capacity_kWandmetadata.PUTis a partial update: absent fields are left unchanged.Time series
Site forecasts carry no probabilistic levels; omit
plevels_kWentirely rather than returning an empty object. If this changes in future, we can easily add to this response shape.POST /sites/{site_id}/generationtakes a flat list and returns 202 with no body:[{"time_utc": "2026-04-17T12:00:00Z", "power_kW": 1.452}]Multi-site forecasts ("2D", forecast over period, up to 10* sites)
One shared
times_utcarray with parallel value arrays per site β no per-row timestamps.Snapshots (one timestamp, many sites)
Default timestamp is now, floored to 30 minutes.
Behaviour
Parameters
Every parameter on every route. Nothing accepts a parameter not listed here β an unknown one should 422 rather than be ignored, so a typo surfaces instead of silently returning the default.
GET /sitesstatus,name,min_latitude,max_latitude,min_longitude,max_longitudePOST /sitesSiteInput)GET /sites/{site_id}PUT /sites/{site_id}SiteInput, partial)GET /sites/{site_id}/forecaststart_utc,end_utc,horizon_minutes,creation_limit_utcGET /sites/{site_id}/generationstart_utc,end_utcPOST /sites/{site_id}/generationGET /sites/{site_id}/clearskystart_utc,end_utcGET /sites/forecasts/periodsite_ids,start_utc,end_utc,horizon_minutes,creation_limit_utcGET /sites/generation/periodsite_ids,start_utc,end_utcGET /sites/clearskysite_ids,start_utc,end_utcGET /sites/forecasts/snapshottime_utcGET /sites/generation/snapshottime_utcNotes:
horizon_minutesfilters to a single forecast horizon β60returns only the 1-hour-ahead value for each target timestep. Same name and meaning as the region forecast routes. Not available on the snapshot routes, which are already a single point in time.statusfilters the site list by lifecycle state. The platform has no metadata filter, so this is applied in the API after fetching.nameis a case-insensitive substring match onclient_site_name, matching the behaviour of?name=onGET /regions.latitude_longitude_min/maxas comma-joined strings parsed by hand; four floats let FastAPI validate them. All four are required together or none β a partial box is a 400.model_nameorobserverparameters, deliberately. Both are resolved from config and reported in the response. See Forecast model selection and Generation observer below.creation_limit_utcreturns the forecast as it was known at a point in time, by excluding runs created after it β the same name and meaning as on the region forecast route. It matters more for sites than for regions, since site customers schedule and trade against a specific forecast and later need to show what it said at the time, per site.created_cutoffis already a parameter onget_predicted_generationinStorageInterface, so this is a query param wired to an argument on a call the route makes anyway.History is available up to one year back; earlier
start_utcis a 400.The multi-site cap
/sites/forecasts/period,/sites/generation/periodand/sites/clearskyserve at most 10 sites per request*
MAX_SITES_PER_PERIOD_REQUESTβ a named constant, not a literal, so it can move to config, and not 100% what the best number is yet for the DP so we'll have to test this.site_idsis an optional repeated query param:Never return an arbitrary subset. Truncating to "the first 10" is silently wrong twice: the caller cannot tell data is missing, and the platform guarantees no ordering, so "the first 10" need not be the same ten between two identical requests β a dashboard would show a different slice of a portfolio on each refresh with nothing to explain it. Make the 400 self-service:
These endpoints fan out one platform call per site, so bound the concurrency of the fan-out independently of the request cap β ten sites each from twenty callers is still two hundred concurrent calls.
The snapshot routes are a single platform call for the whole set of user's sites and are not capped.
Ordering
The platform gives no ordering guarantee. Impose a deterministic order on every list response β
client_site_name, tie-broken onsite_id. Without it, a cached list reshuffles between refreshes for no visible reason, and pagination could never be added.Caching
Site data is per-company, so a cached response must never be served to a different company. The existing v1 cache key builder deliberately shares entries between users whose access is equivalent, which is correct for public region data and wrong here. Treat this as the highest-risk part of the work.
Suggested TTLs, to tune: site list and per-site series and snapshots 60s. The period endpoints are live platform reads and should be cached briefly or not at all.
Forecast model selection
Per-site, in this order:
metadata["forecast_name"]on the site, if set β a per-site override stored in the platform.country_config.py.Always report the resolved model as
model_namein the response. Do not add amodel_namerequest parameter yet β GB runs one site model, and reporting it now means the parameter can be added later without changing the response.Generation observer
Site generation reads and writes use the observer name from config (
site_apifor GB today). Report it asobserver_name. Observer names are validated^[a-z0-9_]+$by the platform.Clearsky
Pure
pvlibcompute from latitude, longitude, tilt, orientation and module capacity β no platform call.pvlibis a new dependency; add it topyproject.toml.If a site is missing
module_capacity_kWorinverter_capacity_kW, return a 400 naming the missing field. Do not infer one from the other.Errors
cmd/main.py403 and 404 divide on whether the answer depends on the resource.
A caller whose token carries no
hubspot_company_idand is notocf:admincan never see any site, on any route. That is a property of the caller, so it is a 403 on every site route including the list ones β not an empty list. An empty list says "you have no sites", which is a different and probably false statement, and it sends someone to the wrong place to debug it. Make the message actionable: the account is not linked to a company, and support can link it.Once a caller does have a company, every failure to find a site is a 404, whether the UUID belongs to another company or to nothing at all. A 403 there would confirm the site exists, which leaks the existence of another company's sites to anyone who can guess or obtain a UUID. The two cases must be indistinguishable in status code, body and timing.
Decide this once in the scoping layer rather than per route. The storage layer is not consistent about it today β depending on the call it returns an empty list, a 404, or a 403 for the same no-company caller β so a route that leans on whatever the backend happens to do will not match this table.
Platform constraints
Facts about the Data Platform that shape the contract. They explain requirements that look arbitrary otherwise.
location_nameis validated^[a-z0-9_|]+$, 2β100 characters. Customer-supplied names cannot be stored there, which is whyclient_site_namelives in the location'smetadataand why sites are addressed by UUID and never by name.effective_capacity_watts, which maps tocapacity_kW.inverter_capacity_kW,module_capacity_kW,client_site_id,tilt,orientationandstatusare all stored in the free-formmetadatastruct. Metadata is flat and its values are strings, ints or floats.ListLocationsreturns everything matching the filter in one message. This is whyGET /sitesis unpaginated for now.ListLocations. Any filtering onstatushappens in the API after fetching.DeleteLocationRPC. Relevant only to the deferredDELETEroute; nothing here should assume a location can be removed.enclosed_location_uuid_filteranswers "which GSP/DNO encloses this site", if site-to-region linkage is needed.Authorization
Sites are owned. Every site route is scoped to the caller's company, and getting this wrong means one customer reading another's portfolio β the most serious failure available in this work compared to the /regions section of the v1 API.
What exists already:
app_metadata.hubspot_company_id.get_org_id_from_authdata()ininternal/middleware/auth.pyturns the claims into an organisation identifier, returningNoneforocf:admin(all companies) and a sentinel when the caller has no company at all.organisation_id_filter.internal/service/sites/router.py) uses this path today and is a working reference.No new Auth0 permission is required. Company membership alone grants access to that company's sites; there is no country-permission check on site routes. We might add a site-specific role later, but this will be easy to insert an extra check before the ownership logic.
Two hazards worth knowing before you start, both of which fail silently rather than loudly:
Do not use pv-site-api as a reference for authorization. It has a bug, which is known, tracked separately, and deliberately not being fixed given that API is being retired. It is mentioned only so the pattern is not carried across.
Migration from pv-site-api
GET /sitesGET /v1/GB/solar/siteslatitude_longitude_min/maxcomma-strings become typed float paramsPOST /sitesPOST /v1/GB/solar/sitesPVSiteInputMetadataβSiteInputPUT /sites/{uuid}PUT /v1/GB/solar/sites/{site_id}GET /sites/{uuid}/pv_actualGET /v1/GB/solar/sites/{site_id}/generationstart_utc/end_utcPOST /sites/{uuid}/pv_actualPOST /v1/GB/solar/sites/{site_id}/generationMultiplePVActualto a listGET /sites/pv_actual?site_uuids=β¦GET /v1/GB/solar/sites/generation/period?site_ids=β¦GET /sites/{uuid}/pv_forecastGET /v1/GB/solar/sites/{site_id}/forecastGET /sites/pv_forecast?site_uuids=β¦GET /v1/GB/solar/sites/forecasts/period?site_ids=β¦horizon_minuteskeeps its nameGET /sites/{uuid}/clearsky_estimateGET /v1/GB/solar/sites/{site_id}/clearskyGET /sites/clearsky_estimate?site_uuids=β¦GET /v1/GB/solar/sites/clearsky?site_ids=β¦GET /api_statusDeliberate omissions:
compactβ the columnar matrix is the only multi-site format, and it is already compact.sum_byβ seeaggregate, specified separately./delete/path segment inDELETE /sites/delete/{uuid}.Breaking changes for clients (but this is a "new" API, so this is fine!)
snake_casethroughout;kwβkWexpected_generation_kw/actual_generation_kwβpower_kWtarget_datetime_utc/datetime_utcβtime_utcUUID-typed, not stringsWhat must be provably true
These are testable things we'll want to ensure are the case before we deploy, and there are def more than listed here!
Tenancy (the ones that matter most):
Behaviour:
PUTleaves unmentioned fields, including metadata, unchanged."active".Reference
src/quartz_api/internal/service/v1/routes/src/quartz_api/internal/service/v1/endpoint_types.pysrc/quartz_api/internal/service/v1/country_config.pysrc/quartz_api/internal/service/v1/cache.pysrc/quartz_api/internal/models/db_interface.pysrc/quartz_api/internal/backends/dataplatform/client.pysrc/quartz_api/internal/middleware/auth.pysrc/quartz_api/internal/service/sites/router.pysrc/quartz_api/internal/service/v1/test_router.pyuv run quartz-api # /v1/docs for the rendered schema pytest src/ --ignore=src/quartz_api/internal/backends/quartzdb/ \ --ignore=src/quartz_api/tests/integration/ ruff check src/Anything here that seems wrong, or that would need rewriting to add a second country, is worth raising before building around it.