diff --git a/commcare_connect/microplanning/buildings.py b/commcare_connect/microplanning/buildings.py new file mode 100644 index 0000000000..b1eeeb2eee --- /dev/null +++ b/commcare_connect/microplanning/buildings.py @@ -0,0 +1,214 @@ +""" +Building footprints for the microplanning map. + +Footprints come from Overture Maps, which is not in our database, so requests are served straight +from the source and cached. To keep that cache useful the requested viewport bbox is snapped +outward to a fixed XYZ grid (``GRID_ZOOM``): raw viewport bboxes are arbitrary floats that never +repeat, while grid tiles are shared between pans and between users looking at the same place. +""" + +import mercantile +import shapely +from django.core.cache import cache + +from commcare_connect.microplanning.const import ( + BUILDINGS_CACHE_KEY, + BUILDINGS_CACHE_TIMEOUT, + GRID_ZOOM, + MAX_BUILDING_GRID_TILES, + OVERTURE_CONNECT_TIMEOUT, + OVERTURE_REQUEST_TIMEOUT, +) +from commcare_connect.microplanning.exceptions import AreaTooLarge, BuildingDataUnavailable + +# The Overture release footprints are read from. We could probably store this in the DB and read +# it on a weekly basis. +OVERTURE_RELEASE = "2026-08-19.0" + +# Web Mercator cannot represent the poles; this is the latitude the projection is cut off at. +MAX_MERCATOR_LATITUDE = 85.0511 + + +def parse_bbox(raw): + """Parse a ``west,south,east,north`` query parameter, raising ValueError if it is unusable.""" + if not raw: + raise ValueError("bbox is required") + + parts = raw.split(",") + if len(parts) != 4: + raise ValueError("bbox must have four comma-separated values: west,south,east,north") + + try: + west, south, east, north = (float(part) for part in parts) + except ValueError: + raise ValueError("bbox values must be numbers") + + if not (-180 <= west <= 180 and -180 <= east <= 180): + raise ValueError("bbox longitudes must be between -180 and 180") + if not (-90 <= south <= 90 and -90 <= north <= 90): + raise ValueError("bbox latitudes must be between -90 and 90") + if west >= east or south >= north: + raise ValueError("bbox must be west,south,east,north with west < east and south < north") + + return west, south, east, north + + +def buildings_for_bbox(west, south, east, north): + """ + Return a GeoJSON FeatureCollection of buildings covering the given bbox. + + Its ``bbox`` member is the snapped grid area actually covered, which is always at least the + requested one. + + Grid tiles are read from the cache, and any that are missing are fetched from Overture together and + written back, so a pan that shifts the viewport by a grid tile only pays for the new column. + + Raises ``AreaTooLarge`` for a bbox covering more grid tiles than one request may fetch. + """ + if count_covering_grid_tiles(west, south, east, north) > MAX_BUILDING_GRID_TILES: + raise AreaTooLarge(f"{west},{south},{east},{north} covers more than {MAX_BUILDING_GRID_TILES} grid_tiles") + + grid_tiles = covering_grid_tiles(west, south, east, north) + cache_tile_keys = {grid_tile: cache_key_for_grid_tile(grid_tile) for grid_tile in grid_tiles} + # Read once and pass it on: a second read could miss a grid tile this one hit, and that grid + # tile is not in `fetched_buildings` precisely because the first read found it. + cached_buildings = cache.get_many(list(cache_tile_keys.values())) + + missing_grid_tiles = [grid_tile for grid_tile in grid_tiles if cache_tile_keys[grid_tile] not in cached_buildings] + # `bounds_for_grid_tiles` has no answer for an empty set, and there is nothing to fetch anyway. + fetched_buildings = fetch_buildings_for_grid_tiles(missing_grid_tiles) if missing_grid_tiles else {} + cache.set_many( + {cache_tile_keys[grid_tile]: features for grid_tile, features in fetched_buildings.items()}, + BUILDINGS_CACHE_TIMEOUT, + ) + + features = _dedupe_features_across_grid_tiles(grid_tiles, cache_tile_keys, cached_buildings, fetched_buildings) + return {"type": "FeatureCollection", "bbox": list(bounds_for_grid_tiles(grid_tiles)), "features": features} + + +def _dedupe_features_across_grid_tiles(grid_tiles, cache_tile_keys, cached_buildings, fetched_buildings): + """ + Flatten the per-grid-tile features into one list, in grid tile order, each building once. + """ + features = [] + seen_ids = set() + for grid_tile in grid_tiles: + for feature in cached_buildings.get(cache_tile_keys[grid_tile], fetched_buildings.get(grid_tile, [])): + # A building straddling a grid tile boundary is stored under both grid tiles. + feature_id = feature.get("properties", {}).get("id") + if feature_id is not None: + if feature_id in seen_ids: + continue + seen_ids.add(feature_id) + features.append(feature) + return features + + +def fetch_buildings_for_grid_tiles(grid_tiles): + """ + Fetch the given grid tiles from Overture, returning ``{(x, y): [feature, ...]}``. + + Every grid tile asked for is present in the result, empty ones included, so the caller can cache the + fact that an area has no buildings. One query covers the rectangle enclosing all of them rather + than one query per grid tile: each Overture read pays seconds of fixed overhead opening the remote + dataset, and neighbouring grid tiles largely share the parquet row groups that then get scanned. + """ + # Imported lazily: reading Overture means pyarrow, which costs a process 60MB of resident + # memory, and most requests this process serves never ask for buildings. + from commcare_connect.microplanning.overture import read_buildings + + try: + table = read_buildings( + bounds_for_grid_tiles(grid_tiles), + OVERTURE_RELEASE, + OVERTURE_CONNECT_TIMEOUT, + OVERTURE_REQUEST_TIMEOUT, + ) + except BuildingDataUnavailable: + raise + except Exception as e: + raise BuildingDataUnavailable("Could not read building data from Overture") from e + + return _features_by_grid_tile(table, grid_tiles) + + +def _features_by_grid_tile(table, grid_tiles): + """Turn an Overture record batch into GeoJSON features, bucketed by the grid tiles they fall in.""" + by_grid_tile = {grid_tile: [] for grid_tile in grid_tiles} + wanted = set(grid_tiles) + + ids = table.column("id").to_pylist() + geometries = shapely.from_wkb(table.column("geometry").to_pylist()) + bounds = table.column("bbox").to_pylist() + + for feature_id, geometry, feature_bounds in zip(ids, geometries, bounds): + if geometry is None: + continue + # A building may overlap several grid tiles, and may reach outside the ones we asked for: the + # query covers a rectangle enclosing them, and buildings on its edge stick out. Only the + # grid tiles we are about to cache are filled in. + overlapped = [ + grid_tile + for grid_tile in covering_grid_tiles( + feature_bounds["xmin"], feature_bounds["ymin"], feature_bounds["xmax"], feature_bounds["ymax"] + ) + if grid_tile in wanted + ] + if not overlapped: + continue + + feature = { + "type": "Feature", + "geometry": shapely.geometry.mapping(geometry), + "properties": {"id": feature_id}, + } + for grid_tile in overlapped: + by_grid_tile[grid_tile].append(feature) + + return by_grid_tile + + +def covering_grid_tiles(west, south, east, north, zoom=GRID_ZOOM): + """ + Return the ``(x, y)`` grid tiles at ``zoom`` that together cover the given bbox. + """ + grid_tiles = [(tile.x, tile.y) for tile in mercantile.tiles(west, south, east, north, zoom, truncate=True)] + if grid_tiles: + return grid_tiles + + corner = mercantile.tile(west, min(north, MAX_MERCATOR_LATITUDE), zoom, truncate=True) + return [(corner.x, corner.y)] + + +def count_covering_grid_tiles(west, south, east, north, zoom=GRID_ZOOM): + """ + How many grid tiles ``covering_grid_tiles`` would return, without building the list. + """ + # mercantile.tile cannot project the poles, and clamps nothing itself. mercantile.tiles does + # this for us; here it is ours to do. + north = min(north, MAX_MERCATOR_LATITUDE) + south = max(south, -MAX_MERCATOR_LATITUDE) + + top_left = mercantile.tile(west, north, zoom, truncate=True) + bottom_right = mercantile.tile(east - mercantile.LL_EPSILON, south + mercantile.LL_EPSILON, zoom, truncate=True) + + # A bbox smaller than one grid tile, or landing exactly on a boundary, still covers it. + return (max(bottom_right.x - top_left.x, 0) + 1) * (max(bottom_right.y - top_left.y, 0) + 1) + + +def bounds_for_grid_tiles(grid_tiles, zoom=GRID_ZOOM): + """ + Return the ``(west, south, east, north)`` bounds enclosing every grid tile in ``grid_tiles``. + """ + corners = [mercantile.bounds(x, y, zoom) for x, y in grid_tiles] + return ( + min(west for west, _, _, _ in corners), + min(south for _, south, _, _ in corners), + max(east for _, _, east, _ in corners), + max(north for _, _, _, north in corners), + ) + + +def cache_key_for_grid_tile(grid_tile, zoom=GRID_ZOOM): + x, y = grid_tile + return BUILDINGS_CACHE_KEY.format(release=OVERTURE_RELEASE, z=zoom, x=x, y=y) diff --git a/commcare_connect/microplanning/const.py b/commcare_connect/microplanning/const.py index 6bd113a0d4..2930d11b0c 100644 --- a/commcare_connect/microplanning/const.py +++ b/commcare_connect/microplanning/const.py @@ -39,3 +39,21 @@ "wag": "work_area_group", "ia": "implementation_area", } + +BUILDING_MIN_ZOOM = 15 + +# Each zoom level divides the map into fixed grid tiles. We can use these grid tiles to cache +# results on so GRID_ZOOM defines the zoom level for which we cache the grid tiles. One grid tile is +# 1024px on screen at BUILDING_MIN_ZOOM, which is what makes MAX_BUILDING_GRID_TILES work out. +GRID_ZOOM = 14 + +# The most (GRID_ZOOM-size) grid tiles one request's boundary box may cover, so that a single view +# cannot fan out into an unbounded number of upstream fetches. +MAX_BUILDING_GRID_TILES = 16 + +# The release is part of the key, so a cached grid tile never goes stale under the release it was read +# from and bumping buildings.OVERTURE_RELEASE invalidates every one of them at once. +BUILDINGS_CACHE_KEY = "buildings:{release}:{z}:{x}:{y}" +BUILDINGS_CACHE_TIMEOUT = 60 * 60 * 24 * 7 +OVERTURE_CONNECT_TIMEOUT = 5 +OVERTURE_REQUEST_TIMEOUT = 30 diff --git a/commcare_connect/microplanning/exceptions.py b/commcare_connect/microplanning/exceptions.py new file mode 100644 index 0000000000..8e49890f08 --- /dev/null +++ b/commcare_connect/microplanning/exceptions.py @@ -0,0 +1,6 @@ +class BuildingDataUnavailable(Exception): + """Raised when Overture could not be read. Distinct from an area that genuinely has no buildings.""" + + +class AreaTooLarge(Exception): + """Raised when more grid tiles were asked for than one request may fetch.""" diff --git a/commcare_connect/microplanning/overture.py b/commcare_connect/microplanning/overture.py new file mode 100644 index 0000000000..b4662438e7 --- /dev/null +++ b/commcare_connect/microplanning/overture.py @@ -0,0 +1,115 @@ +""" +Reading building footprints from Overture Maps. + +This is the only module that knows Overture's storage layout, and it exists so that the rest of +the app is not exposed to it. It replaces the ``overturemaps`` package, and is derived from it: +what follows is that package's ``core.record_batch_reader``, and the STAC lookup behind it, +narrowed to the one query the map makes. + +Derived from https://github.com/OvertureMaps/overturemaps-py (MIT licence, Copyright (c) 2024 +Overture Maps). It departs from that code deliberately in three places, so a comparison against +upstream is not a list of mistakes: a STAC lookup that fails raises rather than falling back to +scanning the whole release, an area Overture has no buildings for is an empty table rather than +an error, and only the three columns the map draws are read. +""" + +import io +from urllib.request import urlopen + +import pyarrow as pa +import pyarrow.compute as pc +import pyarrow.dataset as ds +import pyarrow.fs as fs +import pyarrow.parquet as pq + +from commcare_connect.microplanning.exceptions import BuildingDataUnavailable + +STAC_INDEX_URL = "https://stac.overturemaps.org/{release}/collections.parquet" + +BUILDING_COLLECTION = "building" + +# The bucket is public, and lives in one region. +OVERTURE_S3_REGION = "us-west-2" + +BUILDING_SCHEMA = pa.schema( + [ + ("id", pa.string()), + ("geometry", pa.binary()), + ("bbox", pa.struct([(name, pa.float64()) for name in ("xmin", "xmax", "ymin", "ymax")])), + ] +) +BUILDING_COLUMNS = list(BUILDING_SCHEMA.names) + + +def read_buildings(bounds, release, connect_timeout, request_timeout): + """ + Return an arrow table of the buildings Overture has inside ``bounds``, with BUILDING_SCHEMA's + columns and one row per building. + + An area Overture publishes no buildings for comes back as an empty table; only a read we could + not make raises ``BuildingDataUnavailable``. The caller can tell the two apart, and so may + cache the empty answer. + """ + paths = _intersecting_building_paths(bounds, release, request_timeout) + if not paths: + return BUILDING_SCHEMA.empty_table() + + return _read_building_parquet(paths, bounds, _overture_filesystem(connect_timeout, request_timeout)) + + +def _intersecting_building_paths(bounds, release, request_timeout): + """ + Return the ``bucket/key`` paths of the building parquet files covering ``bounds``. + + An empty list is a real answer rather than a failure: Overture only publishes files where it + has data, so nothing covering a bbox means there is nothing there to draw. + """ + index = _read_stac_index(release, request_timeout) + covering = index.filter((pc.field("collection") == BUILDING_COLLECTION) & _overlapping(bounds)) + # pyarrow reads the bucket and key, so the scheme the index gives them under has to come off. + return [ + asset["aws"]["alternate"]["s3"]["href"].removeprefix("s3://") + for asset in covering.column("assets").to_pylist() + ] + + +def _read_stac_index(release, request_timeout): + """Fetch a release's STAC index. pyarrow cannot read HTTP, so it is read into memory first.""" + url = STAC_INDEX_URL.format(release=release) + try: + with urlopen(url, timeout=request_timeout) as response: + return pq.read_table(io.BytesIO(response.read())) + except Exception as e: + raise BuildingDataUnavailable(f"Could not read the Overture index at {url}") from e + + +def _read_building_parquet(paths, bounds, filesystem): + """Scan the given parquet files, keeping the buildings that overlap ``bounds``.""" + try: + dataset = ds.dataset(paths, filesystem=filesystem) + return dataset.to_table(columns=BUILDING_COLUMNS, filter=_overlapping(bounds)) + except Exception as e: + raise BuildingDataUnavailable(f"Could not read {len(paths)} Overture building file(s)") from e + + +def _overture_filesystem(connect_timeout, request_timeout): + """Overture's bucket is public, so it is read anonymously rather than with our credentials.""" + return fs.S3FileSystem( + anonymous=True, + region=OVERTURE_S3_REGION, + connect_timeout=connect_timeout, + request_timeout=request_timeout, + ) + + +def _overlapping(bounds): + """ + A filter keeping rows whose ``bbox`` column overlaps ``bounds``. + """ + west, south, east, north = bounds + return ( + (pc.field("bbox", "xmin") < east) + & (pc.field("bbox", "xmax") > west) + & (pc.field("bbox", "ymin") < north) + & (pc.field("bbox", "ymax") > south) + ) diff --git a/commcare_connect/microplanning/tests/conftest.py b/commcare_connect/microplanning/tests/conftest.py new file mode 100644 index 0000000000..cbf2f48a7c --- /dev/null +++ b/commcare_connect/microplanning/tests/conftest.py @@ -0,0 +1,10 @@ +import pytest +from django.core.cache import cache + + +@pytest.fixture +def local_cache(settings): + """Keep cache reads off the shared Redis, and out of each other's way.""" + settings.CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}} + cache.clear() + return cache diff --git a/commcare_connect/microplanning/tests/test_buildings.py b/commcare_connect/microplanning/tests/test_buildings.py new file mode 100644 index 0000000000..e7e580b5fc --- /dev/null +++ b/commcare_connect/microplanning/tests/test_buildings.py @@ -0,0 +1,319 @@ +from unittest.mock import patch + +import mercantile +import pyarrow as pa +import pytest +import shapely + +from commcare_connect.microplanning.buildings import ( + _features_by_grid_tile, + bounds_for_grid_tiles, + buildings_for_bbox, + cache_key_for_grid_tile, + count_covering_grid_tiles, + covering_grid_tiles, + fetch_buildings_for_grid_tiles, + parse_bbox, +) +from commcare_connect.microplanning.const import ( + BUILDING_MIN_ZOOM, + GRID_ZOOM, + MAX_BUILDING_GRID_TILES, +) +from commcare_connect.microplanning.exceptions import AreaTooLarge, BuildingDataUnavailable +from commcare_connect.microplanning.overture import BUILDING_SCHEMA + +# The largest map we draw, in css pixels, and where those numbers come from. They live here rather +# than in const.py because they describe the browser, not the endpoint: the cap is a limit on what +# the server will fetch, and this is only the check that the limit leaves room for a real viewport. +# Sizes follow #map-wrapper in microplanning/home.html - a w-96 sidebar plus its gap beside the map, +# and 300px of page above it. +LARGEST_SCREEN_PX = (3840, 2160) # a 4K display +SIDEBAR_PX = 384 + 32 +PAGE_CHROME_PX = 300 +MAPBOX_TILE_PX = 512 # Mapbox GL serves 512px tiles, so the world is this * 2**zoom px across + + +def _arrow_table(rows): + """A minimal stand-in for an Overture record batch: id, WKB geometry and a bbox struct.""" + return pa.table( + { + "id": pa.array([feature_id for feature_id, _ in rows], pa.string()), + "geometry": pa.array([shapely.to_wkb(geometry) for _, geometry in rows], pa.binary()), + "bbox": pa.array( + [dict(zip(("xmin", "ymin", "xmax", "ymax"), geometry.bounds)) for _, geometry in rows], + pa.struct([(name, pa.float64()) for name in ("xmin", "ymin", "xmax", "ymax")]), + ), + } + ) + + +def building(feature_id, lon=8.65, lat=9.05): + return { + "type": "Feature", + "geometry": {"type": "Polygon", "coordinates": [[[lon, lat], [lon, lat], [lon, lat], [lon, lat]]]}, + "properties": {"id": feature_id}, + } + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("8.65,9.05,8.70,9.09", (8.65, 9.05, 8.70, 9.09)), + ("-1.5,-2.5,3,4", (-1.5, -2.5, 3.0, 4.0)), + ], +) +def test_parse_bbox_accepts_valid_values(raw, expected): + assert parse_bbox(raw) == expected + + +@pytest.mark.parametrize( + "raw", + [ + None, + "", + "8.65,9.05,8.70", + "8.65,9.05,8.70,9.09,1", + "8.65,9.05,8.70,north", + "-181,9.05,8.70,9.09", + "8.65,-91,8.70,9.09", + "8.70,9.05,8.65,9.09", # west past east + "8.65,9.09,8.70,9.05", # south past north + "8.65,9.05,8.65,9.09", # zero width + ], +) +def test_parse_bbox_rejects_bad_values(raw): + with pytest.raises(ValueError): + parse_bbox(raw) + + +def test_covering_grid_tiles_returns_a_single_grid_tile_for_a_bbox_inside_one(): + # A hundred metres or so either side of the origin, well inside one z14 grid tile. + assert covering_grid_tiles(0.001, 0.001, 0.002, 0.002) == [(8192, 8191)] + + +def test_covering_grid_tiles_spans_every_grid_tile_a_bbox_touches(): + # Straddles the prime meridian and the equator, so it covers the four grid tiles that meet there. + assert set(covering_grid_tiles(-0.01, -0.01, 0.01, 0.01)) == { + (8191, 8191), + (8191, 8192), + (8192, 8191), + (8192, 8192), + } + + +def test_covering_grid_tiles_clamps_to_the_world_at_zoom_zero(): + assert covering_grid_tiles(-180, -85, 180, 85, zoom=0) == [(0, 0)] + + +@pytest.mark.parametrize( + "bbox", + [ + (8.65, 9.05, 8.70, 9.09), # a zoom-15-ish viewport in northern Nigeria + (-0.01, -0.01, 0.01, 0.01), + (0.001, 0.001, 0.002, 0.002), + (-73.99, 40.70, -73.96, 40.73), + ], +) +def test_covered_area_always_contains_the_requested_bbox(bbox): + west, south, east, north = bbox + covered_west, covered_south, covered_east, covered_north = bounds_for_grid_tiles(covering_grid_tiles(*bbox)) + + assert covered_west <= west + assert covered_south <= south + assert covered_east >= east + assert covered_north >= north + + +@pytest.mark.parametrize( + "bbox", + [ + (8.65, 9.05, 8.70, 9.09), # a zoom-15-ish viewport + (8.6501, 9.0501, 8.6502, 9.0502), # smaller than one grid tile + # Narrower than the epsilon mercantile.tiles trims off the closing edges, and sitting on a + # grid tile boundary, which is the case that made the list come back empty while the count said + # one - leaving buildings_for_bbox with no grid tiles to take bounds from. + (0.0, 0.0, 1e-13, 1e-13), + (-0.01, -0.01, 0.01, 0.01), # across the prime meridian and the equator + (3.0, 4.0, 14.0, 14.0), # far larger than the cap allows + ], +) +def test_counting_grid_tiles_agrees_with_listing_them(bbox): + assert count_covering_grid_tiles(*bbox) == len(covering_grid_tiles(*bbox)) + + +def test_a_bbox_narrower_than_the_tile_epsilon_still_covers_its_corner_grid_tile(): + bbox = (0.0, 0.0, 1e-13, 1e-13) + + corner = mercantile.tile(0.0, 0.0, GRID_ZOOM) + + assert covering_grid_tiles(*bbox) == [(corner.x, corner.y)] + assert count_covering_grid_tiles(*bbox) == 1 + + +def test_counting_grid_tiles_is_cheap_for_a_bbox_that_could_never_be_served(): + """ + parse_bbox admits the whole world, and the view counts grid tiles before anything else, so counting + must not go anywhere near building the list: this bbox covers 2**28 grid tiles, tens of GB of tuples. + """ + whole_grid = 2**GRID_ZOOM * 2**GRID_ZOOM + + assert count_covering_grid_tiles(-180, -90, 180, 90) == whole_grid + assert whole_grid > MAX_BUILDING_GRID_TILES + + +def test_an_area_past_the_cap_is_refused_before_anything_is_fetched(local_cache): + with patch("commcare_connect.microplanning.buildings.fetch_buildings_for_grid_tiles") as fetch: + with pytest.raises(AreaTooLarge): + buildings_for_bbox(3.0, 4.0, 14.0, 14.0) + + fetch.assert_not_called() + + +def test_the_cap_admits_the_largest_viewport_we_support(): + assert count_covering_grid_tiles(*_viewport_bbox(*LARGEST_SCREEN_PX, BUILDING_MIN_ZOOM)) <= MAX_BUILDING_GRID_TILES + + +def test_a_screen_past_the_cap_comes_inside_it_by_zooming_in(): + too_big = (5120, 2880) # a 5K display; a 4K one at 80% browser zoom covers the same ground + + assert count_covering_grid_tiles(*_viewport_bbox(*too_big, BUILDING_MIN_ZOOM)) > MAX_BUILDING_GRID_TILES + assert count_covering_grid_tiles(*_viewport_bbox(*too_big, BUILDING_MIN_ZOOM + 1)) <= MAX_BUILDING_GRID_TILES + + +def _viewport_bbox(screen_width_px, screen_height_px, zoom, west=8.65, south=9.05): + """ + The bbox the map would ask for on a screen of this size, at this zoom. + + Degrees per pixel is taken at the equator, where a pixel spans the most longitude, and the + fetched area is snapped outward to whole grid tiles, so this is the worst case for the count. + """ + width_px = screen_width_px - SIDEBAR_PX + height_px = screen_height_px - PAGE_CHROME_PX + degrees_per_px = 360 / (MAPBOX_TILE_PX * 2**zoom) + return (west, south, west + width_px * degrees_per_px, south + height_px * degrees_per_px) + + +def test_buildings_for_bbox_returns_a_feature_collection_covering_the_request(local_cache): + bbox = (8.65, 9.05, 8.70, 9.09) + with patch( + "commcare_connect.microplanning.buildings.fetch_buildings_for_grid_tiles", + side_effect=lambda grid_tiles: { + grid_tile: [building(f"{grid_tile[0]}-{grid_tile[1]}")] for grid_tile in grid_tiles + }, + ): + collection = buildings_for_bbox(*bbox) + + assert collection["type"] == "FeatureCollection" + assert len(collection["features"]) == len(covering_grid_tiles(*bbox)) + covered_west, covered_south, covered_east, covered_north = collection["bbox"] + assert covered_west <= bbox[0] and covered_south <= bbox[1] + assert covered_east >= bbox[2] and covered_north >= bbox[3] + + +def test_buildings_for_bbox_dedupes_a_building_returned_by_several_grid_tiles(local_cache): + # A building straddling a grid tile boundary comes back from every grid tile it touches. + with patch( + "commcare_connect.microplanning.buildings.fetch_buildings_for_grid_tiles", + side_effect=lambda grid_tiles: { + grid_tile: [building("straddler"), building(f"{grid_tile[0]}-{grid_tile[1]}")] for grid_tile in grid_tiles + }, + ): + collection = buildings_for_bbox(-0.01, -0.01, 0.01, 0.01) + + ids = [feature["properties"]["id"] for feature in collection["features"]] + assert ids.count("straddler") == 1 + assert len(ids) == len(set(ids)) == 5 # one straddler plus one per grid tile + + +def test_buildings_for_bbox_serves_cached_grid_tiles_without_calling_the_service(local_cache): + bbox = (0.001, 0.001, 0.002, 0.002) + local_cache.set(cache_key_for_grid_tile(covering_grid_tiles(*bbox)[0]), [building("cached")]) + + with patch("commcare_connect.microplanning.buildings.fetch_buildings_for_grid_tiles") as fetch: + collection = buildings_for_bbox(*bbox) + + fetch.assert_not_called() + assert [feature["properties"]["id"] for feature in collection["features"]] == ["cached"] + + +@pytest.mark.parametrize( + "fetched_ids", + [ + ["fetched"], + # An area with nothing in it is worth remembering, or every view of it re-queries Overture. + [], + ], +) +def test_buildings_for_bbox_caches_what_it_fetched(local_cache, fetched_ids): + bbox = (0.001, 0.001, 0.002, 0.002) + grid_tile = covering_grid_tiles(*bbox)[0] + + with patch( + "commcare_connect.microplanning.buildings.fetch_buildings_for_grid_tiles", + side_effect=lambda grid_tiles: {c: [building(i) for i in fetched_ids] for c in grid_tiles}, + ): + buildings_for_bbox(*bbox) + + cached = local_cache.get(cache_key_for_grid_tile(grid_tile)) + assert [feature["properties"]["id"] for feature in cached] == fetched_ids + + +def test_buildings_for_bbox_only_fetches_the_grid_tiles_it_is_missing(local_cache): + bbox = (-0.01, -0.01, 0.01, 0.01) + grid_tiles = covering_grid_tiles(*bbox) + cached_grid_tile = grid_tiles[0] + local_cache.set(cache_key_for_grid_tile(cached_grid_tile), [building("cached")]) + + with patch( + "commcare_connect.microplanning.buildings.fetch_buildings_for_grid_tiles", + side_effect=lambda grid_tiles: {c: [] for c in grid_tiles}, + ) as fetch: + buildings_for_bbox(*bbox) + + assert fetch.call_args.args[0] == [grid_tile for grid_tile in grid_tiles if grid_tile != cached_grid_tile] + + +@pytest.mark.parametrize( + "side_effect", + [ + # The read failed somewhere overture.py knows about: the index, or the parquet files. + BuildingDataUnavailable("Could not read the Overture index"), + # And anything it does not: whatever goes wrong, this endpoint owes the map an answer. + OSError("connection reset"), + ], + ids=["overture reported it", "anything else"], +) +def test_fetch_raises_when_overture_cannot_be_read(side_effect): + with patch("commcare_connect.microplanning.overture.read_buildings", side_effect=side_effect): + with pytest.raises(BuildingDataUnavailable): + fetch_buildings_for_grid_tiles([(8192, 8191)]) + + +def test_an_area_overture_has_no_buildings_for_is_not_an_error(local_cache): + """Overture answered, and the answer was that there is nothing there. That is cacheable.""" + bbox = (-0.01, -0.01, 0.01, 0.01) + + with patch("commcare_connect.microplanning.overture.read_buildings", return_value=BUILDING_SCHEMA.empty_table()): + collection = buildings_for_bbox(*bbox) + + assert collection["features"] == [] + assert all(local_cache.get(cache_key_for_grid_tile(grid_tile)) == [] for grid_tile in covering_grid_tiles(*bbox)) + + +def test_features_by_grid_tile_buckets_each_building_into_every_grid_tile_it_touches(): + boundary_lon = mercantile.bounds(8192, 8191, GRID_ZOOM).west + grid_tiles = [(8191, 8191), (8192, 8191)] + straddler = shapely.box(boundary_lon - 0.001, 0.001, boundary_lon + 0.001, 0.002) + inside = shapely.box(boundary_lon + 0.003, 0.001, boundary_lon + 0.004, 0.002) + table = _arrow_table([("straddler", straddler), ("inside", inside)]) + + by_grid_tile = _features_by_grid_tile(table, grid_tiles) + + assert [f["properties"]["id"] for f in by_grid_tile[(8191, 8191)]] == ["straddler"] + assert [f["properties"]["id"] for f in by_grid_tile[(8192, 8191)]] == ["straddler", "inside"] + assert by_grid_tile[(8192, 8191)][1]["geometry"]["type"] == "Polygon" + + +def test_features_by_grid_tile_returns_an_entry_for_every_requested_grid_tile(): + assert _features_by_grid_tile(_arrow_table([]), [(1, 2), (3, 4)]) == {(1, 2): [], (3, 4): []} diff --git a/commcare_connect/microplanning/tests/test_overture.py b/commcare_connect/microplanning/tests/test_overture.py new file mode 100644 index 0000000000..94a6a10a55 --- /dev/null +++ b/commcare_connect/microplanning/tests/test_overture.py @@ -0,0 +1,218 @@ +import io +from contextlib import contextmanager +from unittest.mock import MagicMock, patch + +import pyarrow as pa +import pyarrow.fs as fs +import pyarrow.parquet as pq +import pytest +import shapely + +from commcare_connect.microplanning.exceptions import BuildingDataUnavailable +from commcare_connect.microplanning.overture import _intersecting_building_paths, read_buildings + +RELEASE = "2026-08-19.0" +CONNECT_TIMEOUT = 5 +REQUEST_TIMEOUT = 30 + +# The index and the building files both carry a bbox struct, and they order its fields +# differently. The fakes below follow each, so a read that depends on the order fails here. +INDEX_BBOX_TYPE = pa.struct([(name, pa.float64()) for name in ("xmin", "ymin", "xmax", "ymax")]) +BUILDING_BBOX_TYPE = pa.struct([(name, pa.float64()) for name in ("xmin", "xmax", "ymin", "ymax")]) +ASSETS_TYPE = pa.struct([("aws", pa.struct([("alternate", pa.struct([("s3", pa.struct([("href", pa.string())]))]))]))]) + + +def _stac_index(rows): + """ + A minimal stand-in for a release's STAC collections.parquet. + + Each row is ``(collection, (xmin, ymin, xmax, ymax), s3_key)``, matching the columns of the + real index that we read: what the file holds, where it is, and where to fetch it from. + """ + return pa.table( + { + "collection": pa.array([collection for collection, _, _ in rows], pa.string()), + "bbox": pa.array( + [dict(zip(("xmin", "ymin", "xmax", "ymax"), bounds)) for _, bounds, _ in rows], INDEX_BBOX_TYPE + ), + "assets": pa.array( + [{"aws": {"alternate": {"s3": {"href": f"s3://{key}"}}}} for _, _, key in rows], ASSETS_TYPE + ), + } + ) + + +@contextmanager +def serving_stac_index(index): + """Answer the STAC index request with ``index`` instead of going to stac.overturemaps.org.""" + buffer = io.BytesIO() + pq.write_table(index, buffer) + response = MagicMock() + response.__enter__.return_value.read.return_value = buffer.getvalue() + with patch("commcare_connect.microplanning.overture.urlopen", return_value=response) as urlopen: + yield urlopen + + +def test_only_the_building_files_overlapping_the_bbox_are_read(): + index = _stac_index( + [ + ("building", (-10.0, -10.0, 0.0, 0.0), "bucket/west.parquet"), + ("building", (0.0, -10.0, 10.0, 0.0), "bucket/east.parquet"), + ] + ) + + with serving_stac_index(index): + paths = _intersecting_building_paths((1.0, -1.0, 2.0, -0.5), RELEASE, REQUEST_TIMEOUT) + + assert paths == ["bucket/east.parquet"] + + +def test_files_for_other_overture_collections_are_ignored(): + index = _stac_index( + [ + ("segment", (-1.0, -1.0, 1.0, 1.0), "bucket/roads.parquet"), + ("building", (-1.0, -1.0, 1.0, 1.0), "bucket/buildings.parquet"), + ] + ) + + with serving_stac_index(index): + paths = _intersecting_building_paths((0.0, 0.0, 0.5, 0.5), RELEASE, REQUEST_TIMEOUT) + + assert paths == ["bucket/buildings.parquet"] + + +def test_a_bbox_no_file_covers_yields_no_paths(): + index = _stac_index([("building", (-10.0, -10.0, 0.0, 0.0), "bucket/west.parquet")]) + + with serving_stac_index(index): + assert _intersecting_building_paths((5.0, 5.0, 6.0, 6.0), RELEASE, REQUEST_TIMEOUT) == [] + + +def test_the_index_is_read_for_the_release_we_ask_for(): + with serving_stac_index(_stac_index([])) as urlopen: + _intersecting_building_paths((0.0, 0.0, 1.0, 1.0), RELEASE, REQUEST_TIMEOUT) + + assert urlopen.call_args.args[0] == f"https://stac.overturemaps.org/{RELEASE}/collections.parquet" + + +def test_the_index_request_cannot_hang_forever(): + """A bare urlopen has no timeout at all, and this one runs inside a web request.""" + with serving_stac_index(_stac_index([])) as urlopen: + _intersecting_building_paths((0.0, 0.0, 1.0, 1.0), RELEASE, REQUEST_TIMEOUT) + + assert urlopen.call_args.kwargs["timeout"] == REQUEST_TIMEOUT + + +def test_a_stac_index_that_cannot_be_fetched_is_reported_as_unavailable(): + with patch("commcare_connect.microplanning.overture.urlopen", side_effect=OSError("connection reset")): + with pytest.raises(BuildingDataUnavailable): + _intersecting_building_paths((0.0, 0.0, 1.0, 1.0), RELEASE, REQUEST_TIMEOUT) + + +def test_a_stac_index_that_is_not_parquet_is_reported_as_unavailable(): + response = MagicMock() + response.__enter__.return_value.read.return_value = b"502 Bad Gateway" + + with patch("commcare_connect.microplanning.overture.urlopen", return_value=response): + with pytest.raises(BuildingDataUnavailable): + _intersecting_building_paths((0.0, 0.0, 1.0, 1.0), RELEASE, REQUEST_TIMEOUT) + + +def _building_parquet(path, rows, extra_columns=None): + """Write a parquet file shaped like one of Overture's, plus any columns we do not read.""" + table = pa.table( + { + "id": pa.array([feature_id for feature_id, _ in rows], pa.string()), + "geometry": pa.array([shapely.to_wkb(geometry) for _, geometry in rows], pa.binary()), + "bbox": pa.array( + [dict(zip(("xmin", "ymin", "xmax", "ymax"), geometry.bounds)) for _, geometry in rows], + BUILDING_BBOX_TYPE, + ), + **(extra_columns or {}), + } + ) + pq.write_table(table, path) + return path + + +@contextmanager +def serving_building_files(files): + """ + Serve real local parquet files as though they were Overture's. + + Each file is ``(path, bounds)``: the bounds go in the index, so which files a read opens is + decided the same way it is in production, and what the read then does to the rows - the bbox + pushdown, the column projection - happens for real. + """ + index = _stac_index([("building", bounds, str(path)) for path, bounds in files]) + with serving_stac_index(index): + with patch("commcare_connect.microplanning.overture._overture_filesystem", return_value=fs.LocalFileSystem()): + yield + + +def test_a_bbox_no_file_covers_yields_no_buildings(): + """Overture publishes files only where it has data, so this is an empty area, not a failure.""" + index = _stac_index([("building", (-10.0, -10.0, 0.0, 0.0), "bucket/west.parquet")]) + + with serving_stac_index(index): + table = read_buildings((5.0, 5.0, 6.0, 6.0), RELEASE, CONNECT_TIMEOUT, REQUEST_TIMEOUT) + + assert table.num_rows == 0 + assert table.column_names == ["id", "geometry", "bbox"] + + +def test_buildings_outside_the_requested_bbox_are_left_behind(tmp_path): + path = _building_parquet( + tmp_path / "buildings.parquet", + [("inside", shapely.box(0.1, 0.1, 0.2, 0.2)), ("far away", shapely.box(5.0, 5.0, 5.1, 5.1))], + ) + + with serving_building_files([(path, (0.0, 0.0, 10.0, 10.0))]): + table = read_buildings((0.0, 0.0, 1.0, 1.0), RELEASE, CONNECT_TIMEOUT, REQUEST_TIMEOUT) + + assert table.column("id").to_pylist() == ["inside"] + + +def test_only_the_columns_the_map_needs_are_read(tmp_path): + """Overture publishes about thirty columns per building; parquet lets us pay for three.""" + path = _building_parquet( + tmp_path / "buildings.parquet", + [("a building", shapely.box(0.1, 0.1, 0.2, 0.2))], + extra_columns={"names": pa.array(["Clinic"], pa.string()), "height": pa.array([12.0], pa.float64())}, + ) + + with serving_building_files([(path, (0.0, 0.0, 10.0, 10.0))]): + table = read_buildings((0.0, 0.0, 1.0, 1.0), RELEASE, CONNECT_TIMEOUT, REQUEST_TIMEOUT) + + assert table.column_names == ["id", "geometry", "bbox"] + + +def test_every_file_covering_the_bbox_is_read(tmp_path): + """A bbox on a partition boundary has its buildings split across files.""" + west = _building_parquet(tmp_path / "west.parquet", [("west", shapely.box(-0.2, 0.1, -0.1, 0.2))]) + east = _building_parquet(tmp_path / "east.parquet", [("east", shapely.box(0.1, 0.1, 0.2, 0.2))]) + + with serving_building_files([(west, (-10.0, -10.0, 0.0, 10.0)), (east, (0.0, -10.0, 10.0, 10.0))]): + table = read_buildings((-1.0, 0.0, 1.0, 1.0), RELEASE, CONNECT_TIMEOUT, REQUEST_TIMEOUT) + + assert sorted(table.column("id").to_pylist()) == ["east", "west"] + + +def test_parquet_that_cannot_be_read_is_reported_as_unavailable(tmp_path): + path = tmp_path / "buildings.parquet" + path.write_bytes(b"503 Slow Down") + + with serving_building_files([(path, (0.0, 0.0, 10.0, 10.0))]): + with pytest.raises(BuildingDataUnavailable): + read_buildings((0.0, 0.0, 1.0, 1.0), RELEASE, CONNECT_TIMEOUT, REQUEST_TIMEOUT) + + +def test_an_empty_read_and_a_full_one_come_back_the_same_shape(tmp_path): + """The two are built by different code paths, so a caller may combine them.""" + path = _building_parquet(tmp_path / "buildings.parquet", [("a building", shapely.box(0.1, 0.1, 0.2, 0.2))]) + + with serving_building_files([(path, (0.0, 0.0, 10.0, 10.0))]): + buildings = read_buildings((0.0, 0.0, 1.0, 1.0), RELEASE, CONNECT_TIMEOUT, REQUEST_TIMEOUT) + nothing = read_buildings((50.0, 50.0, 51.0, 51.0), RELEASE, CONNECT_TIMEOUT, REQUEST_TIMEOUT) + + assert pa.concat_tables([buildings, nothing]).num_rows == 1 diff --git a/commcare_connect/microplanning/tests/test_views.py b/commcare_connect/microplanning/tests/test_views.py index d7513facdb..4f1f7c56b9 100644 --- a/commcare_connect/microplanning/tests/test_views.py +++ b/commcare_connect/microplanning/tests/test_views.py @@ -28,6 +28,7 @@ SEARCH_KIND_FILTERS, SERVICE_DELIVERY_UNIT_SLUG, ) +from commcare_connect.microplanning.exceptions import BuildingDataUnavailable from commcare_connect.microplanning.filters import WorkAreaMapFilterSet from commcare_connect.microplanning.forms import AssignmentModeForm from commcare_connect.microplanning.models import ( @@ -237,6 +238,112 @@ def test_clear_deletes_areas_and_keeps_work_area_names(self, client, org_user_ad assert work_area.implementation_area_name == "Ward North" +@pytest.mark.django_db +class TestBuildingsGeojson(BaseMicroplanningFlagTest): + @pytest.fixture(autouse=True) + def isolated_cache(self, local_cache): + """Grid tile lookups must not carry over between tests, or a fetch under test never happens.""" + + def url(self, org_slug, opp_id): + return reverse( + "microplanning:buildings_geojson", + kwargs={"org_slug": org_slug, "opp_id": opp_id}, + ) + + def test_returns_feature_collection(self, client, org_user_admin, organization, opportunity): + feature = { + "type": "Feature", + "geometry": {"type": "Polygon", "coordinates": [[[8.65, 9.05], [8.66, 9.05], [8.66, 9.06], [8.65, 9.05]]]}, + "properties": {"id": "building-1"}, + } + client.force_login(org_user_admin) + with patch( + "commcare_connect.microplanning.buildings.fetch_buildings_for_grid_tiles", + side_effect=lambda grid_tiles: { + grid_tiles[0]: [feature], + **{grid_tile: [] for grid_tile in grid_tiles[1:]}, + }, + ): + response = client.get( + self.url(organization.slug, opportunity.opportunity_id), {"bbox": "8.65,9.05,8.70,9.09"} + ) + + assert response.status_code == 200 + body = response.json() + assert body["type"] == "FeatureCollection" + assert body["features"] == [feature] + # The bbox reported back is the snapped area, so it always contains what was asked for. + west, south, east, north = body["bbox"] + assert west <= 8.65 and south <= 9.05 and east >= 8.70 and north >= 9.09 + + @pytest.mark.parametrize( + "bbox", + [None, "", "8.65,9.05,8.70", "8.65,9.05,8.70,north", "8.70,9.05,8.65,9.09"], + ) + def test_bad_bbox_is_rejected(self, client, org_user_admin, organization, opportunity, bbox): + client.force_login(org_user_admin) + params = {} if bbox is None else {"bbox": bbox} + response = client.get(self.url(organization.slug, opportunity.opportunity_id), params) + assert response.status_code == 400 + assert response.json()["error"] + + def test_too_large_an_area_is_reported_apart_from_a_malformed_one( + self, client, org_user_admin, organization, opportunity + ): + """422, not the 400 a bad bbox gets: the map asks the user to zoom in only for this one.""" + client.force_login(org_user_admin) + response = client.get(self.url(organization.slug, opportunity.opportunity_id), {"bbox": "3.0,4.0,14.0,14.0"}) + assert response.status_code == 422 + assert "too large" in response.json()["error"] + + def test_unavailable_upstream_is_reported_as_503(self, client, org_user_admin, organization, opportunity): + client.force_login(org_user_admin) + with patch( + "commcare_connect.microplanning.buildings.fetch_buildings_for_grid_tiles", + side_effect=BuildingDataUnavailable("overture is down"), + ): + response = client.get( + self.url(organization.slug, opportunity.opportunity_id), {"bbox": "8.65,9.05,8.70,9.09"} + ) + + assert response.status_code == 503 + assert response.json()["error"] + + def test_overture_failing_to_open_is_reported_as_503(self, client, org_user_admin, organization, opportunity): + """Whatever goes wrong reading Overture, the map is owed an answer rather than a traceback.""" + client.force_login(org_user_admin) + with patch( + "commcare_connect.microplanning.overture.read_buildings", + side_effect=Exception("Could not read the Overture index: "), + ): + response = client.get( + self.url(organization.slug, opportunity.opportunity_id), {"bbox": "8.65,9.05,8.70,9.09"} + ) + + assert response.status_code == 503 + + def test_a_bbox_too_narrow_to_cover_a_grid_tile_is_not_a_server_error( + self, client, org_user_admin, organization, opportunity + ): + """parse_bbox admits this, so it must reach the fetch rather than blowing up on empty grid tiles.""" + client.force_login(org_user_admin) + with patch( + "commcare_connect.microplanning.buildings.fetch_buildings_for_grid_tiles", + side_effect=lambda grid_tiles: {grid_tile: [] for grid_tile in grid_tiles}, + ): + response = client.get( + self.url(organization.slug, opportunity.opportunity_id), + {"bbox": "0.0,0.0,0.0000000000001,0.0000000000001"}, + ) + + assert response.status_code == 200 + + def test_requires_org_admin(self, client, org_user_member, organization, opportunity): + client.force_login(org_user_member) + response = client.get(self.url(organization.slug, opportunity.opportunity_id), {"bbox": "8.65,9.05,8.70,9.09"}) + assert response.status_code == 404 + + @pytest.mark.django_db class TestImplementationAreasGeojson(BaseMicroplanningFlagTest): def url(self, org_slug, opp_id): diff --git a/commcare_connect/microplanning/urls.py b/commcare_connect/microplanning/urls.py index 82f7f2d0d3..3392035696 100644 --- a/commcare_connect/microplanning/urls.py +++ b/commcare_connect/microplanning/urls.py @@ -56,6 +56,7 @@ views.implementation_areas_geojson, name="implementation_areas_geojson", ), + path("/buildings/", views.buildings_geojson, name="buildings_geojson"), path("/cluster_work_areas/", views.cluster_work_areas, name="cluster_work_areas"), path("/clear_work_areas/", views.clear_work_areas, name="clear_work_areas"), path("/clear_work_area_groups/", views.clear_work_area_groups, name="clear_work_area_groups"), diff --git a/commcare_connect/microplanning/views.py b/commcare_connect/microplanning/views.py index 80bb428961..e766ebffc0 100644 --- a/commcare_connect/microplanning/views.py +++ b/commcare_connect/microplanning/views.py @@ -47,7 +47,9 @@ from commcare_connect.commcarehq.api import create_or_update_case_by_work_area from commcare_connect.flags.flag_names import MICROPLANNING +from commcare_connect.microplanning.buildings import buildings_for_bbox, parse_bbox from commcare_connect.microplanning.const import ( + BUILDING_MIN_ZOOM, MAX_AUTOZOOM_ZOOM, MAX_EXCLUDE_WORK_AREAS, MAX_UNASSIGN_WORK_AREAS, @@ -61,6 +63,7 @@ annotate_approved_visit_counts, missing_deliver_units, ) +from commcare_connect.microplanning.exceptions import AreaTooLarge, BuildingDataUnavailable from commcare_connect.microplanning.filters import ( CoverageProgressFilterSet, UserVisitMapFilterSet, @@ -202,6 +205,10 @@ def microplanning_home(request, *args, **kwargs): kwargs={"org_slug": request.org.slug, "opp_id": opportunity.opportunity_id}, ) + buildings_url = reverse( + "microplanning:buildings_geojson", kwargs={"org_slug": request.org.slug, "opp_id": opportunity.opportunity_id} + ) + work_area_detail_url = reverse( "microplanning:work_area_detail", args=[request.org.slug, opportunity.opportunity_id, 0], @@ -262,8 +269,10 @@ def microplanning_home(request, *args, **kwargs): "visit_tiles_url": visit_tiles_url, "groups_url": groups_url, "implementation_areas_url": implementation_areas_url, + "buildings_url": buildings_url, "status_meta": status_meta, "workarea_min_zoom": WORKAREA_MIN_ZOOM, + "building_min_zoom": BUILDING_MIN_ZOOM, "max_autozoom_zoom": MAX_AUTOZOOM_ZOOM, "edit_work_area_url": edit_work_area_url, "user_visit_data_url": user_visit_data_url, @@ -839,6 +848,28 @@ def implementation_areas_geojson(request, org_slug, opp_id): return JsonResponse({"implementation_area_features": features}) +# Reading from Overture takes seconds, and ATOMIC_REQUESTS would otherwise hold a database +# transaction open for all of it. This view only reads, so it does not need one. +@transaction.non_atomic_requests +@require_GET +@org_admin_required +@opportunity_required +@waffle_flag(MICROPLANNING) +def buildings_geojson(request, org_slug, opp_id): + try: + bbox = parse_bbox(request.GET.get("bbox")) + except ValueError as e: + return JsonResponse({"error": str(e)}, status=400) + + try: + return JsonResponse(buildings_for_bbox(*bbox)) + except AreaTooLarge: + return JsonResponse({"error": _("Requested area is too large.")}, status=422) + except BuildingDataUnavailable: + logger.exception("Could not fetch building data for bbox %s", bbox) + return JsonResponse({"error": _("Building data is unavailable right now.")}, status=503) + + @org_admin_required @opportunity_required @require_POST diff --git a/commcare_connect/templates/microplanning/home.html b/commcare_connect/templates/microplanning/home.html index 8b9fbf8d85..d5817634a2 100644 --- a/commcare_connect/templates/microplanning/home.html +++ b/commcare_connect/templates/microplanning/home.html @@ -186,7 +186,7 @@

+ class="pointer-events-none absolute top-4 right-4 z-50 flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg text-sm text-white opacity-0 transition-opacity duration-300">
@@ -194,10 +194,11 @@

+ class="absolute top-20 right-2 z-40 pointer-events-none px-2 py-2 rounded-md shadow-lg border border-white/10 text-sm text-white bg-black/50"> {% translate "Click to select group ยท Shift+click to select single area" %} {% endif %} + {% include "microplanning/map_layer_toggles.html" %}
diff --git a/commcare_connect/templates/microplanning/map_handler.html b/commcare_connect/templates/microplanning/map_handler.html index a290f222d1..77f0aff426 100644 --- a/commcare_connect/templates/microplanning/map_handler.html +++ b/commcare_connect/templates/microplanning/map_handler.html @@ -1,5 +1,42 @@ {% load i18n %}