|
1 |
| -from typing import List |
| 1 | +import json |
| 2 | +from typing import Dict, List, Optional, Tuple |
2 | 3 | from uuid import UUID
|
3 | 4 |
|
4 | 5 | from asyncpg.exceptions import UniqueViolationError
|
5 | 6 | from fastapi.logger import logger
|
6 |
| -from sqlalchemy import Column, Table |
7 |
| -from sqlalchemy.sql import Select |
8 |
| -from sqlalchemy.sql.elements import TextClause |
| 7 | +from sqlalchemy import Column, Table, func |
| 8 | +from sqlalchemy.sql import Select, label |
| 9 | +from sqlalchemy.sql.elements import Label, TextClause |
9 | 10 |
|
10 | 11 | from app.application import db
|
11 |
| -from app.errors import RecordNotFoundError |
| 12 | +from app.errors import ( |
| 13 | + BadAdminSourceException, |
| 14 | + BadAdminVersionException, |
| 15 | + GeometryIsNullError, |
| 16 | + RecordNotFoundError, |
| 17 | +) |
12 | 18 | from app.models.orm.user_areas import UserArea as ORMUserArea
|
13 |
| -from app.models.pydantic.geostore import Geometry, Geostore |
| 19 | +from app.models.pydantic.geostore import ( |
| 20 | + Adm0BoundaryInfo, |
| 21 | + Adm1BoundaryInfo, |
| 22 | + Adm2BoundaryInfo, |
| 23 | + AdminGeostore, |
| 24 | + AdminGeostoreResponse, |
| 25 | + AdminListResponse, |
| 26 | + Geometry, |
| 27 | + Geostore, |
| 28 | +) |
| 29 | +from app.settings.globals import ENV, per_env_admin_boundary_versions |
| 30 | +from app.utils.gadm import extract_level_id, fix_id_pattern |
14 | 31 |
|
15 | 32 | GEOSTORE_COLUMNS: List[Column] = [
|
16 | 33 | db.column("gfw_geostore_id"),
|
@@ -115,3 +132,253 @@ async def create_user_area(geometry: Geometry) -> Geostore:
|
115 | 132 | geostore = await get_gfw_geostore_from_any_dataset(geo_id)
|
116 | 133 |
|
117 | 134 | return geostore
|
| 135 | + |
| 136 | + |
| 137 | +async def get_admin_boundary_list( |
| 138 | + admin_provider: str, admin_version: str |
| 139 | +) -> AdminListResponse: |
| 140 | + dv: Tuple[str, str] = await admin_params_to_dataset_version( |
| 141 | + admin_provider, admin_version |
| 142 | + ) |
| 143 | + dataset, version = dv |
| 144 | + |
| 145 | + src_table: Table = db.table(version) |
| 146 | + src_table.schema = dataset |
| 147 | + |
| 148 | + # What exactly is served-up by RW? It looks like it INTENDS to just |
| 149 | + # serve admin 0s, but the response contains much more |
| 150 | + where_clause: TextClause = db.text("adm_level=:adm_level").bindparams(adm_level="0") |
| 151 | + |
| 152 | + gadm_admin_list_columns: List[Column] = [ |
| 153 | + db.column("adm_level"), |
| 154 | + db.column("gfw_geostore_id"), |
| 155 | + db.column("gid_0"), |
| 156 | + db.column("country"), |
| 157 | + ] |
| 158 | + sql: Select = ( |
| 159 | + db.select(gadm_admin_list_columns) |
| 160 | + .select_from(src_table) |
| 161 | + .where(where_clause) |
| 162 | + .order_by("gid_0") |
| 163 | + ) |
| 164 | + |
| 165 | + rows = await get_all_rows(sql) |
| 166 | + |
| 167 | + return AdminListResponse.parse_obj( |
| 168 | + { |
| 169 | + "data": [ |
| 170 | + { |
| 171 | + "geostoreId": str(row.gfw_geostore_id), |
| 172 | + "iso": str(row.gid_0), |
| 173 | + "name": str(row.country), |
| 174 | + } |
| 175 | + for row in rows |
| 176 | + ], |
| 177 | + } |
| 178 | + ) |
| 179 | + |
| 180 | + |
| 181 | +async def get_all_rows(sql: Select): |
| 182 | + rows = await db.all(sql) |
| 183 | + |
| 184 | + return rows |
| 185 | + |
| 186 | + |
| 187 | +async def get_first_row(sql: Select): |
| 188 | + row = await db.first(sql) |
| 189 | + |
| 190 | + return row |
| 191 | + |
| 192 | + |
| 193 | +async def get_gadm_geostore( |
| 194 | + admin_provider: str, |
| 195 | + admin_version: str, |
| 196 | + adm_level: int, |
| 197 | + simplify: float | None, |
| 198 | + country_id: str, |
| 199 | + region_id: str | None = None, |
| 200 | + subregion_id: str | None = None, |
| 201 | +) -> AdminGeostoreResponse: |
| 202 | + dv: Tuple[str, str] = await admin_params_to_dataset_version( |
| 203 | + admin_provider, admin_version |
| 204 | + ) |
| 205 | + dataset, version = dv |
| 206 | + |
| 207 | + src_table: Table = db.table(version) |
| 208 | + src_table.schema = dataset |
| 209 | + |
| 210 | + columns_etc: List[Column | Label] = [ |
| 211 | + db.column("adm_level"), |
| 212 | + db.column("gfw_area__ha"), |
| 213 | + db.column("gfw_bbox"), |
| 214 | + db.column("gfw_geostore_id"), |
| 215 | + label("level_id", db.column(f"gid_{adm_level}")), |
| 216 | + ] |
| 217 | + |
| 218 | + if adm_level == 0: |
| 219 | + columns_etc.append(label("name", db.column("country"))) |
| 220 | + else: |
| 221 | + columns_etc.append(label("name", db.column(f"name_{adm_level}"))) |
| 222 | + |
| 223 | + if simplify is None: |
| 224 | + columns_etc.append(label("geojson", func.ST_AsGeoJSON(db.column("geom")))) |
| 225 | + else: |
| 226 | + columns_etc.append( |
| 227 | + label( |
| 228 | + "geojson", |
| 229 | + func.ST_AsGeoJSON(func.ST_Simplify(db.column("geom"), simplify)), |
| 230 | + ) |
| 231 | + ) |
| 232 | + |
| 233 | + where_clauses: List[TextClause] = [ |
| 234 | + db.text("adm_level=:adm_level").bindparams(adm_level=str(adm_level)) |
| 235 | + ] |
| 236 | + |
| 237 | + # gid_0 is just a three-character value, but all more specific ids are |
| 238 | + # followed by an underscore (which has to be escaped because normally in |
| 239 | + # SQL an underscore is a wildcard) and a revision number (for which we |
| 240 | + # use an UN-escaped underscore). |
| 241 | + level_id_pattern: str = country_id |
| 242 | + |
| 243 | + if adm_level == 0: # Special-case to avoid slow LIKE |
| 244 | + where_clauses.append( |
| 245 | + db.text("gid_0=:level_id_pattern").bindparams( |
| 246 | + level_id_pattern=level_id_pattern |
| 247 | + ) |
| 248 | + ) |
| 249 | + else: |
| 250 | + assert region_id is not None |
| 251 | + level_id_pattern = ".".join((level_id_pattern, region_id)) |
| 252 | + if adm_level >= 2: |
| 253 | + assert subregion_id is not None |
| 254 | + level_id_pattern = ".".join((level_id_pattern, subregion_id)) |
| 255 | + level_id_pattern += r"\__" |
| 256 | + |
| 257 | + # Adjust for any errata |
| 258 | + level_id_pattern = fix_id_pattern( |
| 259 | + adm_level, level_id_pattern, admin_provider, admin_version |
| 260 | + ) |
| 261 | + |
| 262 | + where_clauses.append( |
| 263 | + db.text(f"gid_{adm_level} LIKE :level_id_pattern").bindparams( |
| 264 | + level_id_pattern=level_id_pattern |
| 265 | + ) |
| 266 | + ) |
| 267 | + |
| 268 | + sql: Select = db.select(columns_etc).select_from(src_table) |
| 269 | + |
| 270 | + for clause in where_clauses: |
| 271 | + sql = sql.where(clause) |
| 272 | + |
| 273 | + row = await get_first_row(sql) |
| 274 | + if row is None: |
| 275 | + raise RecordNotFoundError( |
| 276 | + f"Admin boundary not found in {admin_provider} version {admin_version}" |
| 277 | + ) |
| 278 | + |
| 279 | + if row.geojson is None: |
| 280 | + raise GeometryIsNullError( |
| 281 | + "GeoJSON is None, try reducing or eliminating simplification." |
| 282 | + ) |
| 283 | + |
| 284 | + geostore: AdminGeostore = await form_admin_geostore( |
| 285 | + adm_level=adm_level, |
| 286 | + admin_version=admin_version, |
| 287 | + area=float(row.gfw_area__ha), |
| 288 | + bbox=[float(val) for val in row.gfw_bbox], |
| 289 | + name=str(row.name), |
| 290 | + geojson=json.loads(row.geojson), |
| 291 | + geostore_id=str(row.gfw_geostore_id), |
| 292 | + level_id=str(row.level_id), |
| 293 | + simplify=simplify, |
| 294 | + ) |
| 295 | + |
| 296 | + return AdminGeostoreResponse(data=geostore) |
| 297 | + |
| 298 | + |
| 299 | +async def admin_params_to_dataset_version( |
| 300 | + source_provider: str, source_version: str |
| 301 | +) -> Tuple[str, str]: |
| 302 | + admin_source_to_dataset: Dict[str, str] = {"GADM": "gadm_administrative_boundaries"} |
| 303 | + |
| 304 | + try: |
| 305 | + dataset: str = admin_source_to_dataset[source_provider.upper()] |
| 306 | + except KeyError: |
| 307 | + raise BadAdminSourceException( |
| 308 | + ( |
| 309 | + "Invalid admin boundary source. Valid sources:" |
| 310 | + f" {[source.lower() for source in admin_source_to_dataset.keys()]}" |
| 311 | + ) |
| 312 | + ) |
| 313 | + |
| 314 | + try: |
| 315 | + version: str = per_env_admin_boundary_versions[ENV][source_provider.upper()][ |
| 316 | + source_version |
| 317 | + ] |
| 318 | + except KeyError: |
| 319 | + raise BadAdminVersionException( |
| 320 | + ( |
| 321 | + "Invalid admin boundary version. Valid versions:" |
| 322 | + f" {[v for v in per_env_admin_boundary_versions[ENV][source_provider.upper()].keys()]}" |
| 323 | + ) |
| 324 | + ) |
| 325 | + |
| 326 | + return dataset, version |
| 327 | + |
| 328 | + |
| 329 | +async def form_admin_geostore( |
| 330 | + adm_level: int, |
| 331 | + bbox: List[float], |
| 332 | + area: float, |
| 333 | + geostore_id: str, |
| 334 | + level_id: str, |
| 335 | + simplify: Optional[float], |
| 336 | + admin_version: str, |
| 337 | + geojson: Dict, |
| 338 | + name: str, |
| 339 | +) -> AdminGeostore: |
| 340 | + info = Adm0BoundaryInfo.parse_obj( |
| 341 | + { |
| 342 | + "use": {}, |
| 343 | + "simplifyThresh": simplify, |
| 344 | + "gadm": admin_version, |
| 345 | + "name": name, |
| 346 | + "iso": extract_level_id(0, level_id), |
| 347 | + } |
| 348 | + ) |
| 349 | + if adm_level >= 1: |
| 350 | + info = Adm1BoundaryInfo( |
| 351 | + **info.dict(), |
| 352 | + id1=int(extract_level_id(1, level_id)), |
| 353 | + ) |
| 354 | + if adm_level == 2: |
| 355 | + info = Adm2BoundaryInfo( |
| 356 | + **info.dict(), |
| 357 | + id2=int(extract_level_id(2, level_id)), |
| 358 | + ) |
| 359 | + |
| 360 | + return AdminGeostore.parse_obj( |
| 361 | + { |
| 362 | + "type": "geoStore", |
| 363 | + "id": geostore_id, |
| 364 | + "attributes": { |
| 365 | + "geojson": { |
| 366 | + "crs": {}, |
| 367 | + "type": "FeatureCollection", |
| 368 | + "features": [ |
| 369 | + { |
| 370 | + "geometry": geojson, |
| 371 | + "properties": None, |
| 372 | + "type": "Feature", |
| 373 | + } |
| 374 | + ], |
| 375 | + }, |
| 376 | + "hash": geostore_id, |
| 377 | + "provider": {}, |
| 378 | + "areaHa": area, |
| 379 | + "bbox": bbox, |
| 380 | + "lock": False, |
| 381 | + "info": info.dict(), |
| 382 | + }, |
| 383 | + } |
| 384 | + ) |
0 commit comments