Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 266 additions & 0 deletions api/gis_artifacts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
# ===============================================================================
# Copyright 2026 ross
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ===============================================================================
"""Downloadable QGIS and ArcGIS Pro artifacts for the OGC API mounts.

The public routes are deliberately anonymous: they describe the public
`/ogcapi` mount, which is itself anonymous, and a desktop GIS user fetching a
connection file has no credential to present. Nothing they return is
sensitive -- the URLs are already advertised in the pygeoapi landing page, and
no credential is ever embedded (see services/gis_artifacts).

The internal connection file is gated, not because the file is secret, but
because the internal mount's existence is not something to advertise to
anonymous callers. Holding it still gets you nothing without an `OGCInternal`
API key.

Read docs/ogc-desktop-gis-artifacts.md before changing what is emitted.
"""

from typing import Annotated

from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse, Response

from core.app import in_public_schema
from core.dependencies import session_dependency, viewer_dependency
from core.pygeoapi import _app_base_url, _internal_server_url, _server_url
from services.gis_artifacts import (
Connection,
arcgis_layer_file,
collection_fields,
find_curated_layer,
load_curated_layers,
qgis_connections_xml,
qgis_layer_definition,
)

router = APIRouter(prefix="/gis", tags=["desktop gis"])

PUBLIC_CONNECTION_NAME = "NMBGMR Ocotillo"
INTERNAL_CONNECTION_NAME = "NMBGMR Ocotillo (internal)"


class XmlAttachment(Response):
"""An XML download. The media type lives here so the OpenAPI schema and
the response itself cannot disagree: FastAPI reads `media_type` off the
`response_class` to document the operation, and `_attachment` returns an
instance of that same class rather than restating the string."""

media_type = "text/xml"


class JsonAttachment(Response):
"""A JSON download. Not JSONResponse: the body is already serialised, and
re-encoding it would escape the CIM document into a JSON string."""

media_type = "application/json"


def _attachment(response_class: type[Response], body: str, filename: str) -> Response:
return response_class(
content=body,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)


def _wants_json(request: Request, f: str | None) -> bool:
# Same precedence as api/disclaimer.py and pygeoapi itself: an explicit
# ?f= beats the Accept header, so the surfaces behave alike.
if f is not None:
return f.lower() == "json"
accept = request.headers.get("accept", "")
return "application/json" in accept and "text/html" not in accept


def _index_payload() -> dict:
"""Machine-readable catalogue of every artifact this router serves.

Absolute hrefs, built from _app_base_url() rather than the request, so a
browser app on another origin can use them unchanged and a proxy that
rewrites Host cannot send the caller somewhere else.
"""
root = _app_base_url()
service = _public_base()
return {
"service_url": service,
"connections": [
{
"client": "qgis",
"href": f"{root}/gis/qgis/connections.xml",
"media_type": XmlAttachment.media_type,
"filename": "ocotillo-ogcapi-connections.xml",
}
],
"layers": [
{
"id": layer.id,
"title": layer.title,
"abstract": layer.abstract,
"collection": layer.collection,
"collection_url": f"{service}/collections/{layer.collection}",
"geometry": layer.geometry,
"renderer": layer.renderer.get("type"),
"downloads": [
{
"client": "qgis",
"href": f"{root}/gis/qgis/layers/{layer.id}.qlr",
"media_type": XmlAttachment.media_type,
"filename": f"{layer.id}.qlr",
},
{
"client": "arcgis",
"href": f"{root}/gis/arcgis/layers/{layer.id}.lyrx",
"media_type": JsonAttachment.media_type,
"filename": f"{layer.id}.lyrx",
},
],
}
for layer in load_curated_layers()
],
}


def _public_base() -> str:
# _server_url() is what pygeoapi stamps into its own `self`/`next` links.
# Deriving the artifact's URL from the same place means a client that
# imports the connection and then pages through `items` never crosses
# hosts -- the failure mode that PYGEOAPI_INTERNAL_SERVER_URL was added to
# fix (see the comment in core/pygeoapi._internal_server_url).
return _server_url()


@router.get("/qgis/connections.xml", response_class=XmlAttachment)
@in_public_schema
def qgis_connections() -> Response:
"""QGIS connections file registering the public OGC API - Features mount.

Import through **Browser panel > right-click "WFS / OGC API - Features" >
Load Connections**.
"""
body = qgis_connections_xml([Connection(PUBLIC_CONNECTION_NAME, _public_base())])
return _attachment(XmlAttachment, body, "ocotillo-ogcapi-connections.xml")


@router.get("/qgis/connections-internal.xml", response_class=XmlAttachment)
def qgis_connections_internal(user: viewer_dependency) -> Response:
"""QGIS connections file covering the public and internal mounts.

Carries no credential. The internal entry only resolves for a client that
attaches its own `OGCInternal` API key -- see
docs/internal-ogc-desktop-gis.md for how one is issued and attached.
"""
body = qgis_connections_xml(
[
Connection(PUBLIC_CONNECTION_NAME, _public_base()),
Connection(INTERNAL_CONNECTION_NAME, _internal_server_url()),
]
)
return _attachment(XmlAttachment, body, "ocotillo-ogcapi-connections-internal.xml")


@router.get(
"/qgis/layers/{layer_id}.qlr",
response_class=XmlAttachment,
responses={404: {"description": "No curated layer with that id."}},
)
@in_public_schema
def qgis_layer(layer_id: str, session: session_dependency) -> Response:
"""A styled QGIS layer definition for one curated layer."""
layer = find_curated_layer(layer_id)
if layer is None:
raise HTTPException(status_code=404, detail=f"No curated layer {layer_id!r}.")
fields = collection_fields(session, layer.collection)
body = qgis_layer_definition(layer, _public_base(), fields)
return _attachment(XmlAttachment, body, f"{layer_id}.qlr")


@router.get(
"/arcgis/layers/{layer_id}.lyrx",
response_class=JsonAttachment,
responses={404: {"description": "No curated layer with that id."}},
)
@in_public_schema
def arcgis_layer(layer_id: str, session: session_dependency) -> Response:
"""A styled ArcGIS Pro layer file for one curated layer."""
layer = find_curated_layer(layer_id)
if layer is None:
raise HTTPException(status_code=404, detail=f"No curated layer {layer_id!r}.")
fields = collection_fields(session, layer.collection)
body = arcgis_layer_file(layer, _public_base(), fields)
return _attachment(JsonAttachment, body, f"{layer_id}.lyrx")


_PAGE_STYLE = (
"max-width:52rem;margin:3rem auto;padding:0 1.25rem;"
"font-family:system-ui,-apple-system,'Segoe UI',sans-serif;"
"line-height:1.6;color:#1a1a1a"
)


@router.get("", response_class=HTMLResponse)
@in_public_schema
def gis_index(request: Request, f: Annotated[str | None, Query()] = None) -> Response:
"""Landing page listing every downloadable artifact.

HTML by default for a human following the link; `?f=json` (or an
Accept: application/json header) returns the same catalogue as data, so a
frontend can enumerate the layers instead of hardcoding their ids.
"""
if _wants_json(request, f):
return JSONResponse(_index_payload())
base = _public_base()
rows = "".join(
f"<tr><td><strong>{layer.title}</strong><br>"
f"<span style='color:#555;font-size:.9em'>{layer.abstract}</span></td>"
f'<td><a href="qgis/layers/{layer.id}.qlr">.qlr</a></td>'
f'<td><a href="arcgis/layers/{layer.id}.lyrx">.lyrx</a></td></tr>'
for layer in load_curated_layers()
)
return HTMLResponse(f"""<!doctype html><html><head><meta charset="utf-8">
<title>Desktop GIS downloads</title></head>
<body style="{_PAGE_STYLE}">
<h1>Using our OGC layers in QGIS and ArcGIS Pro</h1>
<p>Service URL: <code>{base}</code></p>

<h2>Everything at once</h2>
<p><a href="qgis/connections.xml"><strong>QGIS connections file</strong></a> &mdash;
in QGIS, open the <em>Browser</em> panel, right-click
<em>WFS / OGC API - Features</em>, choose <em>Load Connections</em>, and pick
this file. Every collection then appears in the Browser panel.</p>
<p><strong>ArcGIS Pro</strong> &mdash; Pro writes its own <code>.ogc</code>
connection file and we cannot generate one for you. Add the connection once:
<em>Insert &gt; Connections &gt; Server &gt; New OGC API Server</em>, and paste
the service URL above. Pro saves a <code>.ogc</code> file into your project
folder that you can then share with colleagues.</p>

<h2>One layer at a time</h2>
<p>Styled, with field aliases already applied. Drag the file into QGIS, or add
the <code>.lyrx</code> to a map in Pro.</p>
<table cellpadding="6" style="border-collapse:collapse">
<tr><th align="left">Layer</th><th>QGIS</th><th>ArcGIS Pro</th></tr>
{rows}
</table>

<h2>Time series</h2>
<p>Water levels and water chemistry are also published as
OGC API - EDR time series at <code>{base}/collections/waterlevels</code> and
<code>{base}/collections/water_chemistry</code>. Neither QGIS nor ArcGIS Pro
can read EDR, so the layers above carry the same measurements summarised per
site instead.</p>
</body></html>""")


# ============= EOF =============================================
116 changes: 116 additions & 0 deletions core/gis-curated-layers.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Curated desktop-GIS layers.
#
# Each entry becomes one QGIS .qlr and one ArcGIS Pro .lyrx. These are the
# "I just want water levels" artifacts -- a small, opinionated set, not a
# mirror of the collection list. The connection files cover "give me
# everything"; anything a user can reach by browsing the connection does not
# need an entry here.
#
# `collection` must name a collection served by the OGC API - Features mount.
# The two EDR collections (waterlevels, water_chemistry) cannot appear here:
# neither QGIS nor ArcGIS Pro has an OGC API - EDR client, so a layer file
# pointing at one would not open. The feature collections below carry the same
# measurements summarised per site, which is what a GIS user wants on a map.
#
# Field aliases and value maps are NOT listed here. They are derived from
# core/ogc-field-descriptions.yml, the same file that feeds /schema and
# /queryables, so a renamed field cannot drift between the API and the shipped
# layer files.
#
# Colours are chosen to stay distinguishable for the common forms of colour
# blindness: the sequential ramps run light-to-dark so they survive being read
# by lightness alone, and the trend categories pair hue with a size difference.
#
# See docs/ogc-desktop-gis-artifacts.md.

layers:
- id: water-wells
collection: water_wells
title: Water Wells
abstract: >-
Every groundwater well in the monitoring-point register, at its most
recent recorded location.
geometry: Point
renderer:
type: single
color: "31,119,180,255"
size: 2.2
outline_color: "255,255,255,200"

- id: depth-to-water
collection: water_elevation_wells
title: Depth to Water
abstract: >-
Depth to the water table at each well at its most recent measurement, in
feet below ground surface. Larger values mean a deeper water table.
geometry: Point
renderer:
type: graduated
field: depth_to_water_below_ground_surface_ft
size: 2.6
classes:
- {lower: 0, upper: 25, label: "0 - 25 ft", color: "237,248,251,255"}
- {lower: 25, upper: 50, label: "25 - 50 ft", color: "179,205,227,255"}
- {lower: 50, upper: 100, label: "50 - 100 ft", color: "140,150,198,255"}
- {lower: 100, upper: 250, label: "100 - 250 ft", color: "136,86,167,255"}
- {lower: 250, upper: 100000, label: "over 250 ft", color: "129,15,124,255"}

- id: water-level-trend
collection: depth_to_water_trend_wells
title: Water-Level Trend
abstract: >-
Direction of the fitted depth-to-water trend at each well. "Falling
water table" means depth below ground surface is increasing.
geometry: Point
renderer:
type: categorized
field: trend_category
size: 2.6
categories:
- {value: "increasing", label: "Falling water table", color: "202,58,48,255", size: 3.2}
- {value: "decreasing", label: "Rising water table", color: "42,122,182,255", size: 3.2}
- {value: "stable", label: "Stable", color: "140,140,140,255", size: 2.2}
- {value: "not enough data", label: "Not enough data", color: "225,225,225,255", size: 1.8}

- id: actively-monitored-wells
collection: actively_monitored_wells
title: Actively Monitored Wells
abstract: >-
Wells currently on a monitoring schedule, with their water-level record
summarised.
geometry: Point
renderer:
type: single
color: "44,140,80,255"
size: 2.8
outline_color: "255,255,255,200"

- id: springs
collection: springs
title: Springs
abstract: Natural groundwater discharge points in the register.
geometry: Point
renderer:
type: single
color: "23,150,140,255"
size: 2.6
shape: triangle
outline_color: "255,255,255,200"

- id: latest-tds
collection: latest_tds_wells
title: Latest Total Dissolved Solids
abstract: >-
Most recent total-dissolved-solids result at each well. 1000 mg/L is the
conventional fresh/brackish boundary.
geometry: Point
renderer:
type: graduated
field: latest_tds_value
size: 2.6
classes:
- {lower: 0, upper: 500, label: "0 – 500 mg/L", color: "255,255,204,255"}
- {lower: 500, upper: 1000, label: "500 – 1000 mg/L", color: "161,218,180,255"}
- {lower: 1000, upper: 3000, label: "1000 – 3000 mg/L", color: "65,182,196,255"}
- {lower: 3000, upper: 10000, label: "3000 – 10000 mg/L", color: "44,127,184,255"}
- {lower: 10000, upper: 10000000, label: "over 10000 mg/L", color: "37,52,148,255"}
2 changes: 2 additions & 0 deletions core/initializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,13 +226,15 @@ def register_api_routes(app):
from api.disclaimer import router as disclaimer_router
from api.geothermal import router as geothermal_router
from api.chemisty import router as chemistry_router
from api.gis_artifacts import router as gis_artifacts_router

app.include_router(asset_router)
app.include_router(chemistry_router)
app.include_router(author_router)
app.include_router(contact_router)
app.include_router(disclaimer_router)
app.include_router(geospatial_router)
app.include_router(gis_artifacts_router)
app.include_router(group_router)
app.include_router(lexicon_router)
app.include_router(location_router)
Expand Down
Loading
Loading